This commit is contained in:
Michael Kret
2022-05-30 15:35:25 +03:00
1088 changed files with 23649 additions and 15702 deletions

View File

@@ -374,27 +374,56 @@ module.exports = {
files: ['./packages/nodes-base/nodes/**/*.ts'], files: ['./packages/nodes-base/nodes/**/*.ts'],
plugins: ['eslint-plugin-n8n-nodes-base'], plugins: ['eslint-plugin-n8n-nodes-base'],
rules: { rules: {
"n8n-nodes-base/node-param-default-missing": "error", 'n8n-nodes-base/node-class-description-inputs-wrong-regular-node': 'error',
"n8n-nodes-base/node-class-description-missing-subtitle": "error", 'n8n-nodes-base/node-class-description-inputs-wrong-trigger-node': 'error',
"n8n-nodes-base/node-class-description-inputs-wrong-trigger-node": "error", 'n8n-nodes-base/node-class-description-missing-subtitle': 'error',
"n8n-nodes-base/node-class-description-inputs-wrong-regular-node": "error", 'n8n-nodes-base/node-class-description-outputs-wrong': 'error',
"n8n-nodes-base/node-class-description-outputs-wrong": "error", 'n8n-nodes-base/node-execute-block-double-assertion-for-items': 'error',
"n8n-nodes-base/node-execute-block-double-assertion-for-items": "error", 'n8n-nodes-base/node-param-collection-type-unsorted-items': 'error',
"n8n-nodes-base/node-param-default-wrong-for-collection": "error", 'n8n-nodes-base/node-param-default-missing': 'error',
"n8n-nodes-base/node-param-default-wrong-for-boolean": "error", 'n8n-nodes-base/node-param-default-wrong-for-boolean': 'error',
"n8n-nodes-base/node-param-collection-type-unsorted-items": "error", 'n8n-nodes-base/node-param-default-wrong-for-collection': 'error',
"n8n-nodes-base/node-param-default-wrong-for-fixed-collection": "error", 'n8n-nodes-base/node-param-default-wrong-for-fixed-collection': 'error',
"n8n-nodes-base/node-param-default-wrong-for-multi-options": "error", 'n8n-nodes-base/node-param-default-wrong-for-multi-options': 'error',
"n8n-nodes-base/node-param-description-excess-inner-whitespace": "error", 'n8n-nodes-base/node-param-default-wrong-for-simplify': 'error',
"n8n-nodes-base/node-param-description-empty-string": "error", 'n8n-nodes-base/node-param-description-comma-separated-hyphen': 'error',
"n8n-nodes-base/node-param-description-comma-separated-hyphen": "error", 'n8n-nodes-base/node-param-description-empty-string': 'error',
"n8n-nodes-base/node-param-default-wrong-for-simplify": "error", 'n8n-nodes-base/node-param-description-excess-final-period': 'error',
"n8n-nodes-base/node-param-description-missing-for-return-all": "error", 'n8n-nodes-base/node-param-description-excess-inner-whitespace': 'error',
"n8n-nodes-base/node-param-description-missing-final-period": "error", 'n8n-nodes-base/node-param-description-identical-to-display-name': 'error',
"n8n-nodes-base/node-param-description-missing-for-simplify": "error", 'n8n-nodes-base/node-param-description-miscased-id': 'error',
"n8n-nodes-base/node-param-description-missing-for-ignore-ssl-issues": "error", 'n8n-nodes-base/node-param-description-miscased-json': 'error',
"n8n-nodes-base/node-param-description-identical-to-display-name": "error", 'n8n-nodes-base/node-param-description-missing-final-period': 'error',
} 'n8n-nodes-base/node-param-description-missing-for-ignore-ssl-issues': 'error',
'n8n-nodes-base/node-param-description-missing-for-return-all': 'error',
'n8n-nodes-base/node-param-description-missing-for-simplify': 'error',
'n8n-nodes-base/node-param-description-missing-from-limit': 'error',
'n8n-nodes-base/node-param-description-unencoded-angle-brackets': 'error',
'n8n-nodes-base/node-param-description-unneeded-backticks': 'error',
'n8n-nodes-base/node-param-description-untrimmed': 'error',
'n8n-nodes-base/node-param-description-url-missing-protocol': 'error',
'n8n-nodes-base/node-param-description-weak': 'error',
'n8n-nodes-base/node-param-description-wrong-for-ignore-ssl-issues': 'error',
'n8n-nodes-base/node-param-description-wrong-for-limit': 'error',
'n8n-nodes-base/node-param-description-wrong-for-return-all': 'error',
'n8n-nodes-base/node-param-description-wrong-for-simplify': 'error',
'n8n-nodes-base/node-param-description-wrong-for-upsert': 'error',
'n8n-nodes-base/node-param-display-name-excess-inner-whitespace': 'error',
'n8n-nodes-base/node-param-display-name-miscased-id': 'error',
'n8n-nodes-base/node-param-display-name-untrimmed': 'error',
'n8n-nodes-base/node-param-display-name-wrong-for-simplify': 'error',
'n8n-nodes-base/node-param-display-name-wrong-for-update-fields': 'error',
'n8n-nodes-base/node-param-operation-without-no-data-expression': 'error',
'n8n-nodes-base/node-param-option-description-identical-to-name': 'error',
'n8n-nodes-base/node-param-option-name-containing-star': 'error',
'n8n-nodes-base/node-param-option-name-duplicate': 'error',
'n8n-nodes-base/node-param-option-name-wrong-for-get-all': 'error',
'n8n-nodes-base/node-param-option-value-duplicate': 'error',
'n8n-nodes-base/node-param-required-false': 'error',
'n8n-nodes-base/node-param-resource-with-plural-option': 'error',
'n8n-nodes-base/node-param-resource-without-no-data-expression': 'error',
'n8n-nodes-base/node-param-type-options-missing-from-limit': 'error',
},
}, },
], ],
}; };

View File

@@ -1,3 +1,81 @@
## [0.178.2](https://github.com/n8n-io/n8n/compare/n8n@0.178.1...n8n@0.178.2) (2022-05-25)
### Bug Fixes
* **editor:** Fix parameter loading bug ([#3374](https://github.com/n8n-io/n8n/issues/3374)) ([c7c2061](https://github.com/n8n-io/n8n/commit/c7c2061590493a1b24a8ab4e2615d6d9eb2641e1))
## [0.178.1](https://github.com/n8n-io/n8n/compare/n8n@0.178.0...n8n@0.178.1) (2022-05-24)
### Bug Fixes
* **editor:** Fix problem with HTTP Request Node 1 credentials to be set ([#3371](https://github.com/n8n-io/n8n/issues/3371)) ([c5fc3bc](https://github.com/n8n-io/n8n/commit/c5fc3bc45e80eec47f4c06b950ab8b3ddaf66f2f))
# [0.178.0](https://github.com/n8n-io/n8n/compare/n8n@0.177.0...n8n@0.178.0) (2022-05-24)
### Bug Fixes
* **editor:** Do not display diving line unless necessary ([68db12c](https://github.com/n8n-io/n8n/commit/68db12ce6d8bfc99cd0891cfa44f8b64674dada7))
* **editor:** Do not display welcome sticky in template workflows ([#3320](https://github.com/n8n-io/n8n/issues/3320)) ([29ddac3](https://github.com/n8n-io/n8n/commit/29ddac30d33caff1cf8a061d619742fdb3402d49))
* **Slack Node:** Fix Channel->Kick ([#3365](https://github.com/n8n-io/n8n/issues/3365)) ([0212d65](https://github.com/n8n-io/n8n/commit/0212d65dae885a6a153c67095f04215f5e1f8278))
### Features
* **core:** Allow credential reuse on HTTP Request node ([#3228](https://github.com/n8n-io/n8n/issues/3228)) ([336fc9e](https://github.com/n8n-io/n8n/commit/336fc9e2a820476931a9e9b482e4be284c0337d0)), closes [#3230](https://github.com/n8n-io/n8n/issues/3230) [#3231](https://github.com/n8n-io/n8n/issues/3231) [#3222](https://github.com/n8n-io/n8n/issues/3222) [#3229](https://github.com/n8n-io/n8n/issues/3229) [#3304](https://github.com/n8n-io/n8n/issues/3304) [#3282](https://github.com/n8n-io/n8n/issues/3282) [#3359](https://github.com/n8n-io/n8n/issues/3359)
* **editor:** Add input panel to NDV ([#3204](https://github.com/n8n-io/n8n/issues/3204)) ([3af0abd](https://github.com/n8n-io/n8n/commit/3af0abd9e066a721ac873f255eeb9311ebe6dd27))
* **Salesforce Node:** Add country field ([#3314](https://github.com/n8n-io/n8n/issues/3314)) ([90a1bc1](https://github.com/n8n-io/n8n/commit/90a1bc120bc2e291432c977768929da773dcb96e))
# [0.177.0](https://github.com/n8n-io/n8n/compare/n8n@0.176.0...n8n@0.177.0) (2022-05-16)
### Bug Fixes
* **core:** Fix call to `/executions-current` with unsaved workflow ([#3280](https://github.com/n8n-io/n8n/issues/3280)) ([7090a79](https://github.com/n8n-io/n8n/commit/7090a79b5da611d829da4d027a0194fcb60b4755))
* **core:** Fix issue with fixedCollection having all default values ([7ced654](https://github.com/n8n-io/n8n/commit/7ced65484fa7c91e10e96f70d6791b689a5686d3))
* **Edit Image Node:** Fix font selection ([#3287](https://github.com/n8n-io/n8n/issues/3287)) ([8a8feb1](https://github.com/n8n-io/n8n/commit/8a8feb11c8e22e6a548e077b55e40702f2fb724a))
* **Ghost Node:** Fix post tags and add credential tests ([#3278](https://github.com/n8n-io/n8n/issues/3278)) ([a14d85e](https://github.com/n8n-io/n8n/commit/a14d85ea481b8227ba306f07e13263f45eafa6ca))
* **Google Calendar Node:** Make it work with public calendars and clean up ([#3283](https://github.com/n8n-io/n8n/issues/3283)) ([a7d960c](https://github.com/n8n-io/n8n/commit/a7d960c56122bd3b602f0e9a121919916e5d6174))
* **KoBoToolbox Node:** Fix query and sort + use question name in attachments ([#3017](https://github.com/n8n-io/n8n/issues/3017)) ([c885115](https://github.com/n8n-io/n8n/commit/c8851157684fe15c77db1fe716fa4333b54450cb))
* **Mailjet Trigger Node:** Fix issue that node could not get activated ([#3281](https://github.com/n8n-io/n8n/issues/3281)) ([e09e349](https://github.com/n8n-io/n8n/commit/e09e349fedfe067929556e328a70a32d30759e4d))
* **Pipedrive Node:** Fix resolve properties when multi option field is used ([#3277](https://github.com/n8n-io/n8n/issues/3277)) ([7eb1261](https://github.com/n8n-io/n8n/commit/7eb12615cf3eebac29e3561a079451017f80de5c))
### Features
* **core:** Automatically convert Luxon Dates to string ([#3266](https://github.com/n8n-io/n8n/issues/3266)) ([3fcee14](https://github.com/n8n-io/n8n/commit/3fcee14bf5c61ec11fc1d4f30256f5ceba09e7f4))
* **editor:** Improve n8n welcome experience ([#3289](https://github.com/n8n-io/n8n/issues/3289)) ([35f2ce2](https://github.com/n8n-io/n8n/commit/35f2ce2359bb84437ad6fc68a7115081daeb46fe))
* **Google Drive Node:** Add Shared Drive support for operations upload, delete and share ([#3294](https://github.com/n8n-io/n8n/issues/3294)) ([03cdb1f](https://github.com/n8n-io/n8n/commit/03cdb1fea4fa4967eaafa861f3a9ff4ff7ca625a))
* **Microsoft OneDrive Node:** Add rename option for files and folders ([#3224](https://github.com/n8n-io/n8n/issues/3224)) ([50246d1](https://github.com/n8n-io/n8n/commit/50246d174a274fc9ba3dea44fc83c3605b4db691))
# [0.176.0](https://github.com/n8n-io/n8n/compare/n8n@0.175.1...n8n@0.176.0) (2022-05-10)
### Bug Fixes
* **core:** Fix executions list filtering by waiting status ([#3241](https://github.com/n8n-io/n8n/issues/3241)) ([71afcd6](https://github.com/n8n-io/n8n/commit/71afcd6314a73ab6cc04e22afd69e86ca764bd42))
* **core:** Improve webhook error messages ([49d0e3e](https://github.com/n8n-io/n8n/commit/49d0e3e885003b11092cf3c890847154426dee41))
* **Edit Image Node:** Make node work with binary-data-mode 'filesystem' ([#3274](https://github.com/n8n-io/n8n/issues/3274)) ([a4db0d0](https://github.com/n8n-io/n8n/commit/a4db0d051b18bc224c6cd69faeabf03cf5fba659))
### Features
* **Pipedrive Node:** Add support for filters to getAll:organization ([#3211](https://github.com/n8n-io/n8n/issues/3211)) ([1ef10dd](https://github.com/n8n-io/n8n/commit/1ef10dd23fef0b2e3e0ef76c8116d3bebc36bc4e))
* **Pushover Node:** Add 'HTML Formatting' option and credential test ([#3082](https://github.com/n8n-io/n8n/issues/3082)) ([b3dc6d9](https://github.com/n8n-io/n8n/commit/b3dc6d9d9c640f1e0f04cb56d0fabe2aafb948b6))
* **UProc Node:** Add new tools ([#3104](https://github.com/n8n-io/n8n/issues/3104)) ([ff2bf11](https://github.com/n8n-io/n8n/commit/ff2bf1112f07b7c3fd75f60e8faefdef4e2a02af))
## [0.175.1](https://github.com/n8n-io/n8n/compare/n8n@0.175.0...n8n@0.175.1) (2022-05-03) ## [0.175.1](https://github.com/n8n-io/n8n/compare/n8n@0.175.0...n8n@0.175.1) (2022-05-03)

View File

@@ -4,21 +4,16 @@
n8n is an extendable workflow automation tool. With a [fair-code](http://faircode.io) distribution model, n8n will always have visible source code, be available to self-host, and allow you to add your own custom functions, logic and apps. n8n's node-based approach makes it highly versatile, enabling you to connect anything to everything. n8n is an extendable workflow automation tool. With a [fair-code](http://faircode.io) distribution model, n8n will always have visible source code, be available to self-host, and allow you to add your own custom functions, logic and apps. n8n's node-based approach makes it highly versatile, enabling you to connect anything to everything.
<a href="https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-screenshot.png"><img src="https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-screenshot.png" width="550" alt="n8n.io - Screenshot"></a> <a href="https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-screenshot.png"><img src="https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-screenshot.png" alt="n8n.io - Screenshot"></a>
## Demo ## Demo
[:tv: A short demo (< 3 min)](https://www.youtube.com/watch?v=3w7xIMKLVAg) which shows how to create a simple workflow which [:tv: A short video (< 4 min)](https://www.youtube.com/watch?v=RpjQTGKm-ok) that goes over key concepts of creating workflows in n8n.
automatically sends a new Slack notification every time a Github repository
received or lost a star.
## Available integrations ## Available integrations
n8n has 200+ different nodes to automate workflows. The list can be found on: [https://n8n.io/nodes](https://n8n.io/nodes) n8n has 200+ different nodes to automate workflows. The list can be found on: [https://n8n.io/nodes](https://n8n.io/nodes)
## Documentation ## Documentation
The official n8n documentation can be found under: [https://docs.n8n.io](https://docs.n8n.io) The official n8n documentation can be found under: [https://docs.n8n.io](https://docs.n8n.io)
@@ -27,46 +22,36 @@ Additional information and example workflows on the n8n.io website: [https://n8n
The changelog can be found [here](https://docs.n8n.io/reference/changelog.html) and the list of breaking changes [here](https://github.com/n8n-io/n8n/blob/master/packages/cli/BREAKING-CHANGES.md). The changelog can be found [here](https://docs.n8n.io/reference/changelog.html) and the list of breaking changes [here](https://github.com/n8n-io/n8n/blob/master/packages/cli/BREAKING-CHANGES.md).
## Usage ## Usage
- :books: Learn [how to **install** and **use** it from the command line](https://github.com/n8n-io/n8n/tree/master/packages/cli/README.md) - :books: Learn [how to **install** and **use** it from the command line](https://github.com/n8n-io/n8n/tree/master/packages/cli/README.md)
- :whale: Learn [how to run n8n in **Docker**](https://github.com/n8n-io/n8n/tree/master/docker/images/n8n/README.md) - :whale: Learn [how to run n8n in **Docker**](https://github.com/n8n-io/n8n/tree/master/docker/images/n8n/README.md)
## Start ## Start
Execute: `npm run start` Execute: `npm run start`
## n8n.cloud ## n8n.cloud
Sign-up for an [n8n.cloud](https://www.n8n.cloud/) account. Sign-up for an [n8n.cloud](https://www.n8n.cloud/) account.
While n8n.cloud and n8n are the same in terms of features, n8n.cloud provides certain conveniences such as: While n8n.cloud and n8n are the same in terms of features, n8n.cloud provides certain conveniences such as:
- Not having to set up and maintain your n8n instance - Not having to set up and maintain your n8n instance
- Managed OAuth for authentication - Managed OAuth for authentication
- Easily upgrading to the newer n8n versions - Easily upgrading to the newer n8n versions
## Support ## Support
If you have problems or questions go to our forum, we will then try to help you asap: If you have problems or questions go to our forum, we will then try to help you asap:
[https://community.n8n.io](https://community.n8n.io) [https://community.n8n.io](https://community.n8n.io)
## Jobs ## Jobs
If you are interested in working for n8n and so shape the future of the project If you are interested in working for n8n and so shape the future of the project
check out our [job posts](https://apply.workable.com/n8n/) check out our [job posts](https://apply.workable.com/n8n/)
## What does n8n mean and how do you pronounce it? ## What does n8n mean and how do you pronounce it?
**Short answer:** It means "nodemation" and it is pronounced as n-eight-n. **Short answer:** It means "nodemation" and it is pronounced as n-eight-n.
@@ -81,14 +66,10 @@ However, I did not like how long the name was and I could not imagine writing
something that long every time in the CLI. That is when I then ended up on something that long every time in the CLI. That is when I then ended up on
'n8n'." - **Jan Oberhauser, Founder and CEO, n8n.io** 'n8n'." - **Jan Oberhauser, Founder and CEO, n8n.io**
## Development Setup ## Development Setup
Have you found a bug :bug: ? Or maybe you have a nice feature :sparkles: to contribute ? The [CONTRIBUTING guide](https://github.com/n8n-io/n8n/blob/master/CONTRIBUTING.md) will help you get your development environment ready in minutes. Have you found a bug :bug: ? Or maybe you have a nice feature :sparkles: to contribute ? The [CONTRIBUTING guide](https://github.com/n8n-io/n8n/blob/master/CONTRIBUTING.md) will help you get your development environment ready in minutes.
## License ## License
n8n is [fair-code](http://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). n8n is [fair-code](http://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -6,7 +6,7 @@ USER root
# Install all needed dependencies # Install all needed dependencies
RUN apk --update add --virtual build-dependencies python3 build-base ca-certificates && \ RUN apk --update add --virtual build-dependencies python3 build-base ca-certificates && \
npm_config_user=root npm install -g lerna npm_config_user=root npm install -g lerna run-script-os
WORKDIR /data WORKDIR /data

View File

@@ -4,45 +4,40 @@
n8n is a free and open [fair-code](http://faircode.io) distributed node based Workflow Automation Tool. It can be self-hosted, easily extended, and so also used with internal tools. n8n is a free and open [fair-code](http://faircode.io) distributed node based Workflow Automation Tool. It can be self-hosted, easily extended, and so also used with internal tools.
<a href="https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-screenshot.png"><img src="https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-screenshot.png" width="550" alt="n8n.io - Screenshot"></a> <a href="https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-screenshot.png"><img src="https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-screenshot.png" alt="n8n.io - Screenshot"></a>
## Contents ## Contents
- [Demo](#demo) - [Demo](#demo)
- [Available integrations](#available-integrations) - [Available integrations](#available-integrations)
- [Documentation](#documentation) - [Documentation](#documentation)
- [Start n8n in Docker](#start-n8n-in-docker) - [Start n8n in Docker](#start-n8n-in-docker)
- [Start with tunnel](#start-with-tunnel) - [Start with tunnel](#start-with-tunnel)
- [Securing n8n](#securing-n8n) - [Securing n8n](#securing-n8n)
- [Persist data](#persist-data) - [Persist data](#persist-data)
- [Passing Sensitive Data via File](#passing-sensitive-data-via-file) - [Passing Sensitive Data via File](#passing-sensitive-data-via-file)
- [Updating a Running docker-compose Instance](#updating-a-running-docker-compose-instance) - [Updating a Running docker-compose Instance](#updating-a-running-docker-compose-instance)
- [Example Setup with Lets Encrypt](#example-setup-with-lets-encrypt) - [Example Setup with Lets Encrypt](#example-setup-with-lets-encrypt)
- [What does n8n mean and how do you pronounce it](#what-does-n8n-mean-and-how-do-you-pronounce-it) - [What does n8n mean and how do you pronounce it](#what-does-n8n-mean-and-how-do-you-pronounce-it)
- [Support](#support) - [Support](#support)
- [Jobs](#jobs) - [Jobs](#jobs)
- [Upgrading](#upgrading) - [Upgrading](#upgrading)
- [License](#license) - [License](#license)
## Demo ## Demo
[:tv: A short demo (< 3 min)](https://www.youtube.com/watch?v=3w7xIMKLVAg) [:tv: A short video (< 4 min)](https://www.youtube.com/watch?v=RpjQTGKm-ok) that goes over key concepts of creating workflows in n8n.
which shows how to create a simple workflow which automatically sends a new
Slack notification every time a Github repository received or lost a star.
## Available integrations ## Available integrations
n8n has 200+ different nodes to automate workflows. The list can be found on: [https://n8n.io/nodes](https://n8n.io/nodes) n8n has 200+ different nodes to automate workflows. The list can be found on: [https://n8n.io/nodes](https://n8n.io/nodes)
## Documentation ## Documentation
The official n8n documentation can be found under: [https://docs.n8n.io](https://docs.n8n.io) The official n8n documentation can be found under: [https://docs.n8n.io](https://docs.n8n.io)
Additional information and example workflows on the n8n.io website: [https://n8n.io](https://n8n.io) Additional information and example workflows on the n8n.io website: [https://n8n.io](https://n8n.io)
## Start n8n in Docker ## Start n8n in Docker
``` ```
@@ -56,7 +51,6 @@ docker run -it --rm \
You can then access n8n by opening: You can then access n8n by opening:
[http://localhost:5678](http://localhost:5678) [http://localhost:5678](http://localhost:5678)
## Start with tunnel ## Start with tunnel
> **WARNING**: This is only meant for local development and testing. Should not be used in production! > **WARNING**: This is only meant for local development and testing. Should not be used in production!
@@ -121,12 +115,13 @@ it can not be used anymore as encrypting it is not possible anymore.
#### Use with PostgresDB #### Use with PostgresDB
Replace the following placeholders with the actual data: Replace the following placeholders with the actual data:
- POSTGRES_DATABASE
- POSTGRES_HOST - POSTGRES_DATABASE
- POSTGRES_PASSWORD - POSTGRES_HOST
- POSTGRES_PORT - POSTGRES_PASSWORD
- POSTGRES_USER - POSTGRES_PORT
- POSTGRES_SCHEMA - POSTGRES_USER
- POSTGRES_SCHEMA
``` ```
docker run -it --rm \ docker run -it --rm \
@@ -149,11 +144,12 @@ A full working setup with docker-compose can be found [here](https://github.com/
#### Use with MySQL #### Use with MySQL
Replace the following placeholders with the actual data: Replace the following placeholders with the actual data:
- MYSQLDB_DATABASE
- MYSQLDB_HOST - MYSQLDB_DATABASE
- MYSQLDB_PASSWORD - MYSQLDB_HOST
- MYSQLDB_PORT - MYSQLDB_PASSWORD
- MYSQLDB_USER - MYSQLDB_PORT
- MYSQLDB_USER
``` ```
docker run -it --rm \ docker run -it --rm \
@@ -172,20 +168,21 @@ docker run -it --rm \
## Passing Sensitive Data via File ## Passing Sensitive Data via File
To avoid passing sensitive information via environment variables "_FILE" may be To avoid passing sensitive information via environment variables "\_FILE" may be
appended to some environment variables. It will then load the data from a file appended to some environment variables. It will then load the data from a file
with the given name. That makes it possible to load data easily from with the given name. That makes it possible to load data easily from
Docker- and Kubernetes-Secrets. Docker- and Kubernetes-Secrets.
The following environment variables support file input: The following environment variables support file input:
- DB_POSTGRESDB_DATABASE_FILE
- DB_POSTGRESDB_HOST_FILE - DB_POSTGRESDB_DATABASE_FILE
- DB_POSTGRESDB_PASSWORD_FILE - DB_POSTGRESDB_HOST_FILE
- DB_POSTGRESDB_PORT_FILE - DB_POSTGRESDB_PASSWORD_FILE
- DB_POSTGRESDB_USER_FILE - DB_POSTGRESDB_PORT_FILE
- DB_POSTGRESDB_SCHEMA_FILE - DB_POSTGRESDB_USER_FILE
- N8N_BASIC_AUTH_PASSWORD_FILE - DB_POSTGRESDB_SCHEMA_FILE
- N8N_BASIC_AUTH_USER_FILE - N8N_BASIC_AUTH_PASSWORD_FILE
- N8N_BASIC_AUTH_USER_FILE
## Example Setup with Lets Encrypt ## Example Setup with Lets Encrypt
@@ -214,6 +211,7 @@ some scripts and commands return like `$ date`. The system timezone can be set v
the environment variable `TZ`. the environment variable `TZ`.
Example to use the same timezone for both: Example to use the same timezone for both:
``` ```
docker run -it --rm \ docker run -it --rm \
--name n8n \ --name n8n \
@@ -223,7 +221,6 @@ docker run -it --rm \
n8nio/n8n n8nio/n8n
``` ```
## Build Docker-Image ## Build Docker-Image
``` ```
@@ -233,7 +230,6 @@ docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg N8N_VERSION=0.114.0 -t n8nio/n8n:0.114.0 . docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg N8N_VERSION=0.114.0 -t n8nio/n8n:0.114.0 .
``` ```
## What does n8n mean and how do you pronounce it? ## What does n8n mean and how do you pronounce it?
**Short answer:** It means "nodemation" and it is pronounced as n-eight-n. **Short answer:** It means "nodemation" and it is pronounced as n-eight-n.
@@ -249,32 +245,22 @@ something that long every time in the CLI. That is when I then ended up on
"n8n". Sure does not work perfectly but does neither for Kubernetes (k8s) and "n8n". Sure does not work perfectly but does neither for Kubernetes (k8s) and
did not hear anybody complain there. So I guess it should be ok. did not hear anybody complain there. So I guess it should be ok.
## Support ## Support
If you have problems or questions go to our forum, we will then try to help you asap: If you have problems or questions go to our forum, we will then try to help you asap:
[https://community.n8n.io](https://community.n8n.io) [https://community.n8n.io](https://community.n8n.io)
## Jobs ## Jobs
If you are interested in working for n8n and so shape the future of the project If you are interested in working for n8n and so shape the future of the project
check out our [job posts](https://apply.workable.com/n8n/) check out our [job posts](https://apply.workable.com/n8n/)
## Upgrading ## Upgrading
Before you upgrade to the latest version make sure to check here if there are any breaking changes which concern you: Before you upgrade to the latest version make sure to check here if there are any breaking changes which concern you:
[Breaking Changes](https://github.com/n8n-io/n8n/blob/master/packages/cli/BREAKING-CHANGES.md) [Breaking Changes](https://github.com/n8n-io/n8n/blob/master/packages/cli/BREAKING-CHANGES.md)
## License ## License
n8n is [fair-code](http://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). n8n is [fair-code](http://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md).

14402
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "n8n", "name": "n8n",
"version": "0.175.1", "version": "0.178.2",
"private": true, "private": true,
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
"scripts": { "scripts": {

View File

@@ -4,18 +4,18 @@
n8n is a free and open [fair-code](http://faircode.io) distributed node-based Workflow Automation Tool. You can self-host n8n, easily extend it, and even use it with internal tools. n8n is a free and open [fair-code](http://faircode.io) distributed node-based Workflow Automation Tool. You can self-host n8n, easily extend it, and even use it with internal tools.
<a href="https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-screenshot.png"><img src="https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-screenshot.png" width="550" alt="n8n.io - Screenshot"></a> <a href="https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-screenshot.png"><img src="https://raw.githubusercontent.com/n8n-io/n8n/master/assets/n8n-screenshot.png" alt="n8n.io - Screenshot"></a>
## Contents ## Contents
<!-- TOC --> <!-- TOC -->
- [Demo](#demo) - [Demo](#demo)
- [Getting Started](#getting-started) - [Getting Started](#getting-started)
- [Use npx](#use-npx) - [Use npx](#use-npx)
- [Run with Docker](#run-with-docker) - [Run with Docker](#run-with-docker)
- [Install with npm](#install-with-npm) - [Install with npm](#install-with-npm)
- [Sign-up on n8n.cloud](#sign-up-on-n8n.cloud) - [Sign-up on n8n.cloud](#sign-up-on-n8n.cloud)
- [Available integrations](#available-integrations) - [Available integrations](#available-integrations)
- [Documentation](#documentation) - [Documentation](#documentation)
- [Create Custom Nodes](#create-custom-nodes) - [Create Custom Nodes](#create-custom-nodes)
@@ -29,7 +29,7 @@ n8n is a free and open [fair-code](http://faircode.io) distributed node-based Wo
## Demo ## Demo
📺 Here's a [short demo (<3 min)](https://www.youtube.com/watch?v=3w7xIMKLVAg) that shows how to create a simple workflow to automatically sends a notification on Slack every time a GitHub repository gets starred or un-starred. 📺 Here's a [:tv: short video (< 4 min)](https://www.youtube.com/watch?v=RpjQTGKm-ok) that goes over key concepts of creating workflows in n8n.
## Getting Started ## Getting Started
@@ -96,6 +96,7 @@ n8n start
Sign-up for an [n8n.cloud](https://www.n8n.cloud/) account. Sign-up for an [n8n.cloud](https://www.n8n.cloud/) account.
While n8n.cloud and n8n are the same in terms of features, n8n.cloud provides certain conveniences such as: While n8n.cloud and n8n are the same in terms of features, n8n.cloud provides certain conveniences such as:
- Not having to set up and maintain your n8n instance - Not having to set up and maintain your n8n instance
- Managed OAuth for authentication - Managed OAuth for authentication
- Easily upgrading to the newer n8n versions - Easily upgrading to the newer n8n versions
@@ -110,16 +111,15 @@ To learn more about n8n, refer to the official documentation here: [https://docs
You can find additional information and example workflows on the [n8n.io](https://n8n.io) website. You can find additional information and example workflows on the [n8n.io](https://n8n.io) website.
## Create Custom Nodes ## Create Custom Nodes
You can create custom nodes for n8n. Follow the instructions mentioned in the documentation to create your node: [Creating nodes](https://docs.n8n.io/nodes/creating-nodes/create-node.html) You can create custom nodes for n8n. Follow the instructions mentioned in the documentation to create your node: [Creating nodes](https://docs.n8n.io/nodes/creating-nodes/create-node.html)
## Contributing ## Contributing
🐛 Did you find a bug? 🐛 Did you find a bug?
Do you want to contribute a feature? ✨ Do you want to contribute a feature?
The [CONTRIBUTING guide](https://github.com/n8n-io/n8n/blob/master/CONTRIBUTING.md) will help you set up your development environment. The [CONTRIBUTING guide](https://github.com/n8n-io/n8n/blob/master/CONTRIBUTING.md) will help you set up your development environment.
@@ -141,7 +141,6 @@ If you run into issues or have any questions reach out to us via our community f
If you are interested in working at n8n and building the project, check out the [job openings](https://apply.workable.com/n8n/). If you are interested in working at n8n and building the project, check out the [job openings](https://apply.workable.com/n8n/).
## Upgrading ## Upgrading
Before you upgrade to the latest version, make sure to check the changelogs: [Changelog](https://docs.n8n.io/reference/changelog.html) Before you upgrade to the latest version, make sure to check the changelogs: [Changelog](https://docs.n8n.io/reference/changelog.html)

View File

@@ -122,10 +122,15 @@ export class Worker extends Command {
const executionDb = await Db.collections.Execution.findOne(jobData.executionId); const executionDb = await Db.collections.Execution.findOne(jobData.executionId);
if (!executionDb) { if (!executionDb) {
LoggerProxy.error('Worker failed to find execution data in database. Cannot continue.', { LoggerProxy.error(
executionId: jobData.executionId, `Worker failed to find data of execution "${jobData.executionId}" in database. Cannot continue.`,
}); {
throw new Error('Unable to find execution data in database. Aborting execution.'); executionId: jobData.executionId,
},
);
throw new Error(
`Unable to find data of execution "${jobData.executionId}" in database. Aborting execution.`,
);
} }
const currentExecutionDb = ResponseHelper.unflattenExecutionData(executionDb); const currentExecutionDb = ResponseHelper.unflattenExecutionData(executionDb);
LoggerProxy.info( LoggerProxy.info(

View File

@@ -179,6 +179,12 @@ export const schema = {
default: 'My workflow', default: 'My workflow',
env: 'WORKFLOWS_DEFAULT_NAME', env: 'WORKFLOWS_DEFAULT_NAME',
}, },
onboardingFlowDisabled: {
doc: 'Show onboarding flow in new workflow',
format: 'Boolean',
default: false,
env: 'N8N_ONBOARDING_FLOW_DISABLED',
},
}, },
executions: { executions: {
@@ -766,7 +772,7 @@ export const schema = {
endpoint: { endpoint: {
doc: 'Endpoint to retrieve version information from.', doc: 'Endpoint to retrieve version information from.',
format: String, format: String,
default: 'https://api.n8n.io/versions/', default: 'https://api.n8n.io/api/versions/',
env: 'N8N_VERSION_NOTIFICATIONS_ENDPOINT', env: 'N8N_VERSION_NOTIFICATIONS_ENDPOINT',
}, },
infoUrl: { infoUrl: {
@@ -787,7 +793,7 @@ export const schema = {
host: { host: {
doc: 'Endpoint host to retrieve workflow templates from endpoints.', doc: 'Endpoint host to retrieve workflow templates from endpoints.',
format: String, format: String,
default: 'https://api.n8n.io/', default: 'https://api.n8n.io/api/',
env: 'N8N_TEMPLATES_HOST', env: 'N8N_TEMPLATES_HOST',
}, },
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "n8n", "name": "n8n",
"version": "0.175.1", "version": "0.179.0",
"description": "n8n Workflow Automation Tool", "description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
@@ -19,7 +19,9 @@
"bin": "n8n" "bin": "n8n"
}, },
"scripts": { "scripts": {
"build": "tsc && cp -r ./src/UserManagement/email/templates ./dist/src/UserManagement/email", "build": "run-script-os",
"build:default": "tsc && cp -r ./src/UserManagement/email/templates ./dist/src/UserManagement/email",
"build:windows": "tsc && xcopy /E /I src\\UserManagement\\email\\templates dist\\src\\UserManagement\\email\\templates",
"dev": "concurrently -k -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold\" \"npm run watch\" \"nodemon\"", "dev": "concurrently -k -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold\" \"npm run watch\" \"nodemon\"",
"format": "cd ../.. && node_modules/prettier/bin-prettier.js packages/cli/**/**.ts --write", "format": "cd ../.. && node_modules/prettier/bin-prettier.js packages/cli/**/**.ts --write",
"lint": "cd ../.. && node_modules/eslint/bin/eslint.js packages/cli", "lint": "cd ../.. && node_modules/eslint/bin/eslint.js packages/cli",
@@ -30,9 +32,10 @@
"start:default": "cd bin && ./n8n", "start:default": "cd bin && ./n8n",
"start:windows": "cd bin && n8n", "start:windows": "cd bin && n8n",
"test": "npm run test:sqlite", "test": "npm run test:sqlite",
"test:sqlite": "export N8N_LOG_LEVEL='silent'; export DB_TYPE=sqlite; jest", "test:sqlite": "export N8N_LOG_LEVEL=silent; export DB_TYPE=sqlite; jest",
"test:postgres": "export N8N_LOG_LEVEL='silent'; export DB_TYPE=postgresdb && jest", "test:postgres": "export N8N_LOG_LEVEL=silent; export DB_TYPE=postgresdb; jest",
"test:mysql": "export N8N_LOG_LEVEL='silent'; export DB_TYPE=mysqldb && jest", "test:postgres:alt-schema": "export DB_POSTGRESDB_SCHEMA=alt_schema; npm run test:postgres",
"test:mysql": "export N8N_LOG_LEVEL=silent; export DB_TYPE=mysqldb; jest",
"watch": "tsc --watch", "watch": "tsc --watch",
"typeorm": "ts-node ../../node_modules/typeorm/cli.js" "typeorm": "ts-node ../../node_modules/typeorm/cli.js"
}, },
@@ -125,10 +128,10 @@
"lodash.get": "^4.4.2", "lodash.get": "^4.4.2",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"mysql2": "~2.3.0", "mysql2": "~2.3.0",
"n8n-core": "~0.116.0", "n8n-core": "~0.119.0",
"n8n-editor-ui": "~0.142.1", "n8n-editor-ui": "~0.145.0",
"n8n-nodes-base": "~0.173.0", "n8n-nodes-base": "~0.177.0",
"n8n-workflow": "~0.98.0", "n8n-workflow": "~0.101.0",
"nodemailer": "^6.7.1", "nodemailer": "^6.7.1",
"oauth-1.0a": "^2.2.6", "oauth-1.0a": "^2.2.6",
"open": "^7.0.0", "open": "^7.0.0",

View File

@@ -20,6 +20,7 @@ import {
IGetExecuteTriggerFunctions, IGetExecuteTriggerFunctions,
INode, INode,
INodeExecutionData, INodeExecutionData,
IRun,
IRunExecutionData, IRunExecutionData,
IWorkflowExecuteAdditionalData as IWorkflowExecuteAdditionalDataWorkflow, IWorkflowExecuteAdditionalData as IWorkflowExecuteAdditionalDataWorkflow,
NodeHelpers, NodeHelpers,
@@ -52,6 +53,9 @@ import config from '../config';
import { User } from './databases/entities/User'; import { User } from './databases/entities/User';
import { whereClause } from './WorkflowHelpers'; import { whereClause } from './WorkflowHelpers';
import { WorkflowEntity } from './databases/entities/WorkflowEntity'; import { WorkflowEntity } from './databases/entities/WorkflowEntity';
import * as ActiveExecutions from './ActiveExecutions';
const activeExecutions = ActiveExecutions.getInstance();
const WEBHOOK_PROD_UNREGISTERED_HINT = `The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)`; const WEBHOOK_PROD_UNREGISTERED_HINT = `The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)`;
@@ -134,22 +138,24 @@ export class ActiveWorkflowRunner {
* @memberof ActiveWorkflowRunner * @memberof ActiveWorkflowRunner
*/ */
async removeAll(): Promise<void> { async removeAll(): Promise<void> {
const activeWorkflowId: string[] = []; let activeWorkflowIds: string[] = [];
Logger.verbose('Call to remove all active workflows received (removeAll)'); Logger.verbose('Call to remove all active workflows received (removeAll)');
if (this.activeWorkflows !== null) { if (this.activeWorkflows !== null) {
// TODO: This should be renamed! activeWorkflowIds.push.apply(activeWorkflowIds, this.activeWorkflows.allActiveWorkflows());
activeWorkflowId.push.apply(activeWorkflowId, this.activeWorkflows.allActiveWorkflows());
} }
const activeWorkflows = await this.getActiveWorkflows(); const activeWorkflows = await this.getActiveWorkflows();
activeWorkflowId.push.apply( activeWorkflowIds = [
activeWorkflowId, ...activeWorkflowIds,
activeWorkflows.map((workflow) => workflow.id), ...activeWorkflows.map((workflow) => workflow.id.toString()),
); ];
// Make sure IDs are unique
activeWorkflowIds = Array.from(new Set(activeWorkflowIds));
const removePromises = []; const removePromises = [];
for (const workflowId of activeWorkflowId) { for (const workflowId of activeWorkflowIds) {
removePromises.push(this.remove(workflowId)); removePromises.push(this.remove(workflowId));
} }
@@ -673,14 +679,31 @@ export class ActiveWorkflowRunner {
returnFunctions.emit = ( returnFunctions.emit = (
data: INodeExecutionData[][], data: INodeExecutionData[][],
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>, responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
donePromise?: IDeferredPromise<IRun | undefined>,
): void => { ): void => {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
Logger.debug(`Received trigger for workflow "${workflow.name}"`); Logger.debug(`Received trigger for workflow "${workflow.name}"`);
WorkflowHelpers.saveStaticData(workflow); WorkflowHelpers.saveStaticData(workflow);
// eslint-disable-next-line id-denylist // eslint-disable-next-line id-denylist
this.runWorkflow(workflowData, node, data, additionalData, mode, responsePromise).catch( const executePromise = this.runWorkflow(
(error) => console.error(error), workflowData,
node,
data,
additionalData,
mode,
responsePromise,
); );
if (donePromise) {
executePromise.then((executionId) => {
activeExecutions
.getPostExecutePromise(executionId)
.then(donePromise.resolve)
.catch(donePromise.reject);
});
} else {
executePromise.catch(console.error);
}
}; };
returnFunctions.emitError = async (error: Error): Promise<void> => { returnFunctions.emitError = async (error: Error): Promise<void> => {
await this.activeWorkflows?.remove(workflowData.id.toString()); await this.activeWorkflows?.remove(workflowData.id.toString());

View File

@@ -3,6 +3,7 @@ import {
ICredentialTypeData, ICredentialTypeData,
ICredentialTypes as ICredentialTypesInterface, ICredentialTypes as ICredentialTypesInterface,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { RESPONSE_ERROR_MESSAGES } from './constants';
class CredentialTypesClass implements ICredentialTypesInterface { class CredentialTypesClass implements ICredentialTypesInterface {
credentialTypes: ICredentialTypeData = {}; credentialTypes: ICredentialTypeData = {};
@@ -16,7 +17,11 @@ class CredentialTypesClass implements ICredentialTypesInterface {
} }
getByName(credentialType: string): ICredentialType { getByName(credentialType: string): ICredentialType {
return this.credentialTypes[credentialType].type; try {
return this.credentialTypes[credentialType].type;
} catch (error) {
throw new Error(`${RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL}: ${credentialType}`);
}
} }
} }

View File

@@ -170,7 +170,7 @@ export async function generateUniqueName(
// name is unique // name is unique
if (found.length === 0) { if (found.length === 0) {
return { name: requestedName }; return requestedName;
} }
const maxSuffix = found.reduce((acc, { name }) => { const maxSuffix = found.reduce((acc, { name }) => {
@@ -190,10 +190,10 @@ export async function generateUniqueName(
// name is duplicate but no numeric suffixes exist yet // name is duplicate but no numeric suffixes exist yet
if (maxSuffix === 0) { if (maxSuffix === 0) {
return { name: `${requestedName} 2` }; return `${requestedName} 2`;
} }
return { name: `${requestedName} ${maxSuffix + 1}` }; return `${requestedName} ${maxSuffix + 1}`;
} }
export async function validateEntity( export async function validateEntity(

View File

@@ -475,6 +475,10 @@ export interface IPersonalizationSurveyAnswers {
workArea: string[] | string | null; workArea: string[] | string | null;
} }
export interface IUserSettings {
isOnboarded?: boolean;
}
export interface IUserManagementSettings { export interface IUserManagementSettings {
enabled: boolean; enabled: boolean;
showSetupOnFirstLoad?: boolean; showSetupOnFirstLoad?: boolean;

View File

@@ -138,7 +138,7 @@ import * as TagHelpers from './TagHelpers';
import { InternalHooksManager } from './InternalHooksManager'; import { InternalHooksManager } from './InternalHooksManager';
import { TagEntity } from './databases/entities/TagEntity'; import { TagEntity } from './databases/entities/TagEntity';
import { WorkflowEntity } from './databases/entities/WorkflowEntity'; import { WorkflowEntity } from './databases/entities/WorkflowEntity';
import { getSharedWorkflowIds, whereClause } from './WorkflowHelpers'; import { getSharedWorkflowIds, isBelowOnboardingThreshold, whereClause } from './WorkflowHelpers';
import { getCredentialTranslationPath, getNodeTranslationPath } from './TranslationHelpers'; import { getCredentialTranslationPath, getNodeTranslationPath } from './TranslationHelpers';
import { WEBHOOK_METHODS } from './WebhookHelpers'; import { WEBHOOK_METHODS } from './WebhookHelpers';
@@ -911,7 +911,14 @@ class App {
const requestedName = const requestedName =
req.query.name && req.query.name !== '' ? req.query.name : this.defaultWorkflowName; req.query.name && req.query.name !== '' ? req.query.name : this.defaultWorkflowName;
return await GenericHelpers.generateUniqueName(requestedName, 'workflow'); const name = await GenericHelpers.generateUniqueName(requestedName, 'workflow');
const onboardingFlowEnabled =
!config.getEnv('workflows.onboardingFlowDisabled') &&
!req.user.settings?.isOnboarded &&
(await isBelowOnboardingThreshold(req.user));
return { name, onboardingFlowEnabled };
}), }),
); );
@@ -1449,7 +1456,7 @@ class App {
if (defaultLocale === 'en') { if (defaultLocale === 'en') {
return nodeInfos.reduce<INodeTypeDescription[]>((acc, { name, version }) => { return nodeInfos.reduce<INodeTypeDescription[]>((acc, { name, version }) => {
const { description } = NodeTypes().getByNameAndVersion(name, version); const { description } = NodeTypes().getByNameAndVersion(name, version);
acc.push(description); acc.push(injectCustomApiCallOption(description));
return acc; return acc;
}, []); }, []);
} }
@@ -1473,7 +1480,7 @@ class App {
// ignore - no translation exists at path // ignore - no translation exists at path
} }
nodeTypes.push(description); nodeTypes.push(injectCustomApiCallOption(description));
} }
const nodeTypes: INodeTypeDescription[] = []; const nodeTypes: INodeTypeDescription[] = [];
@@ -2269,7 +2276,7 @@ class App {
let filterToAdd = {}; let filterToAdd = {};
if (key === 'waitTill') { if (key === 'waitTill') {
filterToAdd = { waitTill: !IsNull() }; filterToAdd = { waitTill: Not(IsNull()) };
} else if (key === 'finished' && value === false) { } else if (key === 'finished' && value === false) {
filterToAdd = { finished: false, waitTill: IsNull() }; filterToAdd = { finished: false, waitTill: IsNull() };
} else { } else {
@@ -2658,7 +2665,8 @@ class App {
for (const data of executingWorkflows) { for (const data of executingWorkflows) {
if ( if (
(filter.workflowId !== undefined && filter.workflowId !== data.workflowId) || (filter.workflowId !== undefined && filter.workflowId !== data.workflowId) ||
!sharedWorkflowIds.includes(data.workflowId.toString()) (data.workflowId !== undefined &&
!sharedWorkflowIds.includes(data.workflowId.toString()))
) { ) {
continue; continue;
} }
@@ -3106,3 +3114,58 @@ async function getExecutionsCount(
return { count, estimated: false }; return { count, estimated: false };
} }
const CUSTOM_API_CALL_NAME = 'Custom API Call';
const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__';
/**
* Inject a `Custom API Call` option into `resource` and `operation`
* parameters in a node that supports proxy auth.
*/
function injectCustomApiCallOption(description: INodeTypeDescription) {
if (!supportsProxyAuth(description)) return description;
description.properties.forEach((p) => {
if (
['resource', 'operation'].includes(p.name) &&
Array.isArray(p.options) &&
p.options[p.options.length - 1].name !== CUSTOM_API_CALL_NAME
) {
p.options.push({
name: CUSTOM_API_CALL_NAME,
value: CUSTOM_API_CALL_KEY,
});
}
return p;
});
return description;
}
const credentialTypes = CredentialTypes();
/**
* Whether any of the node's credential types may be used to
* make a request from a node other than itself.
*/
function supportsProxyAuth(description: INodeTypeDescription) {
if (!description.credentials) return false;
return description.credentials.some(({ name }) => {
const credType = credentialTypes.getByName(name);
if (credType.authenticate !== undefined) return true;
return isOAuth(credType);
});
}
function isOAuth(credType: ICredentialType) {
return (
Array.isArray(credType.extends) &&
credType.extends.some((parentType) =>
['oAuth2Api', 'googleOAuth2Api', 'oAuth1Api'].includes(parentType),
)
);
}

View File

@@ -208,7 +208,7 @@ export async function checkPermissionsForExecution(
// then both arrays (allowed credentials vs used credentials) // then both arrays (allowed credentials vs used credentials)
// must be the same length // must be the same length
if (ids.length !== credentialCount) { if (ids.length !== credentialCount) {
throw new Error('One or more of the used credentials are not accessable.'); throw new Error('One or more of the used credentials are not accessible.');
} }
return true; return true;
} }

View File

@@ -9,6 +9,7 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions */ /* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable no-restricted-syntax */ /* eslint-disable no-restricted-syntax */
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import { In } from 'typeorm';
import { import {
IDataObject, IDataObject,
IExecuteData, IExecuteData,
@@ -596,3 +597,52 @@ export async function getSharedWorkflowIds(user: User): Promise<number[]> {
return sharedWorkflows.map(({ workflow }) => workflow.id); return sharedWorkflows.map(({ workflow }) => workflow.id);
} }
/**
* Check if user owns more than 15 workflows or more than 2 workflows with at least 2 nodes.
* If user does, set flag in its settings.
*/
export async function isBelowOnboardingThreshold(user: User): Promise<boolean> {
let belowThreshold = true;
const skippedTypes = ['n8n-nodes-base.start', 'n8n-nodes-base.stickyNote'];
const workflowOwnerRole = await Db.collections.Role.findOne({
name: 'owner',
scope: 'workflow',
});
const ownedWorkflowsIds = await Db.collections.SharedWorkflow.find({
user,
role: workflowOwnerRole,
}).then((ownedWorkflows) => ownedWorkflows.map((wf) => wf.workflowId));
if (ownedWorkflowsIds.length > 15) {
belowThreshold = false;
} else {
// just fetch workflows' nodes to keep memory footprint low
const workflows = await Db.collections.Workflow.find({
where: { id: In(ownedWorkflowsIds) },
select: ['nodes'],
});
// valid workflow: 2+ nodes without start node
const validWorkflowCount = workflows.reduce((counter, workflow) => {
if (counter <= 2 && workflow.nodes.length > 2) {
const nodes = workflow.nodes.filter((node) => !skippedTypes.includes(node.type));
if (nodes.length >= 2) {
return counter + 1;
}
}
return counter;
}, 0);
// more than 2 valid workflows required
belowThreshold = validWorkflowCount <= 2;
}
// user is above threshold --> set flag in settings
if (!belowThreshold) {
void Db.collections.User.update(user.id, { settings: { isOnboarded: true } });
}
return belowThreshold;
}

View File

@@ -98,10 +98,12 @@ credentialsController.get(
ResponseHelper.send(async (req: CredentialRequest.NewName): Promise<{ name: string }> => { ResponseHelper.send(async (req: CredentialRequest.NewName): Promise<{ name: string }> => {
const { name: newName } = req.query; const { name: newName } = req.query;
return GenericHelpers.generateUniqueName( return {
newName ?? config.getEnv('credentials.defaultName'), name: await GenericHelpers.generateUniqueName(
'credentials', newName ?? config.getEnv('credentials.defaultName'),
); 'credentials',
),
};
}), }),
); );

View File

@@ -16,7 +16,7 @@ import {
} from 'typeorm'; } from 'typeorm';
import { IsEmail, IsString, Length } from 'class-validator'; import { IsEmail, IsString, Length } from 'class-validator';
import * as config from '../../../config'; import * as config from '../../../config';
import { DatabaseType, IPersonalizationSurveyAnswers } from '../..'; import { DatabaseType, IPersonalizationSurveyAnswers, IUserSettings } from '../..';
import { Role } from './Role'; import { Role } from './Role';
import { SharedWorkflow } from './SharedWorkflow'; import { SharedWorkflow } from './SharedWorkflow';
import { SharedCredentials } from './SharedCredentials'; import { SharedCredentials } from './SharedCredentials';
@@ -102,6 +102,12 @@ export class User {
}) })
personalizationAnswers: IPersonalizationSurveyAnswers | null; personalizationAnswers: IPersonalizationSurveyAnswers | null;
@Column({
type: resolveDataType('json') as ColumnOptions['type'],
nullable: true,
})
settings: IUserSettings | null;
@ManyToOne(() => Role, (role) => role.globalForUsers, { @ManyToOne(() => Role, (role) => role.globalForUsers, {
cascade: true, cascade: true,
nullable: false, nullable: false,

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as config from '../../../../config';
export class AddUserSettings1652367743993 implements MigrationInterface {
name = 'AddUserSettings1652367743993';
public async up(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query(
'ALTER TABLE `' + tablePrefix + 'user` ADD COLUMN `settings` json NULL DEFAULT NULL',
);
await queryRunner.query(
'ALTER TABLE `' +
tablePrefix +
'user` CHANGE COLUMN `personalizationAnswers` `personalizationAnswers` json NULL DEFAULT NULL',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query('ALTER TABLE `' + tablePrefix + 'user` DROP COLUMN `settings`');
}
}

View File

@@ -13,6 +13,7 @@ import { UpdateWorkflowCredentials1630451444017 } from './1630451444017-UpdateWo
import { AddExecutionEntityIndexes1644424784709 } from './1644424784709-AddExecutionEntityIndexes'; import { AddExecutionEntityIndexes1644424784709 } from './1644424784709-AddExecutionEntityIndexes';
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail'; import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
export const mysqlMigrations = [ export const mysqlMigrations = [
InitialMigration1588157391238, InitialMigration1588157391238,
@@ -30,4 +31,5 @@ export const mysqlMigrations = [
AddExecutionEntityIndexes1644424784709, AddExecutionEntityIndexes1644424784709,
CreateUserManagement1646992772331, CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343, LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
]; ];

View File

@@ -14,6 +14,8 @@ export class InitialMigration1587669153312 implements MigrationInterface {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}credentials_entity ("id" SERIAL NOT NULL, "name" character varying(128) NOT NULL, "data" text NOT NULL, "type" character varying(32) NOT NULL, "nodesAccess" json NOT NULL, "createdAt" TIMESTAMP NOT NULL, "updatedAt" TIMESTAMP NOT NULL, CONSTRAINT PK_${tablePrefixIndex}814c3d3c36e8a27fa8edb761b0e PRIMARY KEY ("id"))`, undefined); await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}credentials_entity ("id" SERIAL NOT NULL, "name" character varying(128) NOT NULL, "data" text NOT NULL, "type" character varying(32) NOT NULL, "nodesAccess" json NOT NULL, "createdAt" TIMESTAMP NOT NULL, "updatedAt" TIMESTAMP NOT NULL, CONSTRAINT PK_${tablePrefixIndex}814c3d3c36e8a27fa8edb761b0e PRIMARY KEY ("id"))`, undefined);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixIndex}07fde106c0b471d8cc80a64fc8 ON ${tablePrefix}credentials_entity (type) `, undefined); await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixIndex}07fde106c0b471d8cc80a64fc8 ON ${tablePrefix}credentials_entity (type) `, undefined);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}execution_entity ("id" SERIAL NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" character varying NOT NULL, "retryOf" character varying, "retrySuccessId" character varying, "startedAt" TIMESTAMP NOT NULL, "stoppedAt" TIMESTAMP NOT NULL, "workflowData" json NOT NULL, "workflowId" character varying, CONSTRAINT PK_${tablePrefixIndex}e3e63bbf986767844bbe1166d4e PRIMARY KEY ("id"))`, undefined); await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}execution_entity ("id" SERIAL NOT NULL, "data" text NOT NULL, "finished" boolean NOT NULL, "mode" character varying NOT NULL, "retryOf" character varying, "retrySuccessId" character varying, "startedAt" TIMESTAMP NOT NULL, "stoppedAt" TIMESTAMP NOT NULL, "workflowData" json NOT NULL, "workflowId" character varying, CONSTRAINT PK_${tablePrefixIndex}e3e63bbf986767844bbe1166d4e PRIMARY KEY ("id"))`, undefined);
@@ -29,6 +31,8 @@ export class InitialMigration1587669153312 implements MigrationInterface {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query(`DROP TABLE ${tablePrefix}workflow_entity`, undefined); await queryRunner.query(`DROP TABLE ${tablePrefix}workflow_entity`, undefined);
await queryRunner.query(`DROP INDEX IDX_${tablePrefixIndex}c4d999a5e90784e8caccf5589d`, undefined); await queryRunner.query(`DROP INDEX IDX_${tablePrefixIndex}c4d999a5e90784e8caccf5589d`, undefined);
await queryRunner.query(`DROP TABLE ${tablePrefix}execution_entity`, undefined); await queryRunner.query(`DROP TABLE ${tablePrefix}execution_entity`, undefined);

View File

@@ -16,6 +16,8 @@ export class WebhookModel1589476000887 implements MigrationInterface {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}webhook_entity ("workflowId" integer NOT NULL, "webhookPath" character varying NOT NULL, "method" character varying NOT NULL, "node" character varying NOT NULL, CONSTRAINT "PK_${tablePrefixIndex}b21ace2e13596ccd87dc9bf4ea6" PRIMARY KEY ("webhookPath", "method"))`, undefined); await queryRunner.query(`CREATE TABLE IF NOT EXISTS ${tablePrefix}webhook_entity ("workflowId" integer NOT NULL, "webhookPath" character varying NOT NULL, "method" character varying NOT NULL, "node" character varying NOT NULL, CONSTRAINT "PK_${tablePrefixIndex}b21ace2e13596ccd87dc9bf4ea6" PRIMARY KEY ("webhookPath", "method"))`, undefined);
} }
@@ -25,6 +27,7 @@ export class WebhookModel1589476000887 implements MigrationInterface {
if (schema) { if (schema) {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query(`DROP TABLE ${tablePrefix}webhook_entity`, undefined); await queryRunner.query(`DROP TABLE ${tablePrefix}webhook_entity`, undefined);
} }

View File

@@ -13,13 +13,21 @@ export class CreateIndexStoppedAt1594828256133 implements MigrationInterface {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixPure}33228da131bb1112247cf52a42 ON ${tablePrefix}execution_entity ("stoppedAt") `); await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixPure}33228da131bb1112247cf52a42 ON ${tablePrefix}execution_entity ("stoppedAt") `);
} }
async down(queryRunner: QueryRunner): Promise<void> { async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix'); let tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query(`DROP INDEX IDX_${tablePrefix}33228da131bb1112247cf52a42`); const tablePrefixPure = tablePrefix;
const schema = config.getEnv('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query(`DROP INDEX IDX_${tablePrefixPure}33228da131bb1112247cf52a42`);
} }
} }

View File

@@ -11,6 +11,9 @@ export class MakeStoppedAtNullable1607431743768 implements MigrationInterface {
if (schema) { if (schema) {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query('ALTER TABLE ' + tablePrefix + 'execution_entity ALTER COLUMN "stoppedAt" DROP NOT NULL', undefined); await queryRunner.query('ALTER TABLE ' + tablePrefix + 'execution_entity ALTER COLUMN "stoppedAt" DROP NOT NULL', undefined);
} }

View File

@@ -12,6 +12,8 @@ export class AddWebhookId1611144599516 implements MigrationInterface {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}webhook_entity ADD "webhookId" character varying`); await queryRunner.query(`ALTER TABLE ${tablePrefix}webhook_entity ADD "webhookId" character varying`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}webhook_entity ADD "pathLength" integer`); await queryRunner.query(`ALTER TABLE ${tablePrefix}webhook_entity ADD "pathLength" integer`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixPure}16f4436789e804e3e1c9eeb240 ON ${tablePrefix}webhook_entity ("webhookId", "method", "pathLength") `); await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixPure}16f4436789e804e3e1c9eeb240 ON ${tablePrefix}webhook_entity ("webhookId", "method", "pathLength") `);
@@ -24,6 +26,7 @@ export class AddWebhookId1611144599516 implements MigrationInterface {
if (schema) { if (schema) {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query(`DROP INDEX IDX_${tablePrefixPure}16f4436789e804e3e1c9eeb240`); await queryRunner.query(`DROP INDEX IDX_${tablePrefixPure}16f4436789e804e3e1c9eeb240`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}webhook_entity DROP COLUMN "pathLength"`); await queryRunner.query(`ALTER TABLE ${tablePrefix}webhook_entity DROP COLUMN "pathLength"`);

View File

@@ -12,6 +12,8 @@ export class CreateTagEntity1617270242566 implements MigrationInterface {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
// create tags table + relationship with workflow entity // create tags table + relationship with workflow entity
await queryRunner.query(`CREATE TABLE ${tablePrefix}tag_entity ("id" SERIAL NOT NULL, "name" character varying(24) NOT NULL, "createdAt" TIMESTAMP NOT NULL, "updatedAt" TIMESTAMP NOT NULL, CONSTRAINT "PK_${tablePrefixPure}7a50a9b74ae6855c0dcaee25052" PRIMARY KEY ("id"))`); await queryRunner.query(`CREATE TABLE ${tablePrefix}tag_entity ("id" SERIAL NOT NULL, "name" character varying(24) NOT NULL, "createdAt" TIMESTAMP NOT NULL, "updatedAt" TIMESTAMP NOT NULL, CONSTRAINT "PK_${tablePrefixPure}7a50a9b74ae6855c0dcaee25052" PRIMARY KEY ("id"))`);
@@ -47,6 +49,8 @@ export class CreateTagEntity1617270242566 implements MigrationInterface {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
// `createdAt` and `updatedAt` // `createdAt` and `updatedAt`
await queryRunner.query(`ALTER TABLE ${tablePrefix}workflow_entity ALTER COLUMN "updatedAt" DROP DEFAULT`); await queryRunner.query(`ALTER TABLE ${tablePrefix}workflow_entity ALTER COLUMN "updatedAt" DROP DEFAULT`);

View File

@@ -12,6 +12,8 @@ export class UniqueWorkflowNames1620824779533 implements MigrationInterface {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
const workflowNames = await queryRunner.query(` const workflowNames = await queryRunner.query(`
SELECT name SELECT name
FROM ${tablePrefix}workflow_entity FROM ${tablePrefix}workflow_entity
@@ -65,6 +67,8 @@ export class UniqueWorkflowNames1620824779533 implements MigrationInterface {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query(`DROP INDEX "IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab"`); await queryRunner.query(`DROP INDEX "IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab"`);
} }
} }

View File

@@ -12,6 +12,8 @@ export class AddwaitTill1626176912946 implements MigrationInterface {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}execution_entity ADD "waitTill" TIMESTAMP`); await queryRunner.query(`ALTER TABLE ${tablePrefix}execution_entity ADD "waitTill" TIMESTAMP`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2 ON ${tablePrefix}execution_entity ("waitTill")`); await queryRunner.query(`CREATE INDEX IF NOT EXISTS IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2 ON ${tablePrefix}execution_entity ("waitTill")`);
} }
@@ -24,6 +26,8 @@ export class AddwaitTill1626176912946 implements MigrationInterface {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query(`DROP INDEX IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2`); await queryRunner.query(`DROP INDEX IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}webhook_entity DROP COLUMN "waitTill"`); await queryRunner.query(`ALTER TABLE ${tablePrefix}webhook_entity DROP COLUMN "waitTill"`);
} }

View File

@@ -14,6 +14,9 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
if (schema) { if (schema) {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
const helpers = new MigrationHelpers(queryRunner); const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(` const credentialsEntities = await queryRunner.query(`
@@ -157,6 +160,7 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
if (schema) { if (schema) {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
const helpers = new MigrationHelpers(queryRunner); const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(` const credentialsEntities = await queryRunner.query(`

View File

@@ -13,6 +13,8 @@ export class AddExecutionEntityIndexes1644422880309 implements MigrationInterfac
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query( await queryRunner.query(
`DROP INDEX IF EXISTS "${schema}".IDX_${tablePrefixPure}c4d999a5e90784e8caccf5589d`, `DROP INDEX IF EXISTS "${schema}".IDX_${tablePrefixPure}c4d999a5e90784e8caccf5589d`,
); );
@@ -49,22 +51,22 @@ export class AddExecutionEntityIndexes1644422880309 implements MigrationInterfac
} }
await queryRunner.query( await queryRunner.query(
`DROP INDEX "${schema}"."IDX_${tablePrefixPure}d160d4771aba5a0d78943edbe3"`, `DROP INDEX "IDX_${tablePrefixPure}d160d4771aba5a0d78943edbe3"`,
); );
await queryRunner.query( await queryRunner.query(
`DROP INDEX "${schema}"."IDX_${tablePrefixPure}85b981df7b444f905f8bf50747"`, `DROP INDEX "IDX_${tablePrefixPure}85b981df7b444f905f8bf50747"`,
); );
await queryRunner.query( await queryRunner.query(
`DROP INDEX "${schema}"."IDX_${tablePrefixPure}72ffaaab9f04c2c1f1ea86e662"`, `DROP INDEX "IDX_${tablePrefixPure}72ffaaab9f04c2c1f1ea86e662"`,
); );
await queryRunner.query( await queryRunner.query(
`DROP INDEX "${schema}"."IDX_${tablePrefixPure}4f474ac92be81610439aaad61e"`, `DROP INDEX "IDX_${tablePrefixPure}4f474ac92be81610439aaad61e"`,
); );
await queryRunner.query( await queryRunner.query(
`DROP INDEX "${schema}"."IDX_${tablePrefixPure}58154df94c686818c99fb754ce"`, `DROP INDEX "IDX_${tablePrefixPure}58154df94c686818c99fb754ce"`,
); );
await queryRunner.query( await queryRunner.query(
`DROP INDEX "${schema}"."IDX_${tablePrefixPure}33228da131bb1112247cf52a42"`, `DROP INDEX "IDX_${tablePrefixPure}33228da131bb1112247cf52a42"`,
); );
await queryRunner.query( await queryRunner.query(
`CREATE INDEX "IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2" ON ${tablePrefix}execution_entity ("waitTill") `, `CREATE INDEX "IDX_${tablePrefixPure}ca4a71b47f28ac6ea88293a8e2" ON ${tablePrefix}execution_entity ("waitTill") `,

View File

@@ -9,7 +9,14 @@ export class IncreaseTypeVarcharLimit1646834195327 implements MigrationInterface
name = 'IncreaseTypeVarcharLimit1646834195327'; name = 'IncreaseTypeVarcharLimit1646834195327';
async up(queryRunner: QueryRunner): Promise<void> { async up(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix'); let tablePrefix = config.getEnv('database.tablePrefix');
const schema = config.getEnv('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}credentials_entity ALTER COLUMN "type" TYPE VARCHAR(128)`); await queryRunner.query(`ALTER TABLE ${tablePrefix}credentials_entity ALTER COLUMN "type" TYPE VARCHAR(128)`);
} }

View File

@@ -14,6 +14,8 @@ export class CreateUserManagement1646992772331 implements MigrationInterface {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query( await queryRunner.query(
`CREATE TABLE ${tablePrefix}role ( `CREATE TABLE ${tablePrefix}role (
"id" serial NOT NULL, "id" serial NOT NULL,
@@ -56,12 +58,12 @@ export class CreateUserManagement1646992772331 implements MigrationInterface {
CONSTRAINT "FK_${tablePrefixPure}3540da03964527aa24ae014b780" FOREIGN KEY ("roleId") REFERENCES ${tablePrefix}role ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_${tablePrefixPure}3540da03964527aa24ae014b780" FOREIGN KEY ("roleId") REFERENCES ${tablePrefix}role ("id") ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT "FK_${tablePrefixPure}82b2fd9ec4e3e24209af8160282" FOREIGN KEY ("userId") REFERENCES ${tablePrefix}user ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_${tablePrefixPure}82b2fd9ec4e3e24209af8160282" FOREIGN KEY ("userId") REFERENCES ${tablePrefix}user ("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_${tablePrefixPure}b83f8d2530884b66a9c848c8b88" FOREIGN KEY ("workflowId") REFERENCES CONSTRAINT "FK_${tablePrefixPure}b83f8d2530884b66a9c848c8b88" FOREIGN KEY ("workflowId") REFERENCES
${tablePrefixPure}workflow_entity ("id") ON DELETE CASCADE ON UPDATE NO ACTION ${tablePrefix}workflow_entity ("id") ON DELETE CASCADE ON UPDATE NO ACTION
);`, );`,
); );
await queryRunner.query( await queryRunner.query(
`CREATE INDEX "IDX_${tablePrefixPure}65a0933c0f19d278881653bf81d35064" ON "shared_workflow" ("workflowId");`, `CREATE INDEX "IDX_${tablePrefixPure}65a0933c0f19d278881653bf81d35064" ON ${tablePrefix}shared_workflow ("workflowId");`,
); );
await queryRunner.query( await queryRunner.query(
@@ -131,7 +133,7 @@ export class CreateUserManagement1646992772331 implements MigrationInterface {
); );
await queryRunner.query( await queryRunner.query(
`INSERT INTO ${tablePrefix}shared_credentials ("createdAt", "updatedAt", "roleId", "userId", "credentialsId") SELECT NOW(), NOW(), '${credentialOwnerRole[0].insertId}', '${ownerUserId}', "id" FROM ${tablePrefix} credentials_entity`, `INSERT INTO ${tablePrefix}shared_credentials ("createdAt", "updatedAt", "roleId", "userId", "credentialsId") SELECT NOW(), NOW(), '${credentialOwnerRole[0].insertId}', '${ownerUserId}', "id" FROM ${tablePrefix}credentials_entity`,
); );
await queryRunner.query( await queryRunner.query(
@@ -146,6 +148,7 @@ export class CreateUserManagement1646992772331 implements MigrationInterface {
if (schema) { if (schema) {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query( await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab" ON ${tablePrefix}workflow_entity ("name")`, `CREATE UNIQUE INDEX "IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab" ON ${tablePrefix}workflow_entity ("name")`,

View File

@@ -11,6 +11,8 @@ export class LowerCaseUserEmail1648740597343 implements MigrationInterface {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query(` await queryRunner.query(`
UPDATE ${tablePrefix}user UPDATE ${tablePrefix}user
SET email = LOWER(email); SET email = LOWER(email);

View File

@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as config from '../../../../config';
export class AddUserSettings1652367743993 implements MigrationInterface {
name = 'AddUserSettings1652367743993';
public async up(queryRunner: QueryRunner): Promise<void> {
let tablePrefix = config.getEnv('database.tablePrefix');
const schema = config.getEnv('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}user ADD COLUMN settings json`);
await queryRunner.query(
`ALTER TABLE ${tablePrefix}user ALTER COLUMN "personalizationAnswers" TYPE json USING to_jsonb("personalizationAnswers")::json;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
let tablePrefix = config.getEnv('database.tablePrefix');
const schema = config.getEnv('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
await queryRunner.query(`SET search_path TO ${schema};`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}user DROP COLUMN settings`);
}
}

View File

@@ -11,6 +11,7 @@ import { AddExecutionEntityIndexes1644422880309 } from './1644422880309-AddExecu
import { IncreaseTypeVarcharLimit1646834195327 } from './1646834195327-IncreaseTypeVarcharLimit'; import { IncreaseTypeVarcharLimit1646834195327 } from './1646834195327-IncreaseTypeVarcharLimit';
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail'; import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
export const postgresMigrations = [ export const postgresMigrations = [
InitialMigration1587669153312, InitialMigration1587669153312,
@@ -26,4 +27,5 @@ export const postgresMigrations = [
IncreaseTypeVarcharLimit1646834195327, IncreaseTypeVarcharLimit1646834195327,
CreateUserManagement1646992772331, CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343, LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
]; ];

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as config from '../../../../config';
import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
export class AddUserSettings1652367743993 implements MigrationInterface {
name = 'AddUserSettings1652367743993';
public async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query('PRAGMA foreign_keys=OFF');
await queryRunner.query(
`CREATE TABLE "temporary_user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, "settings" text, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`,
);
await queryRunner.query(
`INSERT INTO "temporary_user"("id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId") SELECT "id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId" FROM "${tablePrefix}user"`,
);
await queryRunner.query(`DROP TABLE "${tablePrefix}user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "${tablePrefix}user"`);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_${tablePrefix}e12875dfb3b1d92d7d7c5377e2" ON "${tablePrefix}user" ("email")`,
);
await queryRunner.query('PRAGMA foreign_keys=ON');
logMigrationEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query('PRAGMA foreign_keys=OFF');
await queryRunner.query(`ALTER TABLE "${tablePrefix}user" RENAME TO "temporary_user"`);
await queryRunner.query(
`CREATE TABLE "${tablePrefix}user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`,
);
await queryRunner.query(
`INSERT INTO "${tablePrefix}user"("id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId") SELECT "id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId" FROM "temporary_user"`,
);
await queryRunner.query(`DROP TABLE "temporary_user"`);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_${tablePrefix}e12875dfb3b1d92d7d7c5377e2" ON "${tablePrefix}user" ("email")`,
);
await queryRunner.query('PRAGMA foreign_keys=ON');
}
}

View File

@@ -12,6 +12,7 @@ import { UpdateWorkflowCredentials1630330987096 } from './1630330987096-UpdateWo
import { AddExecutionEntityIndexes1644421939510 } from './1644421939510-AddExecutionEntityIndexes'; import { AddExecutionEntityIndexes1644421939510 } from './1644421939510-AddExecutionEntityIndexes';
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement'; import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail'; import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
const sqliteMigrations = [ const sqliteMigrations = [
InitialMigration1588102412422, InitialMigration1588102412422,
@@ -26,6 +27,7 @@ const sqliteMigrations = [
AddExecutionEntityIndexes1644421939510, AddExecutionEntityIndexes1644421939510,
CreateUserManagement1646992772331, CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343, LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
]; ];
export { sqliteMigrations }; export { sqliteMigrations };

View File

@@ -51,7 +51,7 @@ export declare namespace WorkflowRequest {
type Update = AuthenticatedRequest<{ id: string }, {}, RequestBody>; type Update = AuthenticatedRequest<{ id: string }, {}, RequestBody>;
type NewName = express.Request<{}, {}, {}, { name?: string }>; type NewName = AuthenticatedRequest<{}, {}, {}, { name?: string }>;
type GetAll = AuthenticatedRequest<{}, {}, {}, { filter: string }>; type GetAll = AuthenticatedRequest<{}, {}, {}, { filter: string }>;
@@ -244,9 +244,7 @@ export declare namespace OAuthRequest {
namespace OAuth2Credential { namespace OAuth2Credential {
type Auth = OAuth1Credential.Auth; type Auth = OAuth1Credential.Auth;
type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }> & { type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }>;
user?: User;
};
} }
} }

View File

@@ -30,8 +30,6 @@ beforeAll(async () => {
}); });
beforeEach(async () => { beforeEach(async () => {
jest.mock('../../config');
config.set('userManagement.isInstanceOwnerSetUp', false); config.set('userManagement.isInstanceOwnerSetUp', false);
}); });
@@ -111,6 +109,7 @@ test('POST /owner should create owner with lowercased email', async () => {
const { id, email } = response.body.data; const { id, email } = response.body.data;
expect(id).toBe(ownerShell.id);
expect(email).toBe(newOwnerData.email.toLowerCase()); expect(email).toBe(newOwnerData.email.toLowerCase());
const storedOwner = await Db.collections.User.findOneOrFail(id); const storedOwner = await Db.collections.User.findOneOrFail(id);

View File

@@ -21,6 +21,7 @@ let app: express.Application;
let testDbName = ''; let testDbName = '';
let globalOwnerRole: Role; let globalOwnerRole: Role;
let globalMemberRole: Role; let globalMemberRole: Role;
let isSmtpAvailable = false;
beforeAll(async () => { beforeAll(async () => {
app = utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true }); app = utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true });
@@ -32,6 +33,8 @@ beforeAll(async () => {
utils.initTestTelemetry(); utils.initTestTelemetry();
utils.initTestLogger(); utils.initTestLogger();
isSmtpAvailable = await utils.isTestSmtpServiceAvailable();
}); });
beforeEach(async () => { beforeEach(async () => {
@@ -50,6 +53,8 @@ afterAll(async () => {
test( test(
'POST /forgot-password should send password reset email', 'POST /forgot-password should send password reset email',
async () => { async () => {
if (!isSmtpAvailable) utils.skipSmtpTest(expect);
const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authlessAgent = utils.createAgent(app); const authlessAgent = utils.createAgent(app);

View File

@@ -1,3 +1,6 @@
import { exec as callbackExec } from 'child_process';
import { promisify } from 'util';
import { createConnection, getConnection, ConnectionOptions, Connection } from 'typeorm'; import { createConnection, getConnection, ConnectionOptions, Connection } from 'typeorm';
import { Credentials, UserSettings } from 'n8n-core'; import { Credentials, UserSettings } from 'n8n-core';
@@ -12,12 +15,14 @@ import { entities } from '../../../src/databases/entities';
import { mysqlMigrations } from '../../../src/databases/mysqldb/migrations'; import { mysqlMigrations } from '../../../src/databases/mysqldb/migrations';
import { postgresMigrations } from '../../../src/databases/postgresdb/migrations'; import { postgresMigrations } from '../../../src/databases/postgresdb/migrations';
import { sqliteMigrations } from '../../../src/databases/sqlite/migrations'; import { sqliteMigrations } from '../../../src/databases/sqlite/migrations';
import { categorize } from './utils'; import { categorize, getPostgresSchemaSection } from './utils';
import type { Role } from '../../../src/databases/entities/Role'; import type { Role } from '../../../src/databases/entities/Role';
import type { User } from '../../../src/databases/entities/User'; import type { User } from '../../../src/databases/entities/User';
import type { CollectionName, CredentialPayload } from './types'; import type { CollectionName, CredentialPayload } from './types';
const exec = promisify(callbackExec);
/** /**
* Initialize one test DB per suite run, with bootstrap connection if needed. * Initialize one test DB per suite run, with bootstrap connection if needed.
*/ */
@@ -35,21 +40,42 @@ export async function init() {
if (dbType === 'postgresdb') { if (dbType === 'postgresdb') {
let bootstrapPostgres; let bootstrapPostgres;
const bootstrapPostgresOptions = getBootstrapPostgresOptions(); const pgOptions = getBootstrapPostgresOptions();
try { try {
bootstrapPostgres = await createConnection(bootstrapPostgresOptions); bootstrapPostgres = await createConnection(pgOptions);
} catch (error) { } catch (error) {
const { username, password, host, port, schema } = bootstrapPostgresOptions; const pgConfig = getPostgresSchemaSection();
console.error(
`ERROR: Failed to connect to Postgres default DB 'postgres'.\nPlease review your Postgres connection options:\n\thost: "${host}"\n\tusername: "${username}"\n\tpassword: "${password}"\n\tport: "${port}"\n\tschema: "${schema}"\nFix by setting correct values via environment variables:\n\texport DB_POSTGRESDB_HOST=value\n\texport DB_POSTGRESDB_USER=value\n\texport DB_POSTGRESDB_PASSWORD=value\n\texport DB_POSTGRESDB_PORT=value\n\texport DB_POSTGRESDB_SCHEMA=value`, if (!pgConfig) throw new Error("Failed to find config schema section for 'postgresdb'");
);
const message = [
"ERROR: Failed to connect to Postgres default DB 'postgres'",
'Please review your Postgres connection options:',
`host: ${pgOptions.host} | port: ${pgOptions.port} | schema: ${pgOptions.schema} | username: ${pgOptions.username} | password: ${pgOptions.password}`,
'Fix by setting correct values via environment variables:',
`${pgConfig.host.env} | ${pgConfig.port.env} | ${pgConfig.schema.env} | ${pgConfig.user.env} | ${pgConfig.password.env}`,
'Otherwise, make sure your Postgres server is running.'
].join('\n');
console.error(message);
process.exit(1); process.exit(1);
} }
const testDbName = `pg_${randomString(6, 10)}_${Date.now()}_n8n_test`; const testDbName = `pg_${randomString(6, 10)}_${Date.now()}_n8n_test`;
await bootstrapPostgres.query(`CREATE DATABASE ${testDbName};`); await bootstrapPostgres.query(`CREATE DATABASE ${testDbName};`);
try {
const schema = config.getEnv('database.postgresdb.schema');
await exec(`psql -d ${testDbName} -c "CREATE SCHEMA IF NOT EXISTS ${schema}";`);
} catch (error) {
if (error instanceof Error && error.message.includes('command not found')) {
console.error('psql command not found. Make sure psql is installed and added to your PATH.');
}
process.exit(1);
}
await Db.init(getPostgresOptions({ name: testDbName })); await Db.init(getPostgresOptions({ name: testDbName }));
return { testDbName }; return { testDbName };
@@ -116,8 +142,10 @@ export async function truncate(collections: CollectionName[], testDbName: string
if (dbType === 'postgresdb') { if (dbType === 'postgresdb') {
return Promise.all( return Promise.all(
collections.map((collection) => { collections.map((collection) => {
const tableName = toTableName(collection); const schema = config.getEnv('database.postgresdb.schema');
testDb.query(`TRUNCATE TABLE "${tableName}" RESTART IDENTITY CASCADE;`); const fullTableName = `${schema}.${toTableName(collection)}`;
testDb.query(`TRUNCATE TABLE ${fullTableName} RESTART IDENTITY CASCADE;`);
}), }),
); );
} }

View File

@@ -28,3 +28,7 @@ export type SaveCredentialFunction = (
credentialPayload: CredentialPayload, credentialPayload: CredentialPayload,
{ user }: { user: User }, { user }: { user: User },
) => Promise<CredentialsEntity & ICredentialsDb>; ) => Promise<CredentialsEntity & ICredentialsDb>;
export type PostgresSchemaSection = {
[K in 'host' | 'port' | 'schema' | 'user' | 'password']: { env: string };
};

View File

@@ -24,8 +24,9 @@ import { issueJWT } from '../../../src/UserManagement/auth/jwt';
import { getLogger } from '../../../src/Logger'; import { getLogger } from '../../../src/Logger';
import { credentialsController } from '../../../src/api/credentials.api'; import { credentialsController } from '../../../src/api/credentials.api';
import type { User } from '../../../src/databases/entities/User'; import type { User } from '../../../src/databases/entities/User';
import type { EndpointGroup, SmtpTestAccount } from './types'; import type { EndpointGroup, PostgresSchemaSection, SmtpTestAccount } from './types';
import type { N8nApp } from '../../../src/UserManagement/Interfaces'; import type { N8nApp } from '../../../src/UserManagement/Interfaces';
import * as UserManagementMailer from '../../../src/UserManagement/email/UserManagementMailer';
/** /**
* Initialize a test server. * Initialize a test server.
@@ -229,6 +230,21 @@ export async function configureSmtp() {
config.set('userManagement.emails.smtp.auth.pass', pass); config.set('userManagement.emails.smtp.auth.pass', pass);
} }
export async function isTestSmtpServiceAvailable() {
try {
await configureSmtp();
await UserManagementMailer.getInstance();
return true;
} catch (_) {
return false;
}
}
export function skipSmtpTest(expect: jest.Expect) {
console.warn(`SMTP service unavailable - Skipping test ${expect.getState().currentTestName}`);
return;
}
// ---------------------------------- // ----------------------------------
// misc // misc
// ---------------------------------- // ----------------------------------
@@ -246,3 +262,15 @@ export const categorize = <T>(arr: T[], test: (str: T) => boolean) => {
{ pass: [], fail: [] }, { pass: [], fail: [] },
); );
}; };
export function getPostgresSchemaSection(
schema = config.getSchema(),
): PostgresSchemaSection | null {
for (const [key, value] of Object.entries(schema)) {
if (key === 'postgresdb') {
return value._cvtProperties;
}
}
return null;
}

View File

@@ -27,6 +27,7 @@ let globalMemberRole: Role;
let globalOwnerRole: Role; let globalOwnerRole: Role;
let workflowOwnerRole: Role; let workflowOwnerRole: Role;
let credentialOwnerRole: Role; let credentialOwnerRole: Role;
let isSmtpAvailable = false;
beforeAll(async () => { beforeAll(async () => {
app = utils.initTestServer({ endpointGroups: ['users'], applyAuth: true }); app = utils.initTestServer({ endpointGroups: ['users'], applyAuth: true });
@@ -47,6 +48,8 @@ beforeAll(async () => {
utils.initTestTelemetry(); utils.initTestTelemetry();
utils.initTestLogger(); utils.initTestLogger();
isSmtpAvailable = await utils.isTestSmtpServiceAvailable();
}); });
beforeEach(async () => { beforeEach(async () => {
@@ -482,6 +485,8 @@ test('POST /users should fail if user management is disabled', async () => {
test( test(
'POST /users should email invites and create user shells but ignore existing', 'POST /users should email invites and create user shells but ignore existing',
async () => { async () => {
if (!isSmtpAvailable) utils.skipSmtpTest(expect);
const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const member = await testDb.createUser({ globalRole: globalMemberRole }); const member = await testDb.createUser({ globalRole: globalMemberRole });
const memberShell = await testDb.createUserShell(globalMemberRole); const memberShell = await testDb.createUserShell(globalMemberRole);
@@ -534,6 +539,8 @@ test(
test( test(
'POST /users should fail with invalid inputs', 'POST /users should fail with invalid inputs',
async () => { async () => {
if (!isSmtpAvailable) utils.skipSmtpTest(expect);
const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });
@@ -563,6 +570,8 @@ test(
test( test(
'POST /users should ignore an empty payload', 'POST /users should ignore an empty payload',
async () => { async () => {
if (!isSmtpAvailable) utils.skipSmtpTest(expect);
const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner });

View File

@@ -1,6 +1,6 @@
{ {
"name": "n8n-core", "name": "n8n-core",
"version": "0.116.0", "version": "0.119.0",
"description": "Core functionality of n8n", "description": "Core functionality of n8n",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
@@ -52,7 +52,7 @@
"form-data": "^4.0.0", "form-data": "^4.0.0",
"lodash.get": "^4.4.2", "lodash.get": "^4.4.2",
"mime-types": "^2.1.27", "mime-types": "^2.1.27",
"n8n-workflow": "~0.98.0", "n8n-workflow": "~0.101.0",
"oauth-1.0a": "^2.2.6", "oauth-1.0a": "^2.2.6",
"p-cancelable": "^2.0.0", "p-cancelable": "^2.0.0",
"qs": "^6.10.1", "qs": "^6.10.1",

View File

@@ -541,7 +541,10 @@ async function proxyRequestToAxios(
return requestPromiseWithDefaults.call(null, uriOrObject, options); return requestPromiseWithDefaults.call(null, uriOrObject, options);
} }
let axiosConfig: AxiosRequestConfig = {}; let axiosConfig: AxiosRequestConfig = {
maxBodyLength: Infinity,
maxContentLength: Infinity,
};
let axiosPromise: AxiosPromise; let axiosPromise: AxiosPromise;
type ConfigObject = { type ConfigObject = {
auth?: { sendImmediately: boolean }; auth?: { sendImmediately: boolean };
@@ -708,7 +711,10 @@ function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): AxiosRequest
}; };
} }
if (n8nRequest.body) { // if there is a body and it's empty (does not have properties),
// make sure not to send anything in it as some services fail when
// sending GET request with empty body.
if (n8nRequest.body && Object.keys(n8nRequest.body).length) {
axiosRequest.data = n8nRequest.body; axiosRequest.data = n8nRequest.body;
// Let's add some useful header standards here. // Let's add some useful header standards here.
const existingContentTypeHeaderKey = searchForHeader(axiosRequest.headers, 'content-type'); const existingContentTypeHeaderKey = searchForHeader(axiosRequest.headers, 'content-type');
@@ -1386,6 +1392,36 @@ export function getNode(node: INode): INode {
return JSON.parse(JSON.stringify(node)); return JSON.parse(JSON.stringify(node));
} }
/**
* Clean up parameter data to make sure that only valid data gets returned
* INFO: Currently only converts Luxon Dates as we know for sure it will not be breaking
*/
function cleanupParameterData(
inputData: NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[],
): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] {
if (inputData === null || inputData === undefined) {
return inputData;
}
if (Array.isArray(inputData)) {
inputData.forEach((value) => cleanupParameterData(value));
return inputData;
}
if (inputData.constructor.name === 'DateTime') {
// Is a special luxon date so convert to string
return inputData.toString();
}
if (typeof inputData === 'object') {
Object.keys(inputData).forEach((key) => {
inputData[key] = cleanupParameterData(inputData[key]);
});
}
return inputData;
}
/** /**
* Returns the requested resolved (all expressions replaced) node parameters. * Returns the requested resolved (all expressions replaced) node parameters.
* *
@@ -1437,6 +1473,8 @@ export function getNodeParameter(
timezone, timezone,
additionalKeys, additionalKeys,
); );
returnData = cleanupParameterData(returnData);
} catch (e) { } catch (e) {
e.message += ` [Error in parameter: "${parameterName}"]`; e.message += ` [Error in parameter: "${parameterName}"]`;
throw e; throw e;

View File

@@ -627,6 +627,7 @@ export class WorkflowExecute {
let currentExecutionTry = ''; let currentExecutionTry = '';
let lastExecutionTry = ''; let lastExecutionTry = '';
let closeFunction: Promise<void> | undefined;
return new PCancelable(async (resolve, reject, onCancel) => { return new PCancelable(async (resolve, reject, onCancel) => {
let gotCancel = false; let gotCancel = false;
@@ -811,7 +812,7 @@ export class WorkflowExecute {
node: executionNode.name, node: executionNode.name,
workflowId: workflow.id, workflowId: workflow.id,
}); });
nodeSuccessData = await workflow.runNode( const runNodeData = await workflow.runNode(
executionData.node, executionData.node,
executionData.data, executionData.data,
this.runExecutionData, this.runExecutionData,
@@ -820,6 +821,14 @@ export class WorkflowExecute {
NodeExecuteFunctions, NodeExecuteFunctions,
this.mode, this.mode,
); );
nodeSuccessData = runNodeData.data;
if (runNodeData.closeFunction) {
// Explanation why we do this can be found in n8n-workflow/Workflow.ts -> runNode
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
closeFunction = runNodeData.closeFunction();
}
Logger.debug(`Running node "${executionNode.name}" finished successfully`, { Logger.debug(`Running node "${executionNode.name}" finished successfully`, {
node: executionNode.name, node: executionNode.name,
workflowId: workflow.id, workflowId: workflow.id,
@@ -1033,9 +1042,10 @@ export class WorkflowExecute {
startedAt, startedAt,
workflow, workflow,
new WorkflowOperationError('Workflow has been canceled or timed out!'), new WorkflowOperationError('Workflow has been canceled or timed out!'),
closeFunction,
); );
} }
return this.processSuccessExecution(startedAt, workflow, executionError); return this.processSuccessExecution(startedAt, workflow, executionError, closeFunction);
}) })
.catch(async (error) => { .catch(async (error) => {
const fullRunData = this.getFullRunData(startedAt); const fullRunData = this.getFullRunData(startedAt);
@@ -1061,6 +1071,20 @@ export class WorkflowExecute {
}, },
); );
if (closeFunction) {
try {
await closeFunction;
} catch (errorClose) {
Logger.error(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/restrict-template-expressions
`There was a problem deactivating trigger of workflow "${workflow.id}": "${errorClose.message}"`,
{
workflowId: workflow.id,
},
);
}
}
return fullRunData; return fullRunData;
}); });
@@ -1072,6 +1096,7 @@ export class WorkflowExecute {
startedAt: Date, startedAt: Date,
workflow: Workflow, workflow: Workflow,
executionError?: ExecutionError, executionError?: ExecutionError,
closeFunction?: Promise<void>,
): Promise<IRun> { ): Promise<IRun> {
const fullRunData = this.getFullRunData(startedAt); const fullRunData = this.getFullRunData(startedAt);
@@ -1106,6 +1131,20 @@ export class WorkflowExecute {
await this.executeHook('workflowExecuteAfter', [fullRunData, newStaticData]); await this.executeHook('workflowExecuteAfter', [fullRunData, newStaticData]);
if (closeFunction) {
try {
await closeFunction;
} catch (error) {
Logger.error(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`There was a problem deactivating trigger of workflow "${workflow.id}": "${error.message}"`,
{
workflowId: workflow.id,
},
);
}
}
return fullRunData; return fullRunData;
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "n8n-design-system", "name": "n8n-design-system",
"version": "0.19.0", "version": "0.22.0",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
"author": { "author": {

View File

@@ -0,0 +1,46 @@
import { render } from '@testing-library/vue';
import N8nBadge from '../Badge.vue';
describe('components', () => {
describe('N8nBadge', () => {
describe('props', () => {
it('should render default theme correctly', () => {
const wrapper = render(N8nBadge, {
props: {
theme: 'default',
size: 'large',
bold: true,
},
slots: {
default: '<n8n-text>Default badge</n8n-text>',
},
stubs: ['n8n-text'],
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render secondary theme correctly', () => {
const wrapper = render(N8nBadge, {
props: {
theme: 'secondary',
size: 'medium',
bold: false,
},
slots: {
default: '<n8n-text>Secondary badge</n8n-text>',
},
stubs: ['n8n-text'],
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render with default values correctly', () => {
const wrapper = render(N8nBadge, {
slots: {
default: '<n8n-text>A Badge</n8n-text>',
},
stubs: ['n8n-text'],
});
expect(wrapper.html()).toMatchSnapshot();
});
});
});
});

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1
exports[`components > N8nBadge > props > should render default theme correctly 1`] = `"<span class=\\"_default_13dw2_9 _badge_13dw2_1\\"><span class=\\"_size-large_9dlpz_14 _bold_9dlpz_1\\" style=\\"line-height: 1;\\"><n8n-text-stub size=\\"medium\\" tag=\\"span\\">Default badge</n8n-text-stub></span></span>"`;
exports[`components > N8nBadge > props > should render secondary theme correctly 1`] = `"<span class=\\"_secondary_13dw2_16 _badge_13dw2_1\\"><span class=\\"_size-medium_9dlpz_19 _regular_9dlpz_5\\" style=\\"line-height: 1;\\"><n8n-text-stub size=\\"medium\\" tag=\\"span\\">Secondary badge</n8n-text-stub></span></span>"`;
exports[`components > N8nBadge > props > should render with default values correctly 1`] = `"<span class=\\"_default_13dw2_9 _badge_13dw2_1\\"><span class=\\"_size-small_9dlpz_24 _regular_9dlpz_5\\" style=\\"line-height: 1;\\"><n8n-text-stub size=\\"medium\\" tag=\\"span\\">A Badge</n8n-text-stub></span></span>"`;

View File

@@ -13,7 +13,7 @@ export default {
}, },
type: { type: {
control: 'select', control: 'select',
options: ['primary', 'outline', 'light', 'text'], options: ['primary', 'outline', 'light', 'text', 'tertiary'],
}, },
size: { size: {
control: { control: {
@@ -96,6 +96,12 @@ Outline.args = {
label: 'Button', label: 'Button',
}; };
export const Tertiary = ManyTemplate.bind({});
Tertiary.args = {
type: 'tertiary',
label: 'Button',
};
export const Light = ManyTemplate.bind({}); export const Light = ManyTemplate.bind({});
Light.args = { Light.args = {
type: 'light', type: 'light',

View File

@@ -47,7 +47,7 @@ export default {
type: String, type: String,
default: 'primary', default: 'primary',
validator: (value: string): boolean => validator: (value: string): boolean =>
['primary', 'outline', 'light', 'text'].includes(value), ['primary', 'outline', 'light', 'text', 'tertiary'].includes(value),
}, },
theme: { theme: {
type: String, type: String,
@@ -107,8 +107,8 @@ export default {
}; };
}, },
getClass(props: { type: string; theme?: string, transparentBackground: boolean }, $style: any): string { getClass(props: { type: string; theme?: string, transparentBackground: boolean }, $style: any): string {
const theme = props.type === 'text' const theme = props.type === 'text' || props.type === 'tertiary'
? 'text' ? props.type
: `${props.type}-${props.theme || 'primary'}`; : `${props.type}-${props.theme || 'primary'}`;
if (props.transparentBackground) { if (props.transparentBackground) {
@@ -293,6 +293,20 @@ $color-danger-shade: lightness(
--button-active-border-color: transparent; --button-active-border-color: transparent;
} }
.tertiary {
composes: button;
font-weight: var(--font-weight-regular) !important;
--button-color: var(--color-text-dark);
--button-border-color: var(--color-foreground-base);
--button-background-color: var(--color-background-base);
--button-active-background-color: var(--color-primary-tint-2);
--button-active-color: var(--color-primary);
--button-active-border-color: var(--color-primary);
--button-disabled-border-color: var(--color-foreground-xdark);
}
.transparent { .transparent {
--button-background-color: transparent; --button-background-color: transparent;
--button-active-background-color: transparent; --button-active-background-color: transparent;

View File

@@ -65,7 +65,7 @@ export default {
.tooltip { .tooltip {
composes: base; composes: base;
display: inline-block; display: inline-flex;
} }
.info-light { .info-light {

View File

@@ -4,6 +4,7 @@
v-if="!loading" v-if="!loading"
ref="editor" ref="editor"
:class="$style[theme]" v-html="htmlContent" :class="$style[theme]" v-html="htmlContent"
@click="onClick"
/> />
<div v-else :class="$style.markdown"> <div v-else :class="$style.markdown">
<div v-for="(block, index) in loadingBlocks" <div v-for="(block, index) in loadingBlocks"
@@ -117,6 +118,7 @@ export default {
} }
const fileIdRegex = new RegExp('fileId:([0-9]+)'); const fileIdRegex = new RegExp('fileId:([0-9]+)');
const imageFilesRegex = /\.(jpeg|jpg|gif|png|webp|bmp|tif|tiff|apng|svg|avif)$/;
let contentToRender = this.content; let contentToRender = this.content;
if (this.withMultiBreaks) { if (this.withMultiBreaks) {
contentToRender = contentToRender.replaceAll('\n\n', '\n&nbsp;\n'); contentToRender = contentToRender.replaceAll('\n\n', '\n&nbsp;\n');
@@ -129,7 +131,10 @@ export default {
const id = value.split('fileId:')[1]; const id = value.split('fileId:')[1];
return `src=${xss.friendlyAttrValue(imageUrls[id])}` || ''; return `src=${xss.friendlyAttrValue(imageUrls[id])}` || '';
} }
if (!value.startsWith('https://')) { // Only allow http requests to supported image files from the `static` directory
const isImageFile = value.split('#')[0].match(/\.(jpeg|jpg|gif|png|webp)$/) !== null;
const isStaticImageFile = isImageFile && value.startsWith('/static/');
if (!value.startsWith('https://') && !isStaticImageFile) {
return ''; return '';
} }
} }
@@ -154,6 +159,22 @@ export default {
.use(markdownTasklists, this.options.tasklists), .use(markdownTasklists, this.options.tasklists),
}; };
}, },
methods: {
onClick(event) {
let clickedLink = null;
if(event.target instanceof HTMLAnchorElement) {
clickedLink = event.target;
}
if(event.target.matches('a *')) {
const parentLink = event.target.closest('a');
if(parentLink) {
clickedLink = parentLink;
}
}
this.$emit('markdown-click', clickedLink, event);
}
}
}; };
</script> </script>
@@ -287,6 +308,10 @@ export default {
img { img {
object-fit: contain; object-fit: contain;
&[src*="#full-width"] {
width: 100%;
}
} }
} }

View File

@@ -1,24 +1,14 @@
<template> <template>
<div :id="id" :class="classes" role="alert"> <div :id="id" :class="classes" role="alert" @click=onClick>
<div class="notice-content"> <div class="notice-content">
<n8n-text size="small"> <n8n-text size="small" :compact="true">
<slot> <slot>
<span <span
:class="expanded ? $style['expanded'] : $style['truncated']" :class="showFullContent ? $style['expanded'] : $style['truncated']"
:id="`${id}-content`" :id="`${id}-content`"
role="region" role="region"
v-html="sanitizedContent" v-html="sanitizeHtml(showFullContent ? fullContent : content)"
/> />
<span v-if="canTruncate">
<a
role="button"
:aria-controls="`${id}-content`"
:aria-expanded="canTruncate && !expanded ? 'false' : 'true'"
@click="toggleExpanded"
>
{{ t(expanded ? 'notice.showLess' : 'notice.showMore') }}
</a>
</span>
</slot> </slot>
</n8n-text> </n8n-text>
</div> </div>
@@ -30,9 +20,7 @@ import Vue from 'vue';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import N8nText from "../../components/N8nText"; import N8nText from "../../components/N8nText";
import Locale from "../../mixins/locale"; import Locale from "../../mixins/locale";
import {uid} from "../../utils"; import { uid } from "../../utils";
const DEFAULT_TRUNCATION_MAX_LENGTH = 150;
export default Vue.extend({ export default Vue.extend({
name: 'n8n-notice', name: 'n8n-notice',
@@ -49,15 +37,11 @@ export default Vue.extend({
type: String, type: String,
default: 'warning', default: 'warning',
}, },
truncateAt: {
type: Number,
default: 150,
},
truncate: {
type: Boolean,
default: false,
},
content: { content: {
required: true,
type: String,
},
fullContent: {
type: String, type: String,
default: '', default: '',
}, },
@@ -67,7 +51,7 @@ export default Vue.extend({
}, },
data() { data() {
return { return {
expanded: false, showFullContent: false,
}; };
}, },
computed: { computed: {
@@ -79,22 +63,32 @@ export default Vue.extend({
]; ];
}, },
canTruncate(): boolean { canTruncate(): boolean {
return this.truncate && this.content.length > this.truncateAt; return this.fullContent !== undefined;
},
truncatedContent(): string {
if (!this.canTruncate || this.expanded) {
return this.content;
}
return this.content.slice(0, this.truncateAt as number) + '...';
},
sanitizedContent(): string {
return sanitizeHtml(this.truncatedContent);
}, },
}, },
methods: { methods: {
toggleExpanded() { toggleExpanded() {
this.expanded = !this.expanded; this.showFullContent = !this.showFullContent;
},
sanitizeHtml(text: string): string {
return sanitizeHtml(
text, {
allowedAttributes: { a: ['data-key', 'href', 'target'] },
}
);
},
onClick(e) {
if (e.target.localName !== 'a') return;
if (e.target.dataset.key === 'show-less') {
e.stopPropagation();
e.preventDefault();
this.showFullContent = false;
} else if (this.canTruncate && e.target.dataset.key === 'toggle-expand') {
e.stopPropagation();
e.preventDefault();
this.showFullContent = !this.showFullContent;
}
}, },
}, },
}); });
@@ -102,15 +96,17 @@ export default Vue.extend({
<style lang="scss" module> <style lang="scss" module>
.notice { .notice {
font-size: var(--font-size-2xs);
display: flex; display: flex;
color: var(--custom-font-black); color: var(--custom-font-black);
margin: 0; margin: var(--spacing-s) 0;
padding: var(--spacing-xs); padding: var(--spacing-2xs);
background-color: var(--background-color); background-color: var(--background-color);
border-width: 1px 1px 1px 7px; border-width: 1px 1px 1px 7px;
border-style: solid; border-style: solid;
border-color: var(--border-color); border-color: var(--border-color);
border-radius: var(--border-radius-small); border-radius: var(--border-radius-small);
line-height: var(--font-line-height-compact);
a { a {
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);

View File

@@ -11,6 +11,7 @@ describe('components', () => {
slots: { slots: {
default: 'This is a notice.', default: 'This is a notice.',
}, },
stubs: ['n8n-text'],
}); });
expect(wrapper.html()).toMatchSnapshot(); expect(wrapper.html()).toMatchSnapshot();
}); });
@@ -23,28 +24,31 @@ describe('components', () => {
id: 'notice', id: 'notice',
content: 'This is a notice.', content: 'This is a notice.',
}, },
stubs: ['n8n-text'],
}); });
expect(wrapper.html()).toMatchSnapshot(); expect(wrapper.html()).toMatchSnapshot();
}); });
it('should render html', () => { it('should render HTML', () => {
const wrapper = render(N8nNotice, { const wrapper = render(N8nNotice, {
props: { props: {
id: 'notice', id: 'notice',
content: '<strong>Hello world!</strong> This is a notice.', content: '<strong>Hello world!</strong> This is a notice.',
}, },
stubs: ['n8n-text'],
}); });
expect(wrapper.container.querySelectorAll('strong')).toHaveLength(1); expect(wrapper.container.querySelectorAll('strong')).toHaveLength(1);
expect(wrapper.html()).toMatchSnapshot(); expect(wrapper.html()).toMatchSnapshot();
}); });
it('should sanitize rendered html', () => { it('should sanitize rendered HTML', () => {
const wrapper = render(N8nNotice, { const wrapper = render(N8nNotice, {
props: { props: {
id: 'notice', id: 'notice',
content: '<script>alert(1);</script> This is a notice.', content: '<script>alert(1);</script> This is a notice.',
}, },
stubs: ['n8n-text'],
}); });
expect(wrapper.container.querySelector('script')).not.toBeTruthy(); expect(wrapper.container.querySelector('script')).not.toBeTruthy();
@@ -52,44 +56,5 @@ describe('components', () => {
}); });
}); });
}); });
describe('truncation', () => {
it('should truncate content longer than 150 characters', async () => {
const wrapper = render(N8nNotice, {
props: {
id: 'notice',
truncate: true,
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
},
});
const button = await wrapper.findByRole('button');
const region = await wrapper.findByRole('region');
expect(button).toBeVisible();
expect(button).toHaveTextContent('Show more');
expect(region).toBeVisible();
expect(region.textContent!.endsWith('...')).toBeTruthy();
});
it('should expand truncated text when clicking show more', async () => {
const wrapper = render(N8nNotice, {
props: {
id: 'notice',
truncate: true,
content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
},
});
const button = await wrapper.findByRole('button');
const region = await wrapper.findByRole('region');
await fireEvent.click(button);
expect(button).toHaveTextContent('Show less');
expect(region.textContent!.endsWith('...')).not.toBeTruthy();
});
});
}); });
}); });

View File

@@ -1,28 +1,33 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`components > N8nNotice > props > content > should render HTML 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_kaqw5_1 _warning_kaqw5_18\\">
<div class=\\"notice-content\\">
<n8n-text-stub size=\\"small\\" compact=\\"true\\" tag=\\"span\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_kaqw5_43\\"><strong>Hello world!</strong> This is a notice.</span></n8n-text-stub>
</div>
</div>"
`;
exports[`components > N8nNotice > props > content > should render correctly with content prop 1`] = ` exports[`components > N8nNotice > props > content > should render correctly with content prop 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_4m4il_1 _warning_4m4il_16\\"> "<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_kaqw5_1 _warning_kaqw5_18\\">
<div class=\\"notice-content\\"><span class=\\"_size-small_9dlpz_24 _regular_9dlpz_5\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_4m4il_41\\">This is a notice.</span> <div class=\\"notice-content\\">
<!----></span></div> <n8n-text-stub size=\\"small\\" compact=\\"true\\" tag=\\"span\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_kaqw5_43\\">This is a notice.</span></n8n-text-stub>
</div>
</div>" </div>"
`; `;
exports[`components > N8nNotice > props > content > should render html 1`] = ` exports[`components > N8nNotice > props > content > should sanitize rendered HTML 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_4m4il_1 _warning_4m4il_16\\"> "<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_kaqw5_1 _warning_kaqw5_18\\">
<div class=\\"notice-content\\"><span class=\\"_size-small_9dlpz_24 _regular_9dlpz_5\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_4m4il_41\\"><strong>Hello world!</strong> This is a notice.</span> <div class=\\"notice-content\\">
<!----></span></div> <n8n-text-stub size=\\"small\\" compact=\\"true\\" tag=\\"span\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_kaqw5_43\\"> This is a notice.</span></n8n-text-stub>
</div>" </div>
`;
exports[`components > N8nNotice > props > content > should sanitize rendered html 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_4m4il_1 _warning_4m4il_16\\">
<div class=\\"notice-content\\"><span class=\\"_size-small_9dlpz_24 _regular_9dlpz_5\\"><span id=\\"notice-content\\" role=\\"region\\" class=\\"_truncated_4m4il_41\\"> This is a notice.</span>
<!----></span></div>
</div>" </div>"
`; `;
exports[`components > N8nNotice > should render correctly 1`] = ` exports[`components > N8nNotice > should render correctly 1`] = `
"<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_4m4il_1 _warning_4m4il_16\\"> "<div id=\\"notice\\" role=\\"alert\\" class=\\"notice _notice_kaqw5_1 _warning_kaqw5_18\\">
<div class=\\"notice-content\\"><span class=\\"_size-small_9dlpz_24 _regular_9dlpz_5\\">This is a notice.</span></div> <div class=\\"notice-content\\">
<n8n-text-stub size=\\"small\\" compact=\\"true\\" tag=\\"span\\">This is a notice.</n8n-text-stub>
</div>
</div>" </div>"
`; `;

View File

@@ -47,7 +47,7 @@ export default {
.button { .button {
border-radius: 0; border-radius: 0;
padding: 0 var(--spacing-s); padding: 0 var(--spacing-xs);
display: flex; display: flex;
align-items: center; align-items: center;
height: 26px; height: 26px;

View File

@@ -82,6 +82,9 @@ export default {
limitPopperWidth: { limitPopperWidth: {
type: Boolean, type: Boolean,
}, },
noDataText: {
type: String,
},
}, },
methods: { methods: {
getSize(size: string): string | undefined { getSize(size: string): string | undefined {

View File

@@ -10,6 +10,12 @@ export default {
options: ['small', 'medium', 'large'], options: ['small', 'medium', 'large'],
}, },
}, },
type: {
control: {
type: 'select',
options: ['dots', 'ring'],
},
},
}, },
}; };

View File

@@ -1,10 +1,14 @@
<template functional> <template functional>
<component <span>
:is="$options.components.N8nIcon" <div v-if="props.type === 'ring'" class="lds-ring"><div></div><div></div><div></div><div></div></div>
icon="spinner" <component
:size="props.size" v-else
spin :is="$options.components.N8nIcon"
/> icon="spinner"
:size="props.size"
spin
/>
</span>
</template> </template>
<script lang="ts"> <script lang="ts">
@@ -22,6 +26,51 @@ export default {
return ['small', 'medium', 'large'].includes(value); return ['small', 'medium', 'large'].includes(value);
}, },
}, },
type: {
type: String,
validator: function (value: string): boolean {
return ['dots', 'ring'].includes(value);
},
default: 'dots',
},
}, },
}; };
</script> </script>
<style lang="scss">
.lds-ring {
display: inline-block;
position: relative;
width: 48px;
height: 48px;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 48px;
height: 48px;
border: 4px solid #fff;
border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: var(--color-primary) transparent transparent transparent;
}
.lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -26,6 +26,7 @@
theme="sticky" theme="sticky"
:content="content" :content="content"
:withMultiBreaks="true" :withMultiBreaks="true"
@markdown-click="onMarkdownClick"
/> />
</div> </div>
<div <div
@@ -164,6 +165,9 @@ export default mixins(Locale).extend({
onInput(value: string) { onInput(value: string) {
this.$emit('input', value); this.$emit('input', value);
}, },
onMarkdownClick(link, event) {
this.$emit('markdown-click', link, event);
},
onResize(values) { onResize(values) {
this.$emit('resize', values); this.$emit('resize', values);
}, },

View File

@@ -13,7 +13,7 @@
target="_blank" target="_blank"
:href="option.href" :href="option.href"
:class="[$style.link, $style.tab]" :class="[$style.link, $style.tab]"
@click="handleTabClick" @click="() => handleTabClick(option.value)"
> >
<div> <div>
{{ option.label }} {{ option.label }}
@@ -168,7 +168,7 @@ export default Vue.extend({
.button { .button {
position: absolute; position: absolute;
background-color: var(--color-background-light); background-color: var(--color-background-base);
z-index: 1; z-index: 1;
height: 24px; height: 24px;
width: 10px; width: 10px;

View File

@@ -16,7 +16,7 @@ export default Vue.extend({
size: { size: {
type: String, type: String,
default: 'medium', default: 'medium',
validator: (value: string): boolean => ['xsmall', 'small', 'medium', 'large', 'xlarge'].includes(value), validator: (value: string): boolean => ['xsmall', 'small', 'mini', 'medium', 'large', 'xlarge'].includes(value),
}, },
color: { color: {
type: String, type: String,

View File

@@ -120,7 +120,7 @@
var(--color-warning-tint-1-l) var(--color-warning-tint-1-l)
); );
--color-warning-tint-2-h: 34%; --color-warning-tint-2-h: 34;
--color-warning-tint-2-s: 80%; --color-warning-tint-2-s: 80%;
--color-warning-tint-2-l: 96%; --color-warning-tint-2-l: 96%;
--color-warning-tint-2: hsl( --color-warning-tint-2: hsl(

View File

@@ -691,7 +691,7 @@ $button-disabled-background-color: var(
var(--color-foreground-base) var(--color-foreground-base)
); );
/// color||Color|0 /// color||Color|0
$button-disabled-border-color: var(--border-color-base); $button-disabled-border-color: var(--button-disabled-border-color, var(--border-color-base));
/// color||Color|0 /// color||Color|0
$button-primary-border-color: var(--color-primary); $button-primary-border-color: var(--color-primary);

View File

@@ -1,6 +1,6 @@
{ {
"name": "n8n-editor-ui", "name": "n8n-editor-ui",
"version": "0.142.1", "version": "0.145.0",
"description": "Workflow Editor UI for n8n", "description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
@@ -28,7 +28,7 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-regular-svg-icons": "^6.1.1",
"luxon": "^2.3.0", "luxon": "^2.3.0",
"monaco-editor": "^0.29.1", "monaco-editor": "^0.29.1",
"n8n-design-system": "~0.19.0", "n8n-design-system": "~0.22.0",
"timeago.js": "^4.0.2", "timeago.js": "^4.0.2",
"v-click-outside": "^3.1.2", "v-click-outside": "^3.1.2",
"vue-fragment": "^1.5.2", "vue-fragment": "^1.5.2",
@@ -78,7 +78,7 @@
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.get": "^4.4.2", "lodash.get": "^4.4.2",
"lodash.set": "^4.3.2", "lodash.set": "^4.3.2",
"n8n-workflow": "~0.98.0", "n8n-workflow": "~0.101.0",
"monaco-editor-webpack-plugin": "^5.0.0", "monaco-editor-webpack-plugin": "^5.0.0",
"normalize-wheel": "^1.0.1", "normalize-wheel": "^1.0.1",
"prismjs": "^1.17.1", "prismjs": "^1.17.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

View File

@@ -159,6 +159,9 @@ export interface IExternalHooks {
run(eventName: string, metadata?: IDataObject): Promise<void>; run(eventName: string, metadata?: IDataObject): Promise<void>;
} }
/**
* @deprecated Do not add methods to this interface.
*/
export interface IRestApi { export interface IRestApi {
getActiveWorkflows(): Promise<string[]>; getActiveWorkflows(): Promise<string[]>;
getActivationError(id: string): Promise<IActivationError | undefined >; getActivationError(id: string): Promise<IActivationError | undefined >;
@@ -340,6 +343,7 @@ export interface IExecutionResponse extends IExecutionBase {
id: string; id: string;
data: IRunExecutionData; data: IRunExecutionData;
workflowData: IWorkflowDb; workflowData: IWorkflowDb;
executedNode?: string;
} }
export interface IExecutionShortResponse { export interface IExecutionShortResponse {
@@ -840,6 +844,8 @@ export interface IModalState {
activeId?: string | null; activeId?: string | null;
} }
export type IRunDataDisplayMode = 'table' | 'json' | 'binary';
export interface IUiState { export interface IUiState {
sidebarMenuCollapsed: boolean; sidebarMenuCollapsed: boolean;
modalStack: string[]; modalStack: string[];
@@ -848,6 +854,16 @@ export interface IUiState {
}; };
isPageLoading: boolean; isPageLoading: boolean;
currentView: string; currentView: string;
ndv: {
sessionId: string;
input: {
displayMode: IRunDataDisplayMode;
};
output: {
displayMode: IRunDataDisplayMode;
};
};
mainPanelPosition: number;
} }
export type ILogLevel = 'info' | 'debug' | 'warn' | 'error' | 'verbose'; export type ILogLevel = 'info' | 'debug' | 'warn' | 'error' | 'verbose';

View File

@@ -2,6 +2,10 @@ import { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from './helpers'; import { makeRestApiRequest } from './helpers';
export async function getNewWorkflow(context: IRestApiContext, name?: string) { export async function getNewWorkflow(context: IRestApiContext, name?: string) {
return await makeRestApiRequest(context, 'GET', `/workflows/new`, name ? { name } : {}); const response = await makeRestApiRequest(context, 'GET', `/workflows/new`, name ? { name } : {});
return {
name: response.name,
onboardingFlowEnabled: response.onboardingFlowEnabled === true,
};
} }

View File

@@ -101,7 +101,7 @@ export default mixins(
z-index: 10; z-index: 10;
width: 100%; width: 100%;
height: calc(100% - 50px); height: calc(100% - 50px);
background-color: #f9f9f9; background-color: var(--color-background-base);
overflow: hidden; overflow: hidden;
text-align: center; text-align: center;

View File

@@ -10,6 +10,7 @@
<div v-if="parameterOptions.length > 0 && !isReadOnly" class="param-options"> <div v-if="parameterOptions.length > 0 && !isReadOnly" class="param-options">
<n8n-button <n8n-button
v-if="parameter.options.length === 1" v-if="parameter.options.length === 1"
type="tertiary"
fullWidth fullWidth
@click="optionSelected(parameter.options[0].name)" @click="optionSelected(parameter.options[0].name)"
:label="getPlaceholderText" :label="getPlaceholderText"

View File

@@ -7,6 +7,7 @@
:value="credentialData[parameter.name]" :value="credentialData[parameter.name]"
:documentationUrl="documentationUrl" :documentationUrl="documentationUrl"
:showValidationWarnings="showValidationWarnings" :showValidationWarnings="showValidationWarnings"
eventSource="credentials"
@change="valueChanged" @change="valueChanged"
/> />
</form> </form>

View File

@@ -0,0 +1,153 @@
<template>
<div>
<div :class="$style['parameter-value-container']">
<n8n-select
:size="inputSize"
filterable
:value="displayValue"
:placeholder="parameter.placeholder ? getPlaceholder() : $locale.baseText('parameterInput.select')"
:title="displayTitle"
@change="(value) => $emit('valueChanged', value)"
@keydown.stop
@focus="$emit('setFocus')"
@blur="$emit('onBlur')"
>
<n8n-option
v-for="credType in supportedCredentialTypes"
:value="credType.name"
:key="credType.name"
:label="credType.displayName"
>
<div class="list-option">
<div class="option-headline">
{{ credType.displayName }}
</div>
<div
v-if="credType.description"
class="option-description"
v-html="credType.description"
/>
</div>
</n8n-option>
</n8n-select>
<slot name="issues-and-options" />
</div>
<scopes-notice
v-if="scopes.length > 0"
:activeCredentialType="activeCredentialType"
:scopes="scopes"
/>
<div>
<node-credentials
:node="node"
:overrideCredType="node.parameters[parameter.name]"
@credentialSelected="(updateInformation) => $emit('credentialSelected', updateInformation)"
/>
</div>
</div>
</template>
<script lang="ts">
import { ICredentialType } from 'n8n-workflow';
import Vue from 'vue';
import { mapGetters } from 'vuex';
import ScopesNotice from '@/components/ScopesNotice.vue';
import NodeCredentials from '@/components/NodeCredentials.vue';
export default Vue.extend({
name: 'CredentialsSelect',
components: {
ScopesNotice,
NodeCredentials,
},
props: [
'activeCredentialType',
'node',
'parameter',
'inputSize',
'displayValue',
'isReadOnly',
'displayTitle',
],
computed: {
...mapGetters('credentials', ['allCredentialTypes', 'getScopesByCredentialType']),
scopes(): string[] {
if (!this.activeCredentialType) return [];
return this.getScopesByCredentialType(this.activeCredentialType);
},
supportedCredentialTypes(): ICredentialType[] {
return this.allCredentialTypes.filter((c: ICredentialType) => this.isSupported(c.name));
},
},
methods: {
/**
* Check if a credential type belongs to one of the supported sets defined
* in the `credentialTypes` key in a `credentialsSelect` parameter
*/
isSupported(name: string): boolean {
const supported = this.getSupportedSets(this.parameter.credentialTypes);
const checkedCredType = this.$store.getters['credentials/getCredentialTypeByName'](name);
for (const property of supported.has) {
if (checkedCredType[property] !== undefined) {
// edge case: `httpHeaderAuth` has `authenticate` auth but belongs to generic auth
if (name === 'httpHeaderAuth' && property === 'authenticate') continue;
return true;
}
}
if (
checkedCredType.extends &&
checkedCredType.extends.some(
(parentType: string) => supported.extends.includes(parentType),
)
) {
return true;
}
if (checkedCredType.extends && supported.extends.length) {
// recurse upward until base credential type
// e.g. microsoftDynamicsOAuth2Api -> microsoftOAuth2Api -> oAuth2Api
return checkedCredType.extends.reduce(
(acc: boolean, parentType: string) => acc || this.isSupported(parentType),
false,
);
}
return false;
},
getSupportedSets(credentialTypes: string[]) {
return credentialTypes.reduce<{ extends: string[]; has: string[] }>((acc, cur) => {
const _extends = cur.split('extends:');
if (_extends.length === 2) {
acc.extends.push(_extends[1]);
return acc;
}
const _has = cur.split('has:');
if (_has.length === 2) {
acc.has.push(_has[1]);
return acc;
}
return acc;
}, { extends: [], has: [] });
},
},
});
</script>
<style module lang="scss">
.parameter-value-container {
display: flex;
align-items: center;
}
</style>

View File

@@ -1,184 +0,0 @@
<template>
<el-dialog
:visible="(!!node || renaming) && !isActiveStickyNode"
:before-close="close"
:show-close="false"
custom-class="data-display-wrapper"
width="85%"
append-to-body
>
<n8n-tooltip placement="bottom-start" :value="showTriggerWaitingWarning" :disabled="!showTriggerWaitingWarning" :manual="true">
<div slot="content" :class="$style.triggerWarning">{{ $locale.baseText('ndv.backToCanvas.waitingForTriggerWarning') }}</div>
<div :class="$style.backToCanvas" @click="close">
<n8n-icon icon="arrow-left" color="text-xlight" size="medium" />
<n8n-text color="text-xlight" size="medium" :bold="true">{{ $locale.baseText('ndv.backToCanvas') }}</n8n-text>
</div>
</n8n-tooltip>
<div class="data-display" v-if="node" >
<NodeSettings :eventBus="settingsEventBus" @valueChanged="valueChanged" @execute="onNodeExecute" />
<RunData @openSettings="openSettings" />
</div>
</el-dialog>
</template>
<script lang="ts">
import {
INodeTypeDescription,
} from 'n8n-workflow';
import {
INodeUi,
IUpdateInformation,
} from '../Interface';
import { externalHooks } from '@/components/mixins/externalHooks';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import NodeSettings from '@/components/NodeSettings.vue';
import RunData from '@/components/RunData.vue';
import mixins from 'vue-typed-mixins';
import Vue from 'vue';
import { mapGetters } from 'vuex';
import { STICKY_NODE_TYPE } from '@/constants';
export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
name: 'DataDisplay',
components: {
NodeSettings,
RunData,
},
props: {
renaming: {
type: Boolean,
},
},
data () {
return {
settingsEventBus: new Vue(),
triggerWaitingWarningEnabled: false,
};
},
computed: {
...mapGetters(['executionWaitingForWebhook']),
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
showTriggerWaitingWarning(): boolean {
return this.triggerWaitingWarningEnabled && !!this.nodeType && !this.nodeType.group.includes('trigger') && this.workflowRunning && this.executionWaitingForWebhook;
},
node (): INodeUi {
return this.$store.getters.activeNode;
},
nodeType (): INodeTypeDescription | null {
if (this.node) {
return this.$store.getters.nodeType(this.node.type, this.node.typeVersion);
}
return null;
},
isActiveStickyNode(): boolean {
return !!this.$store.getters.activeNode && this.$store.getters.activeNode.type === STICKY_NODE_TYPE;
},
},
watch: {
node (node, oldNode) {
if(node && !oldNode && !this.isActiveStickyNode) {
this.triggerWaitingWarningEnabled = false;
this.$externalHooks().run('dataDisplay.nodeTypeChanged', { nodeSubtitle: this.getNodeSubtitle(node, this.nodeType, this.getWorkflow()) });
this.$telemetry.track('User opened node modal', { node_type: this.nodeType ? this.nodeType.name : '', workflow_id: this.$store.getters.workflowId });
}
if (window.top && !this.isActiveStickyNode) {
window.top.postMessage(JSON.stringify({command: (node? 'openNDV': 'closeNDV')}), '*');
}
},
},
methods: {
onNodeExecute() {
setTimeout(() => {
if (!this.node || !this.workflowRunning) {
return;
}
this.triggerWaitingWarningEnabled = true;
}, 1000);
},
openSettings() {
this.settingsEventBus.$emit('openSettings');
},
valueChanged (parameterData: IUpdateInformation) {
this.$emit('valueChanged', parameterData);
},
nodeTypeSelected (nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
close () {
this.$externalHooks().run('dataDisplay.nodeEditingFinished');
this.triggerWaitingWarningEnabled = false;
this.$store.commit('setActiveNode', null);
},
},
});
</script>
<style lang="scss">
.data-display-wrapper {
height: 85%;
margin-top: 48px !important;
.el-dialog__header {
padding: 0 !important;
}
.el-dialog__body {
padding: 0 !important;
height: 100%;
min-height: 400px;
overflow: hidden;
border-radius: 8px;
}
}
.data-display {
background-color: #fff;
border-radius: 8px;
display: flex;
height: 100%;
}
.fade-enter-active, .fade-enter-to, .fade-leave-active {
transition: all .75s ease;
opacity: 1;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
</style>
<style lang="scss" module>
.triggerWarning {
max-width: 180px;
}
.backToCanvas {
position: absolute;
top: -40px;
&:hover {
cursor: pointer;
}
> * {
margin-right: var(--spacing-3xs);
}
}
@media (min-width: $--breakpoint-lg) {
.backToCanvas {
position: fixed;
top: 10px;
left: 20px;
}
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div
:class="{[$style.dragging]: isDragging }"
@mousedown="onDragStart"
>
<slot :isDragging="isDragging"></slot>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
data() {
return {
isDragging: false,
};
},
methods: {
onDragStart(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
this.isDragging = true;
this.$emit('dragstart');
document.body.style.cursor = 'grabbing';
window.addEventListener('mousemove', this.onDrag);
window.addEventListener('mouseup', this.onDragEnd);
},
onDrag(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
this.$emit('drag', {x: e.pageX, y: e.pageY});
},
onDragEnd(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
document.body.style.cursor = 'unset';
window.removeEventListener('mousemove', this.onDrag);
window.removeEventListener('mouseup', this.onDragEnd);
setTimeout(() => {
this.$emit('dragend');
this.isDragging = false;
}, 0);
},
},
});
</script>
<style lang="scss" module>
.dragging {
visibility: visible;
cursor: grabbing;
}
</style>

View File

@@ -51,6 +51,8 @@ import { genericHelpers } from '@/components/mixins/genericHelpers';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
const MAPPING_PARAMS = [`$evaluateExpression`, `$item`, `$jmespath`, `$node`, `$binary`, `$data`, `$env`, `$json`, `$now`, `$parameters`, `$position`, `$resumeWebhookUrl`, `$runIndex`, `$today`, `$workflow`];
export default mixins( export default mixins(
externalHooks, externalHooks,
genericHelpers, genericHelpers,
@@ -61,6 +63,7 @@ export default mixins(
'parameter', 'parameter',
'path', 'path',
'value', 'value',
'eventSource',
], ],
components: { components: {
ExpressionInput, ExpressionInput,
@@ -110,7 +113,14 @@ export default mixins(
this.$externalHooks().run('expressionEdit.dialogVisibleChanged', { dialogVisible: newValue, parameter: this.parameter, value: this.value, resolvedExpressionValue }); this.$externalHooks().run('expressionEdit.dialogVisibleChanged', { dialogVisible: newValue, parameter: this.parameter, value: this.value, resolvedExpressionValue });
if (!newValue) { if (!newValue) {
this.$telemetry.track('User closed Expression Editor', { empty_expression: (this.value === '=') || (this.value === '={{}}') || !this.value, workflow_id: this.$store.getters.workflowId }); this.$telemetry.track('User closed Expression Editor', {
empty_expression: (this.value === '=') || (this.value === '={{}}') || !this.value,
workflow_id: this.$store.getters.workflowId,
source: this.eventSource,
session_id: this.$store.getters['ui/ndvSessionId'],
has_parameter: this.value.includes('$parameter'),
has_mapping: !!MAPPING_PARAMS.find((param) => this.value.includes(param)),
});
} }
}, },
}, },

View File

@@ -82,6 +82,7 @@
<div v-if="parameterOptions.length > 0 && !isReadOnly"> <div v-if="parameterOptions.length > 0 && !isReadOnly">
<n8n-button <n8n-button
v-if="parameter.options.length === 1" v-if="parameter.options.length === 1"
type="tertiary"
fullWidth fullWidth
@click="optionSelected(parameter.options[0].name)" @click="optionSelected(parameter.options[0].name)"
:label="getPlaceholderText" :label="getPlaceholderText"

View File

@@ -0,0 +1,234 @@
<template>
<RunData
:nodeUi="currentNode"
:runIndex="runIndex"
:linkedRuns="linkedRuns"
:canLinkRuns="canLinkRuns"
:tooMuchDataTitle="$locale.baseText('ndv.input.tooMuchData.title')"
:noDataInBranchMessage="$locale.baseText('ndv.input.noOutputDataInBranch')"
:isExecuting="isExecutingPrevious"
:executingMessage="$locale.baseText('ndv.input.executingPrevious')"
:sessionId="sessionId"
:overrideOutputs="connectedCurrentNodeOutputs"
paneType="input"
@linkRun="onLinkRun"
@unlinkRun="onUnlinkRun"
@runChange="onRunIndexChange">
<template v-slot:header>
<div :class="$style.titleSection">
<n8n-select v-if="parentNodes.length" :popper-append-to-body="true" size="small" :value="currentNodeName" @input="onSelect" :no-data-text="$locale.baseText('ndv.input.noNodesFound')" :placeholder="$locale.baseText('ndv.input.parentNodes')" filterable>
<template slot="prepend">
<span :class="$style.title">{{ $locale.baseText('ndv.input') }}</span>
</template>
<n8n-option v-for="node in parentNodes" :value="node.name" :key="node.name" class="node-option">
{{ truncate(node.name) }}&nbsp;
<span >{{ $locale.baseText('ndv.input.nodeDistance', {adjustToNumber: node.depth}) }}</span>
</n8n-option>
</n8n-select>
<span v-else :class="$style.title">{{ $locale.baseText('ndv.input') }}</span>
</div>
</template>
<template v-slot:node-not-run>
<div :class="$style.noOutputData" v-if="parentNodes.length">
<n8n-text tag="div" :bold="true" color="text-dark" size="large">{{ $locale.baseText('ndv.input.noOutputData.title') }}</n8n-text>
<NodeExecuteButton type="outline" :transparent="true" :nodeName="currentNodeName" :label="$locale.baseText('ndv.input.noOutputData.executePrevious')" @execute="onNodeExecute" />
<n8n-text tag="div" size="small">
{{ $locale.baseText('ndv.input.noOutputData.hint') }}
</n8n-text>
</div>
<div :class="$style.notConnected" v-else>
<div>
<WireMeUp />
</div>
<n8n-text tag="div" :bold="true" color="text-dark" size="large">{{ $locale.baseText('ndv.input.notConnected.title') }}</n8n-text>
<n8n-text tag="div">
{{ $locale.baseText('ndv.input.notConnected.message') }}
<a href="https://docs.n8n.io/workflows/connections/" target="_blank" @click="onConnectionHelpClick">
{{$locale.baseText('ndv.input.notConnected.learnMore')}}
</a>
</n8n-text>
</div>
</template>
<template v-slot:no-output-data>
<n8n-text tag="div" :bold="true" color="text-dark" size="large">{{ $locale.baseText('ndv.input.noOutputData') }}</n8n-text>
</template>
</RunData>
</template>
<script lang="ts">
import { INodeUi } from '@/Interface';
import { IConnectedNode, Workflow } from 'n8n-workflow';
import RunData from './RunData.vue';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
import NodeExecuteButton from './NodeExecuteButton.vue';
import WireMeUp from './WireMeUp.vue';
export default mixins(
workflowHelpers,
).extend({
name: 'InputPanel',
components: { RunData, NodeExecuteButton, WireMeUp },
props: {
currentNodeName: {
type: String,
},
runIndex: {
type: Number,
},
linkedRuns: {
type: Boolean,
},
workflow: {
},
canLinkRuns: {
type: Boolean,
},
sessionId: {
type: String,
},
},
computed: {
isExecutingPrevious(): boolean {
const triggeredNode = this.$store.getters.executedNode;
const executingNode = this.$store.getters.executingNode;
if (this.activeNode && triggeredNode === this.activeNode.name && this.activeNode.name !== executingNode) {
return true;
}
if (executingNode || triggeredNode) {
return !!this.parentNodes.find((node) => node.name === executingNode || node.name === triggeredNode);
}
return false;
},
currentWorkflow(): Workflow {
return this.workflow as Workflow;
},
activeNode (): INodeUi | null {
return this.$store.getters.activeNode;
},
currentNode (): INodeUi {
return this.$store.getters.getNodeByName(this.currentNodeName);
},
connectedCurrentNodeOutputs(): number[] | undefined {
const search = this.parentNodes.find(({name}) => name === this.currentNodeName);
if (search) {
return search.indicies;
}
return undefined;
},
parentNodes (): IConnectedNode[] {
if (!this.activeNode) {
return [];
}
const nodes: IConnectedNode[] = (this.workflow as Workflow).getParentNodesByDepth(this.activeNode.name);
return nodes.filter(({name}, i) => (this.activeNode && (name !== this.activeNode.name)) && nodes.findIndex((node) => node.name === name) === i);
},
},
methods: {
onNodeExecute() {
this.$emit('execute');
if (this.activeNode) {
this.$telemetry.track('User clicked ndv button', {
node_type: this.activeNode.type,
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
pane: 'input',
type: 'executePrevious',
});
}
},
onRunIndexChange(run: number) {
this.$emit('runChange', run);
},
onLinkRun() {
this.$emit('linkRun');
},
onUnlinkRun() {
this.$emit('unlinkRun');
},
onSelect(value: string) {
const index = this.parentNodes.findIndex((node) => node.name === value) + 1;
this.$emit('select', value, index);
},
onConnectionHelpClick() {
if (this.activeNode) {
this.$telemetry.track('User clicked ndv link', {
node_type: this.activeNode.type,
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
pane: 'input',
type: 'not-connected-help',
});
}
},
truncate(nodeName: string) {
const truncated = nodeName.substring(0, 30);
if (truncated.length < nodeName.length) {
return `${truncated}...`;
}
return truncated;
},
},
});
</script>
<style lang="scss" module>
.titleSection {
display: flex;
max-width: 300px;
> * {
margin-right: var(--spacing-2xs);
}
}
.noOutputData {
max-width: 180px;
> *:first-child {
margin-bottom: var(--spacing-m);
}
> * {
margin-bottom: var(--spacing-2xs);
}
}
.notConnected {
max-width: 300px;
> *:first-child {
margin-bottom: var(--spacing-m);
}
> * {
margin-bottom: var(--spacing-2xs);
}
}
.title {
text-transform: uppercase;
color: var(--color-text-light);
letter-spacing: 3px;
font-size: var(--font-size-s);
font-weight: var(--font-weight-bold);
}
</style>
<style lang="scss" scoped>
.node-option {
font-weight: var(--font-weight-regular) !important;
span {
color: var(--color-text-light);
}
&.selected > span {
color: var(--color-primary);
}
}
</style>

View File

@@ -263,6 +263,16 @@ export default mixins(
}, },
helpMenuItems (): object[] { helpMenuItems (): object[] {
return [ return [
{
id: 'quickstart',
type: 'link',
properties: {
href: 'https://www.youtube.com/watch?v=RpjQTGKm-ok',
title: this.$locale.baseText('mainSidebar.helpMenuItems.quickstart'),
icon: 'video',
newWindow: true,
},
},
{ {
id: 'docs', id: 'docs',
type: 'link', type: 'link',

View File

@@ -28,7 +28,7 @@
<div v-if="values && Object.keys(values).length === 0 || isReadOnly" class="no-items-exist"> <div v-if="values && Object.keys(values).length === 0 || isReadOnly" class="no-items-exist">
<n8n-text size="small">{{ $locale.baseText('multipleParameter.currentlyNoItemsExist') }}</n8n-text> <n8n-text size="small">{{ $locale.baseText('multipleParameter.currentlyNoItemsExist') }}</n8n-text>
</div> </div>
<n8n-button v-if="!isReadOnly" fullWidth @click="addItem()" :label="addButtonText" /> <n8n-button v-if="!isReadOnly" type="tertiary" fullWidth @click="addItem()" :label="addButtonText" />
</div> </div>
</n8n-input-label> </n8n-input-label>

View File

@@ -0,0 +1,215 @@
<template>
<div>
<div :class="$style.inputPanel" v-if="!isTriggerNode" :style="inputPanelStyles">
<slot name="input"></slot>
</div>
<div :class="$style.outputPanel" :style="outputPanelStyles">
<slot name="output"></slot>
</div>
<div :class="$style.mainPanel" :style="mainPanelStyles">
<div :class="$style.dragButtonContainer" @click="close">
<PanelDragButton
:class="{ [$style.draggable]: true, [$style.visible]: isDragging }"
v-if="!isTriggerNode"
:canMoveLeft="canMoveLeft"
:canMoveRight="canMoveRight"
@dragstart="onDragStart"
@drag="onDrag"
@dragend="onDragEnd"
/>
</div>
<slot name="main"></slot>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import PanelDragButton from './PanelDragButton.vue';
const MAIN_PANEL_WIDTH = 360;
const SIDE_MARGIN = 24;
export default Vue.extend({
name: 'NDVDraggablePanels',
components: {
PanelDragButton,
},
props: {
isTriggerNode: {
type: Boolean,
},
},
data() {
return {
windowWidth: 0,
isDragging: false,
};
},
mounted() {
this.setTotalWidth();
window.addEventListener('resize', this.setTotalWidth);
this.$emit('init', { position: this.getRelativePosition() });
},
destroyed() {
window.removeEventListener('resize', this.setTotalWidth);
},
computed: {
mainPanelPosition(): number {
if (this.isTriggerNode) {
return 0;
}
const relativePosition = this.$store.getters['ui/mainPanelPosition'] as number;
return relativePosition * this.windowWidth;
},
inputPanelMargin(): number {
return this.isTriggerNode ? 0 : 80;
},
minimumLeftPosition(): number {
return SIDE_MARGIN + this.inputPanelMargin;
},
maximumRightPosition(): number {
return this.windowWidth - MAIN_PANEL_WIDTH - this.minimumLeftPosition;
},
mainPanelFinalPositionPx(): number {
const padding = this.minimumLeftPosition;
let pos = this.mainPanelPosition + MAIN_PANEL_WIDTH / 2;
pos = Math.max(padding, pos - MAIN_PANEL_WIDTH);
pos = Math.min(pos, this.maximumRightPosition);
return pos;
},
canMoveLeft(): boolean {
return this.mainPanelFinalPositionPx > this.minimumLeftPosition;
},
canMoveRight(): boolean {
return this.mainPanelFinalPositionPx < this.maximumRightPosition;
},
mainPanelStyles(): { left: string } {
return {
left: `${this.mainPanelFinalPositionPx}px`,
};
},
inputPanelStyles(): { width: string } {
let width = this.mainPanelPosition - MAIN_PANEL_WIDTH / 2 - SIDE_MARGIN;
width = Math.min(
width,
this.windowWidth - SIDE_MARGIN * 2 - this.inputPanelMargin - MAIN_PANEL_WIDTH,
);
width = Math.max(320, width);
return {
width: `${width}px`,
};
},
outputPanelStyles(): { width: string } {
let width = this.windowWidth - this.mainPanelPosition - MAIN_PANEL_WIDTH / 2 - SIDE_MARGIN;
width = Math.min(
width,
this.windowWidth - SIDE_MARGIN * 2 - this.inputPanelMargin - MAIN_PANEL_WIDTH,
);
width = Math.max(320, width);
return {
width: `${width}px`,
};
},
},
methods: {
getRelativePosition() {
const current = this.mainPanelFinalPositionPx + MAIN_PANEL_WIDTH / 2 - this.windowWidth / 2;
const pos = Math.floor(
(current / ((this.maximumRightPosition - this.minimumLeftPosition) / 2)) * 100,
);
return pos;
},
onDragStart() {
this.isDragging = true;
this.$emit('dragstart', { position: this.getRelativePosition() });
},
onDrag(e: {x: number, y: number}) {
const relativePosition = e.x / this.windowWidth;
this.$store.commit('ui/setMainPanelRelativePosition', relativePosition);
},
onDragEnd() {
setTimeout(() => {
this.isDragging = false;
this.$emit('dragend', {
windowWidth: this.windowWidth,
position: this.getRelativePosition(),
});
}, 0);
},
setTotalWidth() {
this.windowWidth = window.innerWidth;
},
close() {
this.$emit('close');
},
},
});
</script>
<style lang="scss" module>
$--main-panel-width: 360px;
.dataPanel {
position: absolute;
height: calc(100% - 2 * var(--spacing-l));
position: absolute;
top: var(--spacing-l);
z-index: 0;
}
.inputPanel {
composes: dataPanel;
left: var(--spacing-l);
> * {
border-radius: var(--border-radius-large) 0 0 var(--border-radius-large);
}
}
.outputPanel {
composes: dataPanel;
right: var(--spacing-l);
width: $--main-panel-width;
> * {
border-radius: 0 var(--border-radius-large) var(--border-radius-large) 0;
}
}
.mainPanel {
position: absolute;
height: 100%;
&:hover {
.draggable {
visibility: visible;
}
}
}
.draggable {
position: absolute;
left: 40%;
visibility: hidden;
}
.dragButtonContainer {
position: absolute;
top: -12px;
width: $--main-panel-width;
height: 12px;
&:hover .draggable {
visibility: visible;
}
}
.visible {
visibility: visible;
}
</style>

View File

@@ -75,7 +75,7 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import { WAIT_TIME_UNLIMITED } from '@/constants'; import { CUSTOM_API_CALL_KEY, WAIT_TIME_UNLIMITED } from '@/constants';
import { externalHooks } from '@/components/mixins/externalHooks'; import { externalHooks } from '@/components/mixins/externalHooks';
import { nodeBase } from '@/components/mixins/nodeBase'; import { nodeBase } from '@/components/mixins/nodeBase';
import { nodeHelpers } from '@/components/mixins/nodeHelpers'; import { nodeHelpers } from '@/components/mixins/nodeHelpers';
@@ -323,7 +323,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
mounted() { mounted() {
this.setSubtitle(); this.setSubtitle();
setTimeout(() => { setTimeout(() => {
this.$emit('run', {name: this.data.name, data: this.nodeRunData, waiting: !!this.waiting}); this.$emit('run', {name: this.data && this.data.name, data: this.nodeRunData, waiting: !!this.waiting});
}, 0); }, 0);
}, },
data () { data () {
@@ -336,7 +336,11 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
}, },
methods: { methods: {
setSubtitle() { setSubtitle() {
this.nodeSubtitle = this.getNodeSubtitle(this.data, this.nodeType, this.getWorkflow()) || ''; const nodeSubtitle = this.getNodeSubtitle(this.data, this.nodeType, this.getWorkflow()) || '';
this.nodeSubtitle = nodeSubtitle.includes(CUSTOM_API_CALL_KEY)
? ''
: nodeSubtitle;
}, },
disableNode () { disableNode () {
this.disableNodes([this.data]); this.disableNodes([this.data]);

View File

@@ -1,5 +1,5 @@
<template> <template>
<div v-if="credentialTypesNodeDescriptionDisplayed.length" :class="$style.container"> <div v-if="credentialTypesNodeDescriptionDisplayed.length" :class="['node-credentials', $style.container]">
<div v-for="credentialTypeDescription in credentialTypesNodeDescriptionDisplayed" :key="credentialTypeDescription.name"> <div v-for="credentialTypeDescription in credentialTypesNodeDescriptionDisplayed" :key="credentialTypeDescription.name">
<n8n-input-label <n8n-input-label
:label="$locale.baseText( :label="$locale.baseText(
@@ -11,15 +11,20 @@
} }
)" )"
:bold="false" :bold="false"
size="small"
:set="issues = getIssues(credentialTypeDescription.name)" :set="issues = getIssues(credentialTypeDescription.name)"
size="small"
> >
<div v-if="isReadOnly"> <div v-if="isReadOnly">
<n8n-input disabled :value="selected && selected[credentialTypeDescription.name] && selected[credentialTypeDescription.name].name" size="small" /> <n8n-input
:value="selected && selected[credentialTypeDescription.name] && selected[credentialTypeDescription.name].name"
disabled
size="small"
/>
</div> </div>
<div
<div :class="issues.length ? $style.hasIssues : $style.input" v-else > v-else
:class="issues.length ? $style.hasIssues : $style.input"
>
<n8n-select :value="getSelectedId(credentialTypeDescription.name)" @change="(value) => onCredentialSelected(credentialTypeDescription.name, value)" :placeholder="$locale.baseText('nodeCredentials.selectCredential')" size="small"> <n8n-select :value="getSelectedId(credentialTypeDescription.name)" @change="(value) => onCredentialSelected(credentialTypeDescription.name, value)" :placeholder="$locale.baseText('nodeCredentials.selectCredential')" size="small">
<n8n-option <n8n-option
v-for="(item) in credentialOptions[credentialTypeDescription.name]" v-for="(item) in credentialOptions[credentialTypeDescription.name]"
@@ -82,6 +87,7 @@ export default mixins(
name: 'NodeCredentials', name: 'NodeCredentials',
props: [ props: [
'node', // INodeUi 'node', // INodeUi
'overrideCredType', // cred type
], ],
data () { data () {
return { return {
@@ -92,6 +98,7 @@ export default mixins(
computed: { computed: {
...mapGetters('credentials', { ...mapGetters('credentials', {
credentialOptions: 'allCredentialsByType', credentialOptions: 'allCredentialsByType',
getCredentialTypeByName: 'getCredentialTypeByName',
}), }),
credentialTypesNode (): string[] { credentialTypesNode (): string[] {
return this.credentialTypesNodeDescription return this.credentialTypesNodeDescription
@@ -106,6 +113,10 @@ export default mixins(
credentialTypesNodeDescription (): INodeCredentialDescription[] { credentialTypesNodeDescription (): INodeCredentialDescription[] {
const node = this.node as INodeUi; const node = this.node as INodeUi;
const credType = this.getCredentialTypeByName(this.overrideCredType);
if (credType) return [credType];
const activeNodeType = this.$store.getters.nodeType(node.type, node.typeVersion) as INodeTypeDescription | null; const activeNodeType = this.$store.getters.nodeType(node.type, node.typeVersion) as INodeTypeDescription | null;
if (activeNodeType && activeNodeType.credentials) { if (activeNodeType && activeNodeType.credentials) {
return activeNodeType.credentials; return activeNodeType.credentials;
@@ -198,7 +209,15 @@ export default mixins(
return; return;
} }
this.$telemetry.track('User selected credential from node modal', { credential_type: credentialType, workflow_id: this.$store.getters.workflowId }); this.$telemetry.track(
'User selected credential from node modal',
{
credential_type: credentialType,
node_type: this.node.type,
...(this.hasProxyAuth(this.node) ? { is_service_specific: true } : {}),
workflow_id: this.$store.getters.workflowId,
},
);
const selectedCredentials = this.$store.getters['credentials/getCredentialById'](credentialId); const selectedCredentials = this.$store.getters['credentials/getCredentialById'](credentialId);
const oldCredentials = this.node.credentials && this.node.credentials[credentialType] ? this.node.credentials[credentialType] : {}; const oldCredentials = this.node.credentials && this.node.credentials[credentialType] ? this.node.credentials[credentialType] : {};
@@ -295,11 +314,7 @@ export default mixins(
<style lang="scss" module> <style lang="scss" module>
.container { .container {
margin: var(--spacing-xs) 0; margin-top: var(--spacing-xs);
> * {
margin-bottom: var(--spacing-xs);
}
} }
.warning { .warning {

View File

@@ -0,0 +1,524 @@
<template>
<el-dialog
:visible="(!!activeNode || renaming) && !isActiveStickyNode"
:before-close="close"
:show-close="false"
custom-class="data-display-wrapper"
class="ndv-wrapper"
width="auto"
append-to-body
>
<n8n-tooltip
placement="bottom-start"
:value="showTriggerWaitingWarning"
:disabled="!showTriggerWaitingWarning"
:manual="true"
>
<div slot="content" :class="$style.triggerWarning">
{{ $locale.baseText('ndv.backToCanvas.waitingForTriggerWarning') }}
</div>
<div :class="$style.backToCanvas" @click="close">
<n8n-icon icon="arrow-left" color="text-xlight" size="medium" />
<n8n-text color="text-xlight" size="medium" :bold="true">
{{ $locale.baseText('ndv.backToCanvas') }}
</n8n-text>
</div>
</n8n-tooltip>
<div class="data-display" v-if="activeNode">
<div @click="close" :class="$style.modalBackground"></div>
<NDVDraggablePanels :isTriggerNode="isTriggerNode" @close="close" @init="onPanelsInit" @dragstart="onDragStart" @dragend="onDragEnd">
<template #input>
<InputPanel
:workflow="workflow"
:canLinkRuns="canLinkRuns"
:runIndex="inputRun"
:linkedRuns="linked"
:currentNodeName="inputNodeName"
:sessionId="sessionId"
@linkRun="onLinkRunToInput"
@unlinkRun="() => onUnlinkRun('input')"
@runChange="onRunInputIndexChange"
@openSettings="openSettings"
@select="onInputSelect"
@execute="onNodeExecute"
/>
</template>
<template #output>
<OutputPanel
:canLinkRuns="canLinkRuns"
:runIndex="outputRun"
:linkedRuns="linked"
:sessionId="sessionId"
@linkRun="onLinkRunToOutput"
@unlinkRun="() => onUnlinkRun('output')"
@runChange="onRunOutputIndexChange"
@openSettings="openSettings"
/>
</template>
<template #main>
<NodeSettings
:eventBus="settingsEventBus"
:dragging="isDragging"
:sessionId="sessionId"
@valueChanged="valueChanged"
@execute="onNodeExecute"
/>
<a
v-if="featureRequestUrl"
@click="onFeatureRequestClick"
:class="$style.featureRequest"
target="_blank"
>
<font-awesome-icon icon="lightbulb" />
{{ $locale.baseText('ndv.featureRequest') }}
</a>
</template>
</NDVDraggablePanels>
</div>
</el-dialog>
</template>
<script lang="ts">
import {
INodeConnections,
INodeTypeDescription,
IRunData,
IRunExecutionData,
Workflow,
} from 'n8n-workflow';
import { IExecutionResponse, INodeUi, IUpdateInformation } from '../Interface';
import { externalHooks } from '@/components/mixins/externalHooks';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import NodeSettings from '@/components/NodeSettings.vue';
import NDVDraggablePanels from './NDVDraggablePanels.vue';
import mixins from 'vue-typed-mixins';
import Vue from 'vue';
import OutputPanel from './OutputPanel.vue';
import InputPanel from './InputPanel.vue';
import { mapGetters } from 'vuex';
import { BASE_NODE_SURVEY_URL, START_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
import { editor } from 'monaco-editor';
export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
name: 'NodeDetailsView',
components: {
NodeSettings,
InputPanel,
OutputPanel,
NDVDraggablePanels,
},
props: {
renaming: {
type: Boolean,
},
},
data() {
return {
settingsEventBus: new Vue(),
runInputIndex: -1,
runOutputIndex: -1,
isLinkingEnabled: true,
selectedInput: undefined as string | undefined,
triggerWaitingWarningEnabled: false,
isDragging: false,
mainPanelPosition: 0,
};
},
mounted() {
this.$store.commit('ui/setNDVSessionId');
},
computed: {
...mapGetters(['executionWaitingForWebhook']),
sessionId(): string {
return this.$store.getters['ui/ndvSessionId'];
},
workflowRunning(): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
showTriggerWaitingWarning(): boolean {
return (
this.triggerWaitingWarningEnabled &&
!!this.activeNodeType &&
!this.activeNodeType.group.includes('trigger') &&
this.workflowRunning &&
this.executionWaitingForWebhook
);
},
activeNode(): INodeUi {
return this.$store.getters.activeNode;
},
inputNodeName(): string | undefined {
return this.selectedInput || this.parentNode;
},
inputNode(): INodeUi | null {
if (this.inputNodeName) {
return this.$store.getters.getNodeByName(this.inputNodeName);
}
return null;
},
activeNodeType(): INodeTypeDescription | null {
if (this.activeNode) {
return this.$store.getters.nodeType(this.activeNode.type, this.activeNode.typeVersion);
}
return null;
},
workflow(): Workflow {
return this.getWorkflow();
},
parentNodes(): string[] {
if (this.activeNode) {
return (
this.workflow.getParentNodesByDepth(this.activeNode.name, 1).map(({ name }) => name) || []
);
}
return [];
},
parentNode(): string | undefined {
return this.parentNodes[0];
},
isTriggerNode(): boolean {
return (
!!this.activeNodeType &&
(this.activeNodeType.group.includes('trigger') ||
this.activeNodeType.name === START_NODE_TYPE)
);
},
isActiveStickyNode(): boolean {
return (
!!this.$store.getters.activeNode && this.$store.getters.activeNode.type === STICKY_NODE_TYPE
);
},
workflowExecution(): IExecutionResponse | null {
return this.$store.getters.getWorkflowExecution;
},
workflowRunData(): IRunData | null {
if (this.workflowExecution === null) {
return null;
}
const executionData: IRunExecutionData = this.workflowExecution.data;
if (executionData && executionData.resultData) {
return executionData.resultData.runData;
}
return null;
},
maxOutputRun(): number {
if (this.activeNode === null) {
return 0;
}
const runData: IRunData | null = this.workflowRunData;
if (runData === null || !runData.hasOwnProperty(this.activeNode.name)) {
return 0;
}
if (runData[this.activeNode.name].length) {
return runData[this.activeNode.name].length - 1;
}
return 0;
},
outputRun(): number {
if (this.runOutputIndex === -1) {
return this.maxOutputRun;
}
return Math.min(this.runOutputIndex, this.maxOutputRun);
},
maxInputRun(): number {
if (this.inputNode === null) {
return 0;
}
const runData: IRunData | null = this.workflowRunData;
if (runData === null || !runData.hasOwnProperty(this.inputNode.name)) {
return 0;
}
if (runData[this.inputNode.name].length) {
return runData[this.inputNode.name].length - 1;
}
return 0;
},
inputRun(): number {
if (this.isLinkingEnabled && this.maxOutputRun === this.maxInputRun) {
return this.outputRun;
}
if (this.runInputIndex === -1) {
return this.maxInputRun;
}
return Math.min(this.runInputIndex, this.maxInputRun);
},
canLinkRuns(): boolean {
return this.maxOutputRun > 0 && this.maxOutputRun === this.maxInputRun;
},
linked(): boolean {
return this.isLinkingEnabled && this.canLinkRuns;
},
inputPanelMargin(): number {
return this.isTriggerNode ? 0 : 80;
},
featureRequestUrl(): string {
if (!this.activeNodeType) {
return '';
}
return `${BASE_NODE_SURVEY_URL}${this.activeNodeType.name}`;
},
},
watch: {
activeNode(node, oldNode) {
if (node && !oldNode && !this.isActiveStickyNode) {
this.runInputIndex = -1;
this.runOutputIndex = -1;
this.isLinkingEnabled = true;
this.selectedInput = undefined;
this.triggerWaitingWarningEnabled = false;
this.$store.commit('ui/setNDVSessionId');
this.$externalHooks().run('dataDisplay.nodeTypeChanged', {
nodeSubtitle: this.getNodeSubtitle(node, this.activeNodeType, this.getWorkflow()),
});
setTimeout(() => {
const outogingConnections = this.$store.getters.outgoingConnectionsByNodeName(
this.activeNode.name,
) as INodeConnections;
this.$telemetry.track('User opened node modal', {
node_type: this.activeNodeType ? this.activeNodeType.name : '',
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
parameters_pane_position: this.mainPanelPosition,
input_first_connector_runs: this.maxInputRun,
output_first_connector_runs: this.maxOutputRun,
selected_view_inputs: this.isTriggerNode
? 'trigger'
: this.$store.getters['ui/inputPanelDispalyMode'],
selected_view_outputs: this.$store.getters['ui/outputPanelDispalyMode'],
input_connectors: this.parentNodes.length,
output_connectors:
outogingConnections && outogingConnections.main && outogingConnections.main.length,
input_displayed_run_index: this.inputRun,
output_displayed_run_index: this.outputRun,
});
}, 0); // wait for display mode to be set correctly
}
if (window.top && !this.isActiveStickyNode) {
window.top.postMessage(JSON.stringify({ command: node ? 'openNDV' : 'closeNDV' }), '*');
}
},
maxOutputRun() {
this.runOutputIndex = -1;
},
maxInputRun() {
this.runInputIndex = -1;
},
},
methods: {
onFeatureRequestClick() {
window.open(this.featureRequestUrl, '_blank');
this.$telemetry.track('User clicked ndv link', {
node_type: this.activeNode.type,
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
pane: 'main',
type: 'i-wish-this-node-would',
});
},
onPanelsInit(e: { position: number }) {
this.mainPanelPosition = e.position;
},
onDragStart(e: { position: number }) {
this.isDragging = true;
this.mainPanelPosition = e.position;
},
onDragEnd(e: { windowWidth: number, position: number }) {
this.isDragging = false;
this.$telemetry.track('User moved parameters pane', {
window_width: e.windowWidth,
start_position: this.mainPanelPosition,
end_position: e.position,
node_type: this.activeNodeType ? this.activeNodeType.name : '',
session_id: this.sessionId,
workflow_id: this.$store.getters.workflowId,
});
this.mainPanelPosition = e.position;
},
onLinkRunToInput() {
this.runOutputIndex = this.runInputIndex;
this.isLinkingEnabled = true;
this.trackLinking('input');
},
onLinkRunToOutput() {
this.isLinkingEnabled = true;
this.trackLinking('output');
},
onUnlinkRun(pane: string) {
this.runInputIndex = this.runOutputIndex;
this.isLinkingEnabled = false;
this.trackLinking(pane);
},
trackLinking(pane: string) {
this.$telemetry.track('User changed ndv run linking', {
node_type: this.activeNodeType ? this.activeNodeType.name : '',
session_id: this.sessionId,
linked: this.linked,
pane,
});
},
onNodeExecute() {
setTimeout(() => {
if (!this.activeNode || !this.workflowRunning) {
return;
}
this.triggerWaitingWarningEnabled = true;
}, 1000);
},
openSettings() {
this.settingsEventBus.$emit('openSettings');
},
valueChanged(parameterData: IUpdateInformation) {
this.$emit('valueChanged', parameterData);
},
nodeTypeSelected(nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
close() {
if (this.isDragging) {
return;
}
this.$externalHooks().run('dataDisplay.nodeEditingFinished');
this.$telemetry.track('User closed node modal', {
node_type: this.activeNodeType ? this.activeNodeType.name : '',
session_id: this.sessionId,
workflow_id: this.$store.getters.workflowId,
});
this.triggerWaitingWarningEnabled = false;
this.$store.commit('setActiveNode', null);
this.$store.commit('ui/resetNDVSessionId');
},
onRunOutputIndexChange(run: number) {
this.runOutputIndex = run;
this.trackRunChange(run, 'output');
},
onRunInputIndexChange(run: number) {
this.runInputIndex = run;
if (this.linked) {
this.runOutputIndex = run;
}
this.trackRunChange(run, 'input');
},
trackRunChange(run: number, pane: string) {
this.$telemetry.track('User changed ndv run dropdown', {
session_id: this.sessionId,
run_index: run,
node_type: this.activeNodeType ? this.activeNodeType.name : '',
pane,
});
},
onInputSelect(value: string, index: number) {
this.runInputIndex = -1;
this.isLinkingEnabled = true;
this.selectedInput = value;
this.$telemetry.track('User changed ndv input dropdown', {
node_type: this.activeNode ? this.activeNode.type : '',
session_id: this.sessionId,
workflow_id: this.$store.getters.workflowId,
selection_value: index,
input_node_type: this.inputNode ? this.inputNode.type : '',
});
},
},
});
</script>
<style lang="scss">
.ndv-wrapper {
overflow: hidden;
}
.data-display-wrapper {
height: 93%;
width: 100%;
margin-top: var(--spacing-xl) !important;
background: none;
border: none;
.el-dialog__header {
padding: 0 !important;
}
.el-dialog__body {
padding: 0 !important;
height: 100%;
min-height: 400px;
overflow: hidden;
border-radius: 8px;
}
}
.data-display {
height: 100%;
width: 100%;
display: flex;
}
</style>
<style lang="scss" module>
$--main-panel-width: 360px;
.modalBackground {
height: 100%;
width: 100%;
}
.triggerWarning {
max-width: 180px;
}
.backToCanvas {
position: fixed;
top: var(--spacing-xs);
left: var(--spacing-l);
&:hover {
cursor: pointer;
}
> * {
margin-right: var(--spacing-3xs);
}
}
@media (min-width: $--breakpoint-lg) {
.backToCanvas {
top: var(--spacing-xs);
left: var(--spacing-m);
}
}
.featureRequest {
position: absolute;
bottom: var(--spacing-4xs);
left: calc(100% + var(--spacing-s));
color: var(--color-text-xlight);
font-size: var(--font-size-2xs);
white-space: nowrap;
* {
margin-right: var(--spacing-3xs);
}
}
</style>

View File

@@ -1,8 +1,11 @@
<template> <template>
<n8n-button <n8n-button
:loading="workflowRunning" :loading="nodeRunning"
:label="label" :disabled="workflowRunning && !nodeRunning"
size="small" :label="buttonLabel"
:type="type"
:size="size"
:transparentBackground="transparent"
@click="onClick" @click="onClick"
/> />
</template> </template>
@@ -20,6 +23,19 @@ export default mixins(
nodeName: { nodeName: {
type: String, type: String,
}, },
label: {
type: String,
},
type: {
type: String,
},
size: {
type: String,
},
transparent: {
type: Boolean,
default: false,
},
}, },
computed: { computed: {
node (): INodeUi { node (): INodeUi {
@@ -31,6 +47,11 @@ export default mixins(
} }
return null; return null;
}, },
nodeRunning (): boolean {
const triggeredNode = this.$store.getters.executedNode;
const executingNode = this.$store.getters.executingNode;
return executingNode === this.node.name || triggeredNode === this.node.name;
},
workflowRunning (): boolean { workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning'); return this.$store.getters.isActionActive('workflowRunning');
}, },
@@ -43,7 +64,10 @@ export default mixins(
isScheduleTrigger (): boolean { isScheduleTrigger (): boolean {
return !!(this.nodeType && this.nodeType.group.includes('schedule')); return !!(this.nodeType && this.nodeType.group.includes('schedule'));
}, },
label(): string { buttonLabel(): string {
if (this.label) {
return this.label;
}
if (this.isPollingTypeNode) { if (this.isPollingTypeNode) {
return this.$locale.baseText('ndv.execute.fetchEvent'); return this.$locale.baseText('ndv.execute.fetchEvent');
} }

View File

@@ -1,15 +1,15 @@
<template> <template>
<div class="node-settings" @keydown.stop> <div :class="{'node-settings': true, 'dragging': dragging}" @keydown.stop>
<div :class="$style.header"> <div :class="$style.header">
<div class="header-side-menu"> <div class="header-side-menu">
<NodeTitle class="node-name" :value="node.name" :nodeType="nodeType" @input="nameChanged" :readOnly="isReadOnly"></NodeTitle> <NodeTitle class="node-name" :value="node.name" :nodeType="nodeType" @input="nameChanged" :readOnly="isReadOnly"></NodeTitle>
<div <div
v-if="!isReadOnly" v-if="!isReadOnly"
> >
<NodeExecuteButton :nodeName="node.name" @execute="onNodeExecute" /> <NodeExecuteButton :nodeName="node.name" @execute="onNodeExecute" size="small" />
</div> </div>
</div> </div>
<NodeTabs v-model="openPanel" :nodeType="nodeType" /> <NodeSettingsTabs v-model="openPanel" :nodeType="nodeType" :sessionId="sessionId" />
</div> </div>
<div class="node-is-not-valid" v-if="node && !nodeValid"> <div class="node-is-not-valid" v-if="node && !nodeValid">
<n8n-text> <n8n-text>
@@ -23,14 +23,35 @@
</div> </div>
<div class="node-parameters-wrapper" v-if="node && nodeValid"> <div class="node-parameters-wrapper" v-if="node && nodeValid">
<div v-show="openPanel === 'params'"> <div v-show="openPanel === 'params'">
<node-credentials :node="node" @credentialSelected="credentialSelected"></node-credentials> <node-webhooks
<node-webhooks :node="node" :nodeType="nodeType" /> :node="node"
<parameter-input-list :parameters="parametersNoneSetting" :hideDelete="true" :nodeValues="nodeValues" path="parameters" @valueChanged="valueChanged" /> :nodeType="nodeType"
/>
<parameter-input-list
:parameters="parametersNoneSetting"
:hideDelete="true"
:nodeValues="nodeValues" path="parameters" @valueChanged="valueChanged"
>
<node-credentials
:node="node"
@credentialSelected="credentialSelected"
/>
</parameter-input-list>
<div v-if="parametersNoneSetting.length === 0" class="no-parameters"> <div v-if="parametersNoneSetting.length === 0" class="no-parameters">
<n8n-text> <n8n-text>
{{ $locale.baseText('nodeSettings.thisNodeDoesNotHaveAnyParameters') }} {{ $locale.baseText('nodeSettings.thisNodeDoesNotHaveAnyParameters') }}
</n8n-text> </n8n-text>
</div> </div>
<div v-if="isCustomApiCallSelected(nodeValues)" class="parameter-item parameter-notice">
<n8n-notice
:content="$locale.baseText(
'nodeSettings.useTheHttpRequestNode',
{ interpolate: { nodeTypeDisplayName: nodeType.displayName } }
)"
/>
</div>
</div> </div>
<div v-show="openPanel === 'settings'"> <div v-show="openPanel === 'settings'">
<parameter-input-list :parameters="nodeSettings" :hideDelete="true" :nodeValues="nodeValues" path="" @valueChanged="valueChanged" /> <parameter-input-list :parameters="nodeSettings" :hideDelete="true" :nodeValues="nodeValues" path="" @valueChanged="valueChanged" />
@@ -59,7 +80,7 @@ import NodeTitle from '@/components/NodeTitle.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue'; import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ParameterInputList from '@/components/ParameterInputList.vue'; import ParameterInputList from '@/components/ParameterInputList.vue';
import NodeCredentials from '@/components/NodeCredentials.vue'; import NodeCredentials from '@/components/NodeCredentials.vue';
import NodeTabs from '@/components/NodeTabs.vue'; import NodeSettingsTabs from '@/components/NodeSettingsTabs.vue';
import NodeWebhooks from '@/components/NodeWebhooks.vue'; import NodeWebhooks from '@/components/NodeWebhooks.vue';
import { get, set, unset } from 'lodash'; import { get, set, unset } from 'lodash';
@@ -82,7 +103,7 @@ export default mixins(
NodeCredentials, NodeCredentials,
ParameterInputFull, ParameterInputFull,
ParameterInputList, ParameterInputList,
NodeTabs, NodeSettingsTabs,
NodeWebhooks, NodeWebhooks,
NodeExecuteButton, NodeExecuteButton,
}, },
@@ -151,6 +172,12 @@ export default mixins(
props: { props: {
eventBus: { eventBus: {
}, },
dragging: {
type: Boolean,
},
sessionId: {
type: String,
},
}, },
data () { data () {
return { return {
@@ -542,8 +569,13 @@ export default mixins(
<style lang="scss"> <style lang="scss">
.node-settings { .node-settings {
overflow: hidden; overflow: hidden;
min-width: 350px; min-width: 360px;
max-width: 350px; max-width: 360px;
background-color: var(--color-background-xlight);
height: 100%;
border: var(--border-base);
border-radius: var(--border-radius-large);
box-shadow: 0 4px 16px rgb(50 61 85 / 10%);
.no-parameters { .no-parameters {
margin-top: var(--spacing-xs); margin-top: var(--spacing-xs);
@@ -569,6 +601,11 @@ export default mixins(
overflow-y: auto; overflow-y: auto;
padding: 0 20px 200px 20px; padding: 0 20px 200px 20px;
} }
&.dragging {
border-color: var(--color-primary);
box-shadow: 0px 6px 16px rgba(255, 74, 51, 0.15);
}
} }
.parameter-content { .parameter-content {

View File

@@ -8,7 +8,7 @@
<script lang="ts"> <script lang="ts">
import { externalHooks } from '@/components/mixins/externalHooks'; import { externalHooks } from '@/components/mixins/externalHooks';
import { ITab } from '@/Interface'; import { INodeUi, ITab } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow'; import { INodeTypeDescription } from 'n8n-workflow';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
@@ -16,15 +16,21 @@ import mixins from 'vue-typed-mixins';
export default mixins( export default mixins(
externalHooks, externalHooks,
).extend({ ).extend({
name: 'NodeTabs', name: 'NodeSettingsTabs',
props: { props: {
value: { value: {
type: String, type: String,
}, },
nodeType: { nodeType: {
}, },
sessionId: {
type: String,
},
}, },
computed: { computed: {
activeNode(): INodeUi {
return this.$store.getters.activeNode;
},
documentationUrl (): string { documentationUrl (): string {
const nodeType = this.nodeType as INodeTypeDescription | null; const nodeType = this.nodeType as INodeTypeDescription | null;
if (!nodeType) { if (!nodeType) {
@@ -70,6 +76,13 @@ export default mixins(
onTabSelect(tab: string) { onTabSelect(tab: string) {
if (tab === 'docs' && this.nodeType) { if (tab === 'docs' && this.nodeType) {
this.$externalHooks().run('dataDisplay.onDocumentationUrlClick', { nodeType: this.nodeType as INodeTypeDescription, documentationUrl: this.documentationUrl }); this.$externalHooks().run('dataDisplay.onDocumentationUrlClick', { nodeType: this.nodeType as INodeTypeDescription, documentationUrl: this.documentationUrl });
this.$telemetry.track('User clicked ndv link', {
node_type: this.activeNode.type,
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
pane: 'main',
type: 'docs',
});
} }
if(tab === 'settings' && this.nodeType) { if(tab === 'settings' && this.nodeType) {

View File

@@ -0,0 +1,209 @@
<template>
<RunData
:nodeUi="node"
:runIndex="runIndex"
:linkedRuns="linkedRuns"
:canLinkRuns="canLinkRuns"
:tooMuchDataTitle="$locale.baseText('ndv.output.tooMuchData.title')"
:noDataInBranchMessage="$locale.baseText('ndv.output.noOutputDataInBranch')"
:isExecuting="isNodeRunning"
:executingMessage="$locale.baseText('ndv.output.executing')"
:sessionId="sessionId"
paneType="output"
@runChange="onRunIndexChange"
@linkRun="onLinkRun"
@unlinkRun="onUnlinkRun"
>
<template v-slot:header>
<div :class="$style.titleSection">
<span :class="$style.title">{{ $locale.baseText('ndv.output') }}</span>
<RunInfo v-if="runsCount === 1" :taskData="runTaskData" />
<n8n-info-tip
theme="warning"
type="tooltip"
tooltipPlacement="right"
v-if="hasNodeRun && staleData"
>
<template>
<span v-html="$locale.baseText('ndv.output.staleDataWarning')"></span>
</template>
</n8n-info-tip>
</div>
</template>
<template v-slot:node-not-run>
<n8n-text v-if="workflowRunning">{{ $locale.baseText('ndv.output.waitingToRun') }}</n8n-text>
<n8n-text v-else-if="isPollingTypeNode">{{ $locale.baseText('ndv.output.pollEventNodeHint') }}</n8n-text>
<n8n-text v-else-if="isTriggerNode && !isScheduleTrigger">{{ $locale.baseText('ndv.output.triggerEventNodeHint') }}</n8n-text>
<n8n-text v-else>{{ $locale.baseText('ndv.output.runNodeHint') }}</n8n-text>
</template>
<template v-slot:no-output-data>
<n8n-text :bold="true" color="text-dark" size="large">{{ $locale.baseText('ndv.output.noOutputData.title') }}</n8n-text>
<n8n-text>
{{ $locale.baseText('ndv.output.noOutputData.message') }}
<a @click="openSettings">{{ $locale.baseText('ndv.output.noOutputData.message.settings') }}</a>
{{ $locale.baseText('ndv.output.noOutputData.message.settingsOption') }}
</n8n-text>
</template>
<template #run-info v-if="runsCount > 1">
<RunInfo :taskData="runTaskData" />
</template>
</RunData>
</template>
<script lang="ts">
import { IExecutionResponse, INodeUi } from '@/Interface';
import { INodeTypeDescription, IRunData, IRunExecutionData, ITaskData } from 'n8n-workflow';
import Vue from 'vue';
import RunData from './RunData.vue';
import RunInfo from './RunInfo.vue';
export default Vue.extend({
name: 'OutputPanel',
components: { RunData, RunInfo },
props: {
runIndex: {
type: Number,
},
linkedRuns: {
type: Boolean,
},
canLinkRuns: {
type: Boolean,
},
sessionId: {
type: String,
},
},
computed: {
node(): INodeUi {
return this.$store.getters.activeNode;
},
nodeType (): INodeTypeDescription | null {
if (this.node) {
return this.$store.getters.nodeType(this.node.type, this.node.typeVersion);
}
return null;
},
isTriggerNode (): boolean {
return !!(this.nodeType && this.nodeType.group.includes('trigger'));
},
isPollingTypeNode (): boolean {
return !!(this.nodeType && this.nodeType.polling);
},
isScheduleTrigger (): boolean {
return !!(this.nodeType && this.nodeType.group.includes('schedule'));
},
isNodeRunning(): boolean {
const executingNode = this.$store.getters.executingNode;
return executingNode === this.node.name;
},
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
workflowExecution(): IExecutionResponse | null {
return this.$store.getters.getWorkflowExecution;
},
workflowRunData(): IRunData | null {
if (this.workflowExecution === null) {
return null;
}
const executionData: IRunExecutionData = this.workflowExecution.data;
return executionData.resultData.runData;
},
hasNodeRun(): boolean {
return Boolean(
this.node && this.workflowRunData && this.workflowRunData.hasOwnProperty(this.node.name),
);
},
runTaskData(): ITaskData | null {
if (!this.node || this.workflowExecution === null) {
return null;
}
const runData = this.workflowRunData;
if (runData === null || !runData.hasOwnProperty(this.node.name)) {
return null;
}
if (runData[this.node.name].length <= this.runIndex) {
return null;
}
return runData[this.node.name][this.runIndex];
},
runsCount(): number {
if (this.node === null) {
return 0;
}
const runData: IRunData | null = this.workflowRunData;
if (runData === null || !runData.hasOwnProperty(this.node.name)) {
return 0;
}
if (runData[this.node.name].length) {
return runData[this.node.name].length;
}
return 0;
},
staleData(): boolean {
if (!this.node) {
return false;
}
const updatedAt = this.$store.getters.getParametersLastUpdated(this.node.name);
if (!updatedAt || !this.runTaskData) {
return false;
}
const runAt = this.runTaskData.startTime;
return updatedAt > runAt;
},
},
methods: {
onLinkRun() {
this.$emit('linkRun');
},
onUnlinkRun() {
this.$emit('unlinkRun');
},
openSettings() {
this.$emit('openSettings');
this.$telemetry.track('User clicked ndv link', {
node_type: this.node.type,
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
pane: 'output',
type: 'settings',
});
},
onRunIndexChange(run: number) {
this.$emit('runChange', run);
},
},
});
</script>
<style lang="scss" module>
.titleSection {
display: flex;
> * {
margin-right: var(--spacing-2xs);
}
}
.title {
text-transform: uppercase;
color: var(--color-text-light);
letter-spacing: 3px;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-s);
}
</style>

View File

@@ -0,0 +1,132 @@
<template>
<Draggable @drag="onDrag" @dragstart="onDragStart" @dragend="onDragEnd">
<template v-slot="{ isDragging }">
<div
:class="{ [$style.dragButton]: true }"
>
<span v-if="canMoveLeft" :class="{ [$style.leftArrow]: true, [$style.visible]: isDragging }">
<font-awesome-icon icon="arrow-left" />
</span>
<span v-if="canMoveRight" :class="{ [$style.rightArrow]: true, [$style.visible]: isDragging }">
<font-awesome-icon icon="arrow-right" />
</span>
<div :class="$style.grid">
<div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
</div>
</template>
</Draggable>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import Draggable from './Draggable.vue';
import dragging from './Draggable.vue';
export default mixins(dragging).extend({
components: {
Draggable,
},
props: {
canMoveRight: {
type: Boolean,
},
canMoveLeft: {
type: Boolean,
},
},
methods: {
onDrag(e: {x: number, y: number}) {
this.$emit('drag', e);
},
onDragStart() {
this.$emit('dragstart');
},
onDragEnd() {
this.$emit('dragend');
},
},
});
</script>
<style lang="scss" module>
.dragButton {
background-color: var(--color-background-base);
width: 64px;
height: 21px;
border-top-left-radius: var(--border-radius-base);
border-top-right-radius: var(--border-radius-base);
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
&:hover {
.leftArrow, .rightArrow {
visibility: visible;
}
}
}
.visible {
visibility: visible !important;
}
.arrow {
position: absolute;
color: var(--color-background-xlight);
font-size: var(--font-size-3xs);
visibility: hidden;
top: 0;
}
.leftArrow {
composes: arrow;
left: -16px;
}
.rightArrow {
composes: arrow;
right: -16px;
}
.grid {
> div {
display: flex;
&:first-child {
> div {
margin-bottom: 2px;
}
}
> div {
height: 2px;
width: 2px;
border-radius: 50%;
background-color: var(--color-foreground-xdark);
margin-right: 4px;
&:last-child {
margin-right: 0;
}
}
}
}
</style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div @keydown.stop :class="parameterInputClasses"> <div @keydown.stop :class="parameterInputClasses">
<expression-edit :dialogVisible="expressionEditDialogVisible" :value="value" :parameter="parameter" :path="path" @closeDialog="closeExpressionEditDialog" @valueChanged="expressionUpdated"></expression-edit> <expression-edit :dialogVisible="expressionEditDialogVisible" :value="value" :parameter="parameter" :path="path" :eventSource="eventSource || 'ndv'" @closeDialog="closeExpressionEditDialog" @valueChanged="expressionUpdated"></expression-edit>
<div class="parameter-input ignore-key-press" :style="parameterInputWrapperStyle" @click="openExpressionEdit"> <div class="parameter-input ignore-key-press" :style="parameterInputWrapperStyle" @click="openExpressionEdit">
<n8n-input <n8n-input
@@ -104,12 +104,44 @@
:placeholder="parameter.placeholder" :placeholder="parameter.placeholder"
/> />
<credentials-select
v-else-if="parameter.type === 'credentialsSelect' || (parameter.name === 'genericAuthType')"
ref="inputField"
:parameter="parameter"
:node="node"
:activeCredentialType="activeCredentialType"
:inputSize="inputSize"
:displayValue="displayValue"
:isReadOnly="isReadOnly"
:displayTitle="displayTitle"
@credentialSelected="credentialSelected"
@valueChanged="valueChanged"
@setFocus="setFocus"
@onBlur="onBlur"
>
<template v-slot:issues-and-options>
<parameter-issues
:issues="getIssues"
/>
<parameter-options
v-if="displayOptionsComputed"
:displayOptionsComputed="displayOptionsComputed"
:parameter="parameter"
:isValueExpression="isValueExpression"
:isDefault="isDefault"
:hasRemoteMethod="hasRemoteMethod"
@optionSelected="optionSelected"
/>
</template>
</credentials-select>
<n8n-select <n8n-select
v-else-if="parameter.type === 'options'" v-else-if="parameter.type === 'options'"
ref="inputField" ref="inputField"
:size="inputSize" :size="inputSize"
filterable filterable
:value="displayValue" :value="displayValue"
:placeholder="parameter.placeholder ? getPlaceholder() : $locale.baseText('parameterInput.select')"
:loading="remoteParameterOptionsLoading" :loading="remoteParameterOptionsLoading"
:disabled="isReadOnly || remoteParameterOptionsLoading" :disabled="isReadOnly || remoteParameterOptionsLoading"
:title="displayTitle" :title="displayTitle"
@@ -168,26 +200,21 @@
/> />
</div> </div>
<div class="parameter-issues" v-if="getIssues.length"> <parameter-issues
<n8n-tooltip placement="top" > v-if="parameter.type !== 'credentialsSelect'"
<div slot="content" v-html="`${$locale.baseText('parameterInput.issues')}:<br />&nbsp;&nbsp;- ` + getIssues.join('<br />&nbsp;&nbsp;- ')"></div> :issues="getIssues"
<font-awesome-icon icon="exclamation-triangle" /> />
</n8n-tooltip>
</div> <parameter-options
v-if="displayOptionsComputed && parameter.type !== 'credentialsSelect'"
:displayOptionsComputed="displayOptionsComputed"
:parameter="parameter"
:isValueExpression="isValueExpression"
:isDefault="isDefault"
:hasRemoteMethod="hasRemoteMethod"
@optionSelected="optionSelected"
/>
<div class="parameter-options" v-if="displayOptionsComputed">
<el-dropdown trigger="click" @command="optionSelected" size="mini">
<span class="el-dropdown-link">
<font-awesome-icon icon="cogs" class="reset-icon clickable" :title="$locale.baseText('parameterInput.parameterOptions')"/>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="addExpression" v-if="parameter.noDataExpression !== true && !isValueExpression">{{ $locale.baseText('parameterInput.addExpression') }}</el-dropdown-item>
<el-dropdown-item command="removeExpression" v-if="parameter.noDataExpression !== true && isValueExpression">{{ $locale.baseText('parameterInput.removeExpression') }}</el-dropdown-item>
<el-dropdown-item command="refreshOptions" v-if="hasRemoteMethod">{{ $locale.baseText('parameterInput.refreshList') }}</el-dropdown-item>
<el-dropdown-item command="resetValue" :disabled="isDefault" divided>{{ $locale.baseText('parameterInput.resetValue') }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div> </div>
</template> </template>
@@ -196,6 +223,7 @@ import { get } from 'lodash';
import { import {
INodeUi, INodeUi,
INodeUpdatePropertiesInformation,
} from '@/Interface'; } from '@/Interface';
import { import {
NodeHelpers, NodeHelpers,
@@ -208,7 +236,12 @@ import {
} from 'n8n-workflow'; } from 'n8n-workflow';
import CodeEdit from '@/components/CodeEdit.vue'; import CodeEdit from '@/components/CodeEdit.vue';
import CredentialsSelect from '@/components/CredentialsSelect.vue';
import ExpressionEdit from '@/components/ExpressionEdit.vue'; import ExpressionEdit from '@/components/ExpressionEdit.vue';
import NodeCredentials from '@/components/NodeCredentials.vue';
import ScopesNotice from '@/components/ScopesNotice.vue';
import ParameterOptions from '@/components/ParameterOptions.vue';
import ParameterIssues from '@/components/ParameterIssues.vue';
// @ts-ignore // @ts-ignore
import PrismEditor from 'vue-prism-editor'; import PrismEditor from 'vue-prism-editor';
import TextEdit from '@/components/TextEdit.vue'; import TextEdit from '@/components/TextEdit.vue';
@@ -218,6 +251,8 @@ import { showMessage } from '@/components/mixins/showMessage';
import { workflowHelpers } from '@/components/mixins/workflowHelpers'; import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import { CUSTOM_API_CALL_KEY } from '@/constants';
import { mapGetters } from 'vuex';
export default mixins( export default mixins(
externalHooks, externalHooks,
@@ -230,7 +265,12 @@ export default mixins(
components: { components: {
CodeEdit, CodeEdit,
ExpressionEdit, ExpressionEdit,
NodeCredentials,
CredentialsSelect,
PrismEditor, PrismEditor,
ScopesNotice,
ParameterOptions,
ParameterIssues,
TextEdit, TextEdit,
}, },
props: [ props: [
@@ -244,6 +284,7 @@ export default mixins(
'hideIssues', // boolean 'hideIssues', // boolean
'errorHighlight', 'errorHighlight',
'isForCredential', // boolean 'isForCredential', // boolean
'eventSource', // string
], ],
data () { data () {
return { return {
@@ -256,6 +297,8 @@ export default mixins(
remoteParameterOptionsLoadingIssues: null as string | null, remoteParameterOptionsLoadingIssues: null as string | null,
textEditDialogVisible: false, textEditDialogVisible: false,
tempValue: '', // el-date-picker and el-input does not seem to work without v-model so add one tempValue: '', // el-date-picker and el-input does not seem to work without v-model so add one
CUSTOM_API_CALL_KEY,
activeCredentialType: '',
dateTimePickerOptions: { dateTimePickerOptions: {
shortcuts: [ shortcuts: [
{ {
@@ -302,6 +345,7 @@ export default mixins(
}, },
}, },
computed: { computed: {
...mapGetters('credentials', ['allCredentialTypes']),
areExpressionsDisabled(): boolean { areExpressionsDisabled(): boolean {
return this.$store.getters['ui/areExpressionsDisabled']; return this.$store.getters['ui/areExpressionsDisabled'];
}, },
@@ -372,6 +416,13 @@ export default mixins(
returnValue = this.expressionValueComputed; returnValue = this.expressionValueComputed;
} }
if (this.parameter.type === 'credentialsSelect') {
const credType = this.$store.getters['credentials/getCredentialTypeByName'](this.value);
if (credType) {
returnValue = credType.displayName;
}
}
if (this.parameter.type === 'color' && this.getArgument('showAlpha') === true && returnValue.charAt(0) === '#') { if (this.parameter.type === 'color' && this.getArgument('showAlpha') === true && returnValue.charAt(0) === '#') {
// Convert the value to rgba that el-color-picker can display it correctly // Convert the value to rgba that el-color-picker can display it correctly
const bigint = parseInt(returnValue.slice(1), 16); const bigint = parseInt(returnValue.slice(1), 16);
@@ -470,7 +521,17 @@ export default mixins(
const issues = NodeHelpers.getParameterIssues(this.parameter, this.node.parameters, newPath.join('.'), this.node); const issues = NodeHelpers.getParameterIssues(this.parameter, this.node.parameters, newPath.join('.'), this.node);
if (['options', 'multiOptions'].includes(this.parameter.type) && this.remoteParameterOptionsLoading === false && this.remoteParameterOptionsLoadingIssues === null) { if (this.parameter.type === 'credentialsSelect' && this.displayValue === '') {
issues.parameters = issues.parameters || {};
const issue = this.$locale.baseText('parameterInput.selectACredentialTypeFromTheDropdown');
issues.parameters[this.parameter.name] = [issue];
} else if (
['options', 'multiOptions'].includes(this.parameter.type) &&
this.remoteParameterOptionsLoading === false &&
this.remoteParameterOptionsLoadingIssues === null
) {
// Check if the value resolves to a valid option // Check if the value resolves to a valid option
// Currently it only displays an error in the node itself in // Currently it only displays an error in the node itself in
// case the value is not valid. The workflow can still be executed // case the value is not valid. The workflow can still be executed
@@ -478,10 +539,13 @@ export default mixins(
const validOptions = this.parameterOptions!.map((options: INodePropertyOptions) => options.value); const validOptions = this.parameterOptions!.map((options: INodePropertyOptions) => options.value);
const checkValues: string[] = []; const checkValues: string[] = [];
if (Array.isArray(this.displayValue)) {
checkValues.push.apply(checkValues, this.displayValue); if (!this.skipCheck(this.displayValue)) {
} else { if (Array.isArray(this.displayValue)) {
checkValues.push(this.displayValue as string); checkValues.push.apply(checkValues, this.displayValue);
} else {
checkValues.push(this.displayValue as string);
}
} }
for (const checkValue of checkValues) { for (const checkValue of checkValues) {
@@ -489,7 +553,13 @@ export default mixins(
if (issues.parameters === undefined) { if (issues.parameters === undefined) {
issues.parameters = {}; issues.parameters = {};
} }
issues.parameters[this.parameter.name] = [`The value "${checkValue}" is not supported!`];
const issue = this.$locale.baseText(
'parameterInput.theValueIsNotSupported',
{ interpolate: { checkValue } },
);
issues.parameters[this.parameter.name] = [issue];
} }
} }
} else if (this.remoteParameterOptionsLoadingIssues !== null) { } else if (this.remoteParameterOptionsLoadingIssues !== null) {
@@ -556,6 +626,9 @@ export default mixins(
const styles = { const styles = {
width: '100%', width: '100%',
}; };
if (this.parameter.type === 'credentialsSelect') {
return styles;
}
if (this.displayOptionsComputed === true) { if (this.displayOptionsComputed === true) {
deductWidth += 25; deductWidth += 25;
} }
@@ -582,6 +655,23 @@ export default mixins(
}, },
}, },
methods: { methods: {
credentialSelected (updateInformation: INodeUpdatePropertiesInformation) {
// Update the values on the node
this.$store.commit('updateNodeProperties', updateInformation);
const node = this.$store.getters.getNodeByName(updateInformation.name);
// Update the issues
this.updateNodeCredentialIssues(node);
this.$externalHooks().run('nodeSettings.credentialSelected', { updateInformation });
},
/**
* Check whether a param value must be skipped when collecting node param issues for validation.
*/
skipCheck(value: string | number | boolean | null) {
return typeof value === 'string' && value.includes(CUSTOM_API_CALL_KEY);
},
getPlaceholder(): string { getPlaceholder(): string {
return this.isForCredential return this.isForCredential
? this.$locale.credText().placeholder(this.parameter) ? this.$locale.credText().placeholder(this.parameter)
@@ -640,6 +730,8 @@ export default mixins(
parameter_field_type: this.parameter.type, parameter_field_type: this.parameter.type,
new_expression: !this.isValueExpression, new_expression: !this.isValueExpression,
workflow_id: this.$store.getters.workflowId, workflow_id: this.$store.getters.workflowId,
session_id: this.$store.getters['ui/ndvSessionId'],
source: this.eventSource || 'ndv',
}); });
} }
}, },
@@ -734,6 +826,10 @@ export default mixins(
this.$emit('textInput', parameterData); this.$emit('textInput', parameterData);
}, },
valueChanged (value: string[] | string | number | boolean | Date | null) { valueChanged (value: string[] | string | number | boolean | Date | null) {
if (this.parameter.name === 'nodeCredentialType') {
this.activeCredentialType = value as string;
}
if (value instanceof Date) { if (value instanceof Date) {
value = value.toISOString(); value = value.toISOString();
} }
@@ -787,6 +883,10 @@ export default mixins(
this.nodeName = this.node.name; this.nodeName = this.node.name;
} }
if (this.node && this.node.parameters.authentication === 'predefinedCredentialType') {
this.activeCredentialType = this.node.parameters.nodeCredentialType as string;
}
if (this.parameter.type === 'color' && this.getArgument('showAlpha') === true && this.displayValue !== null && this.displayValue.toString().charAt(0) !== '#') { if (this.parameter.type === 'color' && this.getArgument('showAlpha') === true && this.displayValue !== null && this.displayValue.toString().charAt(0) !== '#') {
const newValue = this.rgbaToHex(this.displayValue as string); const newValue = this.rgbaToHex(this.displayValue as string);
if (newValue !== null) { if (newValue !== null) {
@@ -853,20 +953,6 @@ export default mixins(
display: inline-block; display: inline-block;
} }
.parameter-options {
width: 25px;
text-align: right;
float: right;
}
.parameter-issues {
width: 20px;
text-align: right;
float: right;
color: #ff8080;
font-size: var(--font-size-s);
}
::v-deep .color-input { ::v-deep .color-input {
display: flex; display: flex;

View File

@@ -19,6 +19,7 @@
@textInput="valueChanged" @textInput="valueChanged"
@valueChanged="valueChanged" @valueChanged="valueChanged"
inputSize="large" inputSize="large"
:eventSource="eventSource"
/> />
<div :class="$style.errors" v-if="showRequiredErrors"> <div :class="$style.errors" v-if="showRequiredErrors">
<n8n-text color="danger" size="small"> <n8n-text color="danger" size="small">
@@ -55,6 +56,9 @@ export default Vue.extend({
documentationUrl: { documentationUrl: {
type: String, type: String,
}, },
eventSource: {
type: String,
},
}, },
data() { data() {
return { return {

View File

@@ -1,6 +1,8 @@
<template> <template>
<div class="paramter-input-list-wrapper"> <div class="parameter-input-list-wrapper">
<div v-for="parameter in filteredParameters" :key="parameter.name" :class="{indent}"> <div v-for="(parameter, index) in filteredParameters" :key="parameter.name">
<slot v-if="indexToShowSlotAt === index" />
<div <div
v-if="multipleValues(parameter) === true && parameter.type !== 'fixedCollection'" v-if="multipleValues(parameter) === true && parameter.type !== 'fixedCollection'"
class="parameter-item" class="parameter-item"
@@ -18,8 +20,6 @@
v-else-if="parameter.type === 'notice'" v-else-if="parameter.type === 'notice'"
class="parameter-item" class="parameter-item"
:content="$locale.nodeText().inputLabelDisplayName(parameter, path)" :content="$locale.nodeText().inputLabelDisplayName(parameter, path)"
:truncate="parameter.typeOptions && parameter.typeOptions.truncate"
:truncate-at="parameter.typeOptions && parameter.typeOptions.truncateAt"
/> />
<div <div
@@ -101,6 +101,7 @@ import ParameterInputFull from '@/components/ParameterInputFull.vue';
import { get, set } from 'lodash'; import { get, set } from 'lodash';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import { WEBHOOK_NODE_TYPE } from '@/constants';
export default mixins( export default mixins(
genericHelpers, genericHelpers,
@@ -129,6 +130,11 @@ export default mixins(
node (): INodeUi { node (): INodeUi {
return this.$store.getters.activeNode; return this.$store.getters.activeNode;
}, },
indexToShowSlotAt (): number {
if (this.node.type === WEBHOOK_NODE_TYPE) return 1;
return 0;
},
}, },
methods: { methods: {
multipleValues (parameter: INodeProperties): boolean { multipleValues (parameter: INodeProperties): boolean {
@@ -164,11 +170,27 @@ export default mixins(
this.$emit('valueChanged', parameterData); this.$emit('valueChanged', parameterData);
}, },
mustHideDuringCustomApiCall (parameter: INodeProperties, nodeValues: INodeParameters): boolean {
if (parameter && parameter.displayOptions && parameter.displayOptions.hide) return true;
const MUST_REMAIN_VISIBLE = ['authentication', 'resource', 'operation', ...Object.keys(nodeValues)];
return !MUST_REMAIN_VISIBLE.includes(parameter.name);
},
displayNodeParameter (parameter: INodeProperties): boolean { displayNodeParameter (parameter: INodeProperties): boolean {
if (parameter.type === 'hidden') { if (parameter.type === 'hidden') {
return false; return false;
} }
if (
this.isCustomApiCallSelected(this.nodeValues) &&
this.mustHideDuringCustomApiCall(parameter, this.nodeValues)
) {
return false;
}
if (parameter.displayOptions === undefined) { if (parameter.displayOptions === undefined) {
// If it is not defined no need to do a proper check // If it is not defined no need to do a proper check
return true; return true;
@@ -260,7 +282,7 @@ export default mixins(
</script> </script>
<style lang="scss"> <style lang="scss">
.paramter-input-list-wrapper { .parameter-input-list-wrapper {
.delete-option { .delete-option {
display: none; display: none;
position: absolute; position: absolute;

View File

@@ -0,0 +1,30 @@
<template>
<div :class="$style['parameter-issues']" v-if="issues.length">
<n8n-tooltip placement="top" >
<div slot="content" v-html="`${$locale.baseText('parameterInput.issues')}:<br />&nbsp;&nbsp;- ` + issues.join('<br />&nbsp;&nbsp;- ')"></div>
<font-awesome-icon icon="exclamation-triangle" />
</n8n-tooltip>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'ParameterIssues',
props: [
'issues',
],
});
</script>
<style module lang="scss">
.parameter-issues {
width: 20px;
text-align: right;
float: right;
color: #ff8080;
font-size: var(--font-size-s);
padding-left: var(--spacing-4xs);
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<div :class="$style['parameter-options']">
<el-dropdown
trigger="click"
@command="(opt) => $emit('optionSelected', opt)"
size="mini"
>
<span class="el-dropdown-link">
<font-awesome-icon
icon="cogs"
class="reset-icon clickable"
:title="$locale.baseText('parameterInput.parameterOptions')"
/>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-if="parameter.noDataExpression !== true && !isValueExpression"
command="addExpression"
>
{{ $locale.baseText('parameterInput.addExpression') }}
</el-dropdown-item>
<el-dropdown-item
v-if="parameter.noDataExpression !== true && isValueExpression"
command="removeExpression"
>
{{ $locale.baseText('parameterInput.removeExpression') }}
</el-dropdown-item>
<el-dropdown-item
v-if="hasRemoteMethod"
command="refreshOptions"
>
{{ $locale.baseText('parameterInput.refreshList') }}
</el-dropdown-item>
<el-dropdown-item
command="resetValue"
:disabled="isDefault"
divided
>
{{ $locale.baseText('parameterInput.resetValue') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'ParameterOptions',
props: [
'displayOptionsComputed',
'optionSelected',
'parameter',
'isValueExpression',
'isDefault',
'hasRemoteMethod',
],
});
</script>
<style module lang="scss">
.parameter-options {
width: 25px;
text-align: right;
float: right;
}
</style>

View File

@@ -3,21 +3,7 @@
<BinaryDataDisplay :windowVisible="binaryDataDisplayVisible" :displayData="binaryDataDisplayData" @close="closeBinaryDataDisplay"/> <BinaryDataDisplay :windowVisible="binaryDataDisplayVisible" :displayData="binaryDataDisplayData" @close="closeBinaryDataDisplay"/>
<div :class="$style.header"> <div :class="$style.header">
<div :class="$style.titleSection"> <slot name="header"></slot>
<span :class="$style.title">{{ $locale.baseText('ndv.output') }}</span>
<n8n-info-tip type="tooltip" theme="info-light" tooltipPlacement="right" v-if="runMetadata">
<div>
<n8n-text :bold="true" size="small">{{ $locale.baseText('runData.startTime') + ':' }}</n8n-text> {{runMetadata.startTime}}<br/>
<n8n-text :bold="true" size="small">{{ $locale.baseText('runData.executionTime') + ':' }}</n8n-text> {{runMetadata.executionTime}} {{ $locale.baseText('runData.ms') }}
</div>
</n8n-info-tip>
<n8n-info-tip theme="warning" type="tooltip" tooltipPlacement="right" v-if="hasNodeRun && staleData">
<template>
<span v-html="$locale.baseText('ndv.output.staleDataWarning')"></span>
</template>
</n8n-info-tip>
</div>
<div v-if="!hasRunError" @click.stop :class="$style.displayModes"> <div v-if="!hasRunError" @click.stop :class="$style.displayModes">
<n8n-radio-buttons <n8n-radio-buttons
@@ -29,19 +15,27 @@
</div> </div>
<div :class="$style.runSelector" v-if="maxRunIndex > 0" > <div :class="$style.runSelector" v-if="maxRunIndex > 0" >
<n8n-select size="small" v-model="runIndex" @click.stop> <n8n-select size="small" :value="runIndex" @input="onRunIndexChange" @click.stop>
<template slot="prepend">{{ $locale.baseText('ndv.output.run') }}</template> <template slot="prepend">{{ $locale.baseText('ndv.output.run') }}</template>
<n8n-option v-for="option in (maxRunIndex + 1)" :label="getRunLabel(option)" :value="option - 1" :key="option"></n8n-option> <n8n-option v-for="option in (maxRunIndex + 1)" :label="getRunLabel(option)" :value="option - 1" :key="option"></n8n-option>
</n8n-select> </n8n-select>
<n8n-tooltip placement="right" v-if="canLinkRuns" :content="$locale.baseText(linkedRuns ? 'runData.unlinking.hint': 'runData.linking.hint')">
<n8n-icon-button v-if="linkedRuns" icon="unlink" type="text" size="small" @click="unlinkRun" />
<n8n-icon-button v-else icon="link" type="text" size="small" @click="linkRun" />
</n8n-tooltip>
<slot name="run-info"></slot>
</div> </div>
<div v-if="maxOutputIndex > 0" :class="{[$style.tabs]: displayMode === 'table'}"> <div v-if="maxOutputIndex > 0" :class="{[$style.tabs]: displayMode === 'table'}">
<n8n-tabs v-model="outputIndex" :options="branches" /> <n8n-tabs :value="currentOutputIndex" @input="onBranchChange" :options="branches" />
</div> </div>
<div v-else-if="hasNodeRun && dataCount > 0 && maxRunIndex === 0" :class="$style.itemsCount"> <div v-else-if="hasNodeRun && dataCount > 0 && maxRunIndex === 0" :class="$style.itemsCount">
<n8n-text> <n8n-text>
{{ dataCount }} {{ $locale.baseText(dataCount === 1 ? 'ndv.output.item' : 'ndv.output.items') }} {{ dataCount }} {{ $locale.baseText('ndv.output.items', {adjustToNumber: dataCount}) }}
</n8n-text> </n8n-text>
</div> </div>
@@ -65,46 +59,49 @@
</el-dropdown> </el-dropdown>
</div> </div>
<div v-if="!hasNodeRun" :class="$style.center"> <div v-if="isExecuting" :class="$style.center">
<div v-if="workflowRunning"> <div :class="$style.spinner"><n8n-spinner type="ring" /></div>
<div :class="$style.spinner"><n8n-spinner /></div> <n8n-text>{{ executingMessage }}</n8n-text>
<n8n-text>{{ $locale.baseText('ndv.output.executing') }}</n8n-text>
</div>
<n8n-text v-else-if="isPollingTypeNode">{{ $locale.baseText('ndv.output.pollEventNodeHint') }}</n8n-text>
<n8n-text v-else-if="isTriggerNode && !isScheduleTrigger">{{ $locale.baseText('ndv.output.triggerEventNodeHint') }}</n8n-text>
<n8n-text v-else>{{ $locale.baseText('ndv.output.runNodeHint') }}</n8n-text>
</div> </div>
<div v-else-if="hasNodeRun && hasRunError" :class="$style.dataDisplay"> <div v-else-if="!hasNodeRun" :class="$style.center">
<slot name="node-not-run"></slot>
</div>
<div v-else-if="hasNodeRun && hasRunError" :class="$style.errorDisplay">
<NodeErrorView :error="workflowRunData[node.name][runIndex].error" /> <NodeErrorView :error="workflowRunData[node.name][runIndex].error" />
</div> </div>
<div v-else-if="hasNodeRun && jsonData && jsonData.length === 0 && branches.length > 1" :class="$style.center"> <div v-else-if="hasNodeRun && jsonData && jsonData.length === 0 && branches.length > 1" :class="$style.center">
<n8n-text> <n8n-text>
{{ $locale.baseText('ndv.output.noOutputDataInBranch') }} {{ noDataInBranchMessage }}
</n8n-text> </n8n-text>
</div> </div>
<div v-else-if="hasNodeRun && jsonData && jsonData.length === 0" :class="$style.center"> <div v-else-if="hasNodeRun && jsonData && jsonData.length === 0" :class="$style.center">
<n8n-text :bold="true" color="text-dark">{{ $locale.baseText('ndv.output.noOutputData.title') }}</n8n-text> <slot name="no-output-data"></slot>
<n8n-text>
{{ $locale.baseText('ndv.output.noOutputData.message') }}
<a @click="openSettings">{{ $locale.baseText('ndv.output.noOutputData.message.settings') }}</a>
{{ $locale.baseText('ndv.output.noOutputData.message.settingsOption') }}
</n8n-text>
</div> </div>
<div v-else-if="hasNodeRun && !showData" :class="$style.center"> <div v-else-if="hasNodeRun && !showData" :class="$style.center">
<n8n-text :bold="true" color="text-dark">{{ $locale.baseText('ndv.output.tooMuchData.title') }}</n8n-text> <n8n-text :bold="true" color="text-dark" size="large">{{ tooMuchDataTitle }}</n8n-text>
<n8n-text align="center" tag="div"><span v-html="$locale.baseText('ndv.output.tooMuchData.message', { interpolate: {size: dataSizeInMB }})"></span></n8n-text> <n8n-text align="center" tag="div"><span v-html="$locale.baseText('ndv.output.tooMuchData.message', { interpolate: {size: dataSizeInMB }})"></span></n8n-text>
<n8n-button <n8n-button
type="outline" type="outline"
:label="$locale.baseText('ndv.output.tooMuchData.showDataAnyway')" :label="$locale.baseText('ndv.output.tooMuchData.showDataAnyway')"
@click="showData = true" @click="showTooMuchData"
/> />
</div> </div>
<div v-else-if="hasNodeRun && displayMode === 'table' && tableData && tableData.columns && tableData.columns.length === 0 && binaryData.length > 0" :class="$style.center">
<n8n-text>
{{ $locale.baseText('runData.switchToBinary.info') }}
<a @click="switchToBinary">
{{ $locale.baseText('runData.switchToBinary.binary') }}
</a>
</n8n-text>
</div>
<div v-else-if="hasNodeRun && displayMode === 'table' && tableData && tableData.columns && tableData.columns.length === 0" :class="$style.dataDisplay"> <div v-else-if="hasNodeRun && displayMode === 'table' && tableData && tableData.columns && tableData.columns.length === 0" :class="$style.dataDisplay">
<table :class="$style.table"> <table :class="$style.table">
<tr> <tr>
@@ -112,7 +109,7 @@
</tr> </tr>
<tr v-for="(row, index1) in tableData.data" :key="index1"> <tr v-for="(row, index1) in tableData.data" :key="index1">
<td> <td>
<n8n-text>{{ $locale.baseText('ndv.output.emptyOutput') }}</n8n-text> <n8n-text>{{ $locale.baseText('runData.emptyItemHint') }}</n8n-text>
</td> </td>
</tr> </tr>
</table> </table>
@@ -198,6 +195,7 @@
:pager-count="5" :pager-count="5"
:page-size="pageSize" :page-size="pageSize"
layout="prev, pager, next" layout="prev, pager, next"
@current-change="onCurrentPageChange"
:total="dataCount"> :total="dataCount">
</el-pagination> </el-pagination>
@@ -241,6 +239,7 @@ import {
IBinaryDisplayData, IBinaryDisplayData,
IExecutionResponse, IExecutionResponse,
INodeUi, INodeUi,
IRunDataDisplayMode,
ITab, ITab,
ITableData, ITableData,
} from '@/Interface'; } from '@/Interface';
@@ -260,7 +259,6 @@ import { genericHelpers } from '@/components/mixins/genericHelpers';
import { nodeHelpers } from '@/components/mixins/nodeHelpers'; import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import Vue from 'vue/types/umd';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
@@ -281,17 +279,49 @@ export default mixins(
VueJsonPretty, VueJsonPretty,
WarningTooltip, WarningTooltip,
}, },
props: {
nodeUi: {
}, // INodeUi | null
runIndex: {
type: Number,
},
linkedRuns: {
type: Boolean,
},
canLinkRuns: {
type: Boolean,
},
tooMuchDataTitle: {
type: String,
},
noDataInBranchMessage: {
type: String,
},
isExecuting: {
type: Boolean,
},
executingMessage: {
type: String,
},
sessionId: {
type: String,
},
paneType: {
type: String,
},
overrideOutputs: {
type: Array,
},
},
data () { data () {
return { return {
binaryDataPreviewActive: false, binaryDataPreviewActive: false,
dataSize: 0, dataSize: 0,
deselectedPlaceholder, deselectedPlaceholder,
displayMode: 'table',
state: { state: {
value: '' as object | number | string, value: '' as object | number | string,
path: deselectedPlaceholder, path: deselectedPlaceholder,
}, },
runIndex: 0,
showData: false, showData: false,
outputIndex: 0, outputIndex: 0,
binaryDataDisplayVisible: false, binaryDataDisplayVisible: false,
@@ -308,21 +338,21 @@ export default mixins(
this.init(); this.init();
}, },
computed: { computed: {
activeNode(): INodeUi {
return this.$store.getters.activeNode;
},
displayMode(): IRunDataDisplayMode {
return this.$store.getters['ui/getPanelDisplayMode'](this.paneType);
},
node(): INodeUi | null {
return (this.nodeUi as INodeUi | null) || null;
},
nodeType (): INodeTypeDescription | null { nodeType (): INodeTypeDescription | null {
if (this.node) { if (this.node) {
return this.$store.getters.nodeType(this.node.type, this.node.typeVersion); return this.$store.getters.nodeType(this.node.type, this.node.typeVersion);
} }
return null; return null;
}, },
isTriggerNode (): boolean {
return !!(this.nodeType && this.nodeType.group.includes('trigger'));
},
isPollingTypeNode (): boolean {
return !!(this.nodeType && this.nodeType.polling);
},
isScheduleTrigger (): boolean {
return !!(this.nodeType && this.nodeType.group.includes('schedule'));
},
buttons(): Array<{label: string, value: string}> { buttons(): Array<{label: string, value: string}> {
const defaults = [ const defaults = [
{ label: this.$locale.baseText('runData.table'), value: 'table'}, { label: this.$locale.baseText('runData.table'), value: 'table'},
@@ -337,14 +367,11 @@ export default mixins(
return defaults; return defaults;
}, },
hasNodeRun(): boolean { hasNodeRun(): boolean {
return Boolean(this.node && this.workflowRunData && this.workflowRunData.hasOwnProperty(this.node.name)); return Boolean(!this.isExecuting && this.node && this.workflowRunData && this.workflowRunData.hasOwnProperty(this.node.name));
}, },
hasRunError(): boolean { hasRunError(): boolean {
return Boolean(this.node && this.workflowRunData && this.workflowRunData[this.node.name] && this.workflowRunData[this.node.name][this.runIndex] && this.workflowRunData[this.node.name][this.runIndex].error); return Boolean(this.node && this.workflowRunData && this.workflowRunData[this.node.name] && this.workflowRunData[this.node.name][this.runIndex] && this.workflowRunData[this.node.name][this.runIndex].error);
}, },
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
workflowExecution (): IExecutionResponse | null { workflowExecution (): IExecutionResponse | null {
return this.$store.getters.getWorkflowExecution; return this.$store.getters.getWorkflowExecution;
}, },
@@ -358,48 +385,8 @@ export default mixins(
} }
return null; return null;
}, },
node (): INodeUi | null {
return this.$store.getters.activeNode;
},
runTaskData (): ITaskData | null {
if (!this.node || this.workflowExecution === null) {
return null;
}
const runData = this.workflowRunData;
if (runData === null || !runData.hasOwnProperty(this.node.name)) {
return null;
}
if (runData[this.node.name].length <= this.runIndex) {
return null;
}
return runData[this.node.name][this.runIndex];
},
runMetadata (): {executionTime: number, startTime: string} | null {
if (!this.runTaskData) {
return null;
}
return {
executionTime: this.runTaskData.executionTime,
startTime: new Date(this.runTaskData.startTime).toLocaleString(),
};
},
staleData(): boolean {
if (!this.node) {
return false;
}
const updatedAt = this.$store.getters.getParametersLastUpdated(this.node.name);
if (!updatedAt || !this.runTaskData) {
return false;
}
const runAt = this.runTaskData.startTime;
return updatedAt > runAt;
},
dataCount (): number { dataCount (): number {
return this.getDataCount(this.runIndex, this.outputIndex); return this.getDataCount(this.runIndex, this.currentOutputIndex);
}, },
dataSizeInMB(): string { dataSizeInMB(): string {
return (this.dataSize / 1024 / 1000).toLocaleString(); return (this.dataSize / 1024 / 1000).toLocaleString();
@@ -419,13 +406,14 @@ export default mixins(
return 0; return 0;
} }
if (runData[this.node.name][this.runIndex].data === undefined || if (runData[this.node.name][this.runIndex]) {
runData[this.node.name][this.runIndex].data!.main === undefined const taskData = runData[this.node.name][this.runIndex].data;
) { if (taskData && taskData.main) {
return 0; return taskData.main.length - 1;
}
} }
return runData[this.node.name][this.runIndex].data!.main.length - 1; return 0;
}, },
maxRunIndex (): number { maxRunIndex (): number {
if (this.node === null) { if (this.node === null) {
@@ -445,7 +433,7 @@ export default mixins(
return 0; return 0;
}, },
inputData (): INodeExecutionData[] { inputData (): INodeExecutionData[] {
let inputData = this.getNodeInputData(this.node, this.runIndex, this.outputIndex); let inputData = this.getNodeInputData(this.node, this.runIndex, this.currentOutputIndex);
if (inputData.length === 0 || !Array.isArray(inputData)) { if (inputData.length === 0 || !Array.isArray(inputData)) {
return []; return [];
} }
@@ -462,11 +450,18 @@ export default mixins(
return this.convertToTable(this.inputData); return this.convertToTable(this.inputData);
}, },
binaryData (): IBinaryKeyData[] { binaryData (): IBinaryKeyData[] {
if (this.node === null) { if (!this.node) {
return []; return [];
} }
return this.getBinaryData(this.workflowRunData, this.node.name, this.runIndex, this.outputIndex); return this.getBinaryData(this.workflowRunData, this.node.name, this.runIndex, this.currentOutputIndex);
},
currentOutputIndex(): number {
if (this.overrideOutputs && this.overrideOutputs.length && !this.overrideOutputs.includes(this.outputIndex)) {
return this.overrideOutputs[0] as number;
}
return this.outputIndex;
}, },
branches (): ITab[] { branches (): ITab[] {
function capitalize(name: string) { function capitalize(name: string) {
@@ -474,8 +469,11 @@ export default mixins(
} }
const branches: ITab[] = []; const branches: ITab[] = [];
for (let i = 0; i <= this.maxOutputIndex; i++) { for (let i = 0; i <= this.maxOutputIndex; i++) {
if (this.overrideOutputs && !this.overrideOutputs.includes(i)) {
continue;
}
const itemsCount = this.getDataCount(this.runIndex, i); const itemsCount = this.getDataCount(this.runIndex, i);
const items = this.$locale.baseText(itemsCount === 1 ? 'ndv.output.item': 'ndv.output.items'); const items = this.$locale.baseText('ndv.output.items', {adjustToNumber: itemsCount});
let outputName = this.getOutputName(i); let outputName = this.getOutputName(i);
if (`${outputName}` === `${i}`) { if (`${outputName}` === `${i}`) {
outputName = `${this.$locale.baseText('ndv.output')} ${outputName}`; outputName = `${this.$locale.baseText('ndv.output')} ${outputName}`;
@@ -492,16 +490,67 @@ export default mixins(
}, },
}, },
methods: { methods: {
switchToBinary() {
this.onDisplayModeChange('binary');
},
onBranchChange(value: number) {
this.outputIndex = value;
this.$telemetry.track('User changed ndv branch', {
session_id: this.sessionId,
branch_index: value,
node_type: this.activeNode.type,
node_type_input_selection: this.nodeType? this.nodeType.name: '',
pane: this.paneType,
});
},
showTooMuchData() {
this.showData = true;
this.$telemetry.track('User clicked ndv button', {
node_type: this.activeNode.type,
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
pane: this.paneType,
type: 'showTooMuchData',
});
},
linkRun() {
this.$emit('linkRun');
},
unlinkRun() {
this.$emit('unlinkRun');
},
onCurrentPageChange() {
this.$telemetry.track('User changed ndv page', {
node_type: this.activeNode.type,
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
pane: this.paneType,
page_selected: this.currentPage,
page_size: this.pageSize,
items_total: this.dataCount,
});
},
onPageSizeChange(pageSize: number) { onPageSizeChange(pageSize: number) {
this.pageSize = pageSize; this.pageSize = pageSize;
const maxPage = Math.ceil(this.dataCount / this.pageSize); const maxPage = Math.ceil(this.dataCount / this.pageSize);
if (maxPage < this.currentPage) { if (maxPage < this.currentPage) {
this.currentPage = maxPage; this.currentPage = maxPage;
} }
this.$telemetry.track('User changed ndv page size', {
node_type: this.activeNode.type,
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
pane: this.paneType,
page_selected: this.currentPage,
page_size: this.pageSize,
items_total: this.dataCount,
});
}, },
onDisplayModeChange(displayMode: string) { onDisplayModeChange(displayMode: IRunDataDisplayMode) {
const previous = this.displayMode; const previous = this.displayMode;
this.displayMode = displayMode; this.$store.commit('ui/setPanelDisplayMode', {pane: this.paneType, mode: displayMode});
const dataContainer = this.$refs.dataContainer; const dataContainer = this.$refs.dataContainer;
if (dataContainer) { if (dataContainer) {
@@ -514,9 +563,15 @@ export default mixins(
this.closeBinaryDataDisplay(); this.closeBinaryDataDisplay();
this.$externalHooks().run('runData.displayModeChanged', { newValue: displayMode, oldValue: previous }); this.$externalHooks().run('runData.displayModeChanged', { newValue: displayMode, oldValue: previous });
if(this.node) { if(this.activeNode) {
const nodeType = this.node ? this.node.type : ''; this.$telemetry.track('User changed ndv item view', {
this.$telemetry.track('User changed node output view mode', { old_mode: previous, new_mode: displayMode, node_type: nodeType, workflow_id: this.$store.getters.workflowId }); previous_view: previous,
new_view: displayMode,
node_type: this.activeNode.type,
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
pane: this.paneType,
});
} }
}, },
getRunLabel(option: number) { getRunLabel(option: number) {
@@ -524,7 +579,7 @@ export default mixins(
for (let i = 0; i <= this.maxOutputIndex; i++) { for (let i = 0; i <= this.maxOutputIndex; i++) {
itemsCount += this.getDataCount(option - 1, i); itemsCount += this.getDataCount(option - 1, i);
} }
const items = this.$locale.baseText(itemsCount === 1 ? 'ndv.output.item': 'ndv.output.items'); const items = this.$locale.baseText('ndv.output.items', {adjustToNumber: itemsCount});
const itemsLabel = itemsCount > 0 ? ` (${itemsCount} ${items})` : ''; const itemsLabel = itemsCount > 0 ? ` (${itemsCount} ${items})` : '';
return option + this.$locale.baseText('ndv.output.of') + (this.maxRunIndex+1) + itemsLabel; return option + this.$locale.baseText('ndv.output.of') + (this.maxRunIndex+1) + itemsLabel;
}, },
@@ -557,18 +612,16 @@ export default mixins(
return inputData.length; return inputData.length;
}, },
openSettings() {
this.$emit('openSettings');
},
init() { init() {
// Reset the selected output index every time another node gets selected // Reset the selected output index every time another node gets selected
this.outputIndex = 0; this.outputIndex = 0;
this.refreshDataSize(); this.refreshDataSize();
if (this.displayMode === 'binary') { this.closeBinaryDataDisplay();
this.closeBinaryDataDisplay(); if (this.binaryData.length > 0) {
if (this.binaryData.length === 0) { this.$store.commit('ui/setPanelDisplayMode', {pane: this.paneType, mode: 'binary'});
this.displayMode = 'table'; }
} else if (this.displayMode === 'binary') {
this.$store.commit('ui/setPanelDisplayMode', {pane: this.paneType, mode: 'table'});
} }
}, },
closeBinaryDataDisplay () { closeBinaryDataDisplay () {
@@ -671,7 +724,7 @@ export default mixins(
this.binaryDataDisplayData = { this.binaryDataDisplayData = {
node: this.node!.name, node: this.node!.name,
runIndex: this.runIndex, runIndex: this.runIndex,
outputIndex: this.outputIndex, outputIndex: this.currentOutputIndex,
index, index,
key, key,
}; };
@@ -760,7 +813,7 @@ export default mixins(
this.showData = false; this.showData = false;
// Check how much data there is to display // Check how much data there is to display
const inputData = this.getNodeInputData(this.node, this.runIndex, this.outputIndex); const inputData = this.getNodeInputData(this.node, this.runIndex, this.currentOutputIndex);
const offset = this.pageSize * (this.currentPage - 1); const offset = this.pageSize * (this.currentPage - 1);
const jsonItems = inputData.slice(offset, offset + this.pageSize).map(item => item.json); const jsonItems = inputData.slice(offset, offset + this.pageSize).map(item => item.json);
@@ -772,6 +825,9 @@ export default mixins(
this.showData = true; this.showData = true;
} }
}, },
onRunIndexChange(run: number) {
this.$emit('runChange', run);
},
}, },
watch: { watch: {
node() { node() {
@@ -780,8 +836,13 @@ export default mixins(
jsonData () { jsonData () {
this.refreshDataSize(); this.refreshDataSize();
}, },
maxRunIndex () { binaryData (newData: IBinaryKeyData[], prevData: IBinaryKeyData[]) {
this.runIndex = Math.min(this.runIndex, this.maxRunIndex); if (newData.length && !prevData.length && this.displayMode !== 'binary') {
this.switchToBinary();
}
else if (!newData.length && this.displayMode === 'binary') {
this.onDisplayModeChange('table');
}
}, },
}, },
}); });
@@ -798,7 +859,7 @@ export default mixins(
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: var(--spacing-s); padding: var(--spacing-s) var(--spacing-s) var(--spacing-xl) var(--spacing-s);
text-align: center; text-align: center;
> * { > * {
@@ -807,39 +868,11 @@ export default mixins(
} }
} }
.spinner {
* {
color: var(--color-primary);
min-height: 40px;
min-width: 40px;
}
display: flex;
justify-content: center;
margin-bottom: var(--spacing-s);
}
.title {
text-transform: uppercase;
color: var(--color-text-light);
letter-spacing: 3px;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-s);
}
.titleSection {
display: flex;
> * {
margin-right: var(--spacing-2xs);
}
}
.container { .container {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: var(--color-background-light); background-color: var(--color-background-base);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -874,6 +907,11 @@ export default mixins(
padding-bottom: var(--spacing-3xl); padding-bottom: var(--spacing-3xl);
} }
.errorDisplay {
composes: dataDisplay;
padding-right: var(--spacing-s);
}
.jsonDisplay { .jsonDisplay {
composes: dataDisplay; composes: dataDisplay;
background-color: var(--color-background-base); background-color: var(--color-background-base);
@@ -928,6 +966,11 @@ export default mixins(
max-width: 200px; max-width: 200px;
margin-left: var(--spacing-s); margin-left: var(--spacing-s);
margin-bottom: var(--spacing-s); margin-bottom: var(--spacing-s);
display: flex;
> * {
margin-right: var(--spacing-4xs);
}
} }
.copyButton { .copyButton {
@@ -1015,8 +1058,21 @@ export default mixins(
} }
.displayModes { .displayModes {
position: absolute; display: flex;
right: var(--spacing-s); justify-content: flex-end;
flex-grow: 1;
}
.spinner {
* {
color: var(--color-primary);
min-height: 40px;
min-width: 40px;
}
display: flex;
justify-content: center;
margin-bottom: var(--spacing-s);
} }
</style> </style>

View File

@@ -0,0 +1,40 @@
<template>
<n8n-info-tip type="tooltip" theme="info-light" tooltipPlacement="right" v-if="runMetadata">
<div>
<n8n-text :bold="true" size="small">{{
$locale.baseText('runData.startTime') + ':'
}}</n8n-text>
{{ runMetadata.startTime }}<br />
<n8n-text :bold="true" size="small">{{
$locale.baseText('runData.executionTime') + ':'
}}</n8n-text>
{{ runMetadata.executionTime }} {{ $locale.baseText('runData.ms') }}
</div>
</n8n-info-tip>
</template>
<script lang="ts">
import { ITaskData } from 'n8n-workflow';
import Vue from 'vue';
export default Vue.extend({
props: {
taskData: {}, // ITaskData
},
computed: {
runTaskData(): ITaskData {
return this.taskData as ITaskData;
},
runMetadata(): { executionTime: number; startTime: string } | null {
if (!this.runTaskData) {
return null;
}
return {
executionTime: this.runTaskData.executionTime,
startTime: new Date(this.runTaskData.startTime).toLocaleString(),
};
},
},
});
</script>

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