diff --git a/.editorconfig b/.editorconfig index 5ab90f90d7..b6b59f3ccf 100644 --- a/.editorconfig +++ b/.editorconfig @@ -14,3 +14,7 @@ indent_size = 2 [*.ts] quote_type = single + +[*.yml] +indent_style = space +indent_size = 2 diff --git a/.github/workflows/docker-images-rpi.yml b/.github/workflows/docker-images-rpi.yml deleted file mode 100644 index 1eeb66ecdb..0000000000 --- a/.github/workflows/docker-images-rpi.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Docker Image CI - Rpi - -on: - push: - tags: - - n8n@* - workflow_dispatch: - inputs: - version: - description: 'n8n version to build docker image for.' - required: true - default: '0.112.0' - -jobs: - armv7_job: - runs-on: ubuntu-18.04 - name: Build on ARMv7 (Rpi) - steps: - - uses: actions/checkout@v1 - - name: Get the version - id: vars - run: echo ::set-output name=tag::$(echo ${GITHUB_REF:14}) - - - name: Log in to Docker registry - run: docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} - - - name: Set up Docker Buildx - uses: crazy-max/ghaction-docker-buildx@v3 - with: - buildx-version: latest - qemu-version: latest - - name: Run Buildx (push image) - if: success() - run: | - docker buildx build \ - --platform linux/arm/v7 \ - --build-arg N8N_VERSION=${{github.event.inputs.version || steps.vars.outputs.tag}} \ - -t ${{ secrets.DOCKER_USERNAME }}/n8n:${{github.event.inputs.version || steps.vars.outputs.tag}}-rpi \ - -t ${{ secrets.DOCKER_USERNAME }}/n8n:latest-rpi \ - --output type=image,push=true docker/images/n8n-rpi diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index 1a9b81d09f..8b9e32e0bf 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -6,33 +6,41 @@ on: - n8n@* jobs: - build: - runs-on: ubuntu-latest - steps: - uses: actions/checkout@v1 - name: Get the version id: vars run: echo ::set-output name=tag::$(echo ${GITHUB_REF:14}) - - name: Log in to Docker registry - run: docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} - - - name: Build the Docker image of version - run: docker build --build-arg N8N_VERSION=${{steps.vars.outputs.tag}} -t n8nio/n8n:${{steps.vars.outputs.tag}} docker/images/n8n - - name: Push Docker image of version - run: docker push n8nio/n8n:${{steps.vars.outputs.tag}} - - name: Tag Docker image with latest - run: docker tag n8nio/n8n:${{steps.vars.outputs.tag}} n8nio/n8n:latest - - name: Push docker images of latest - run: docker push n8nio/n8n:latest - - - name: Build the Docker image of version (Debian) - run: docker build --build-arg N8N_VERSION=${{steps.vars.outputs.tag}} -t n8nio/n8n:${{steps.vars.outputs.tag}}-debian docker/images/n8n-debian - - name: Push Docker image of version (Debian) - run: docker push n8nio/n8n:${{steps.vars.outputs.tag}}-debian - - name: Tag Docker image with latest (Debian) - run: docker tag n8nio/n8n:${{steps.vars.outputs.tag}}-debian n8nio/n8n:latest-debian - - name: Push docker images of latest (Debian) - run: docker push n8nio/n8n:latest-debian + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Build + uses: docker/build-push-action@v2 + with: + context: ./docker/images/n8n + build-args: | + N8N_VERSION=${{steps.vars.outputs.tag}} + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: true + tags: | + ${{ secrets.DOCKER_USERNAME }}/n8n:${{ steps.vars.outputs.tag }} + ${{ secrets.DOCKER_USERNAME }}/n8n:latest + - name: Build (debian) + uses: docker/build-push-action@v2 + with: + context: ./docker/images/n8n-debian + build-args: | + N8N_VERSION=${{ steps.vars.outputs.tag }} + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: true + tags: | + ${{ secrets.DOCKER_USERNAME }}/n8n:${{ steps.vars.outputs.tag }}-debian + ${{ secrets.DOCKER_USERNAME }}/n8n:latest-debian diff --git a/docker/images/n8n-custom/Dockerfile b/docker/images/n8n-custom/Dockerfile index 05d08a7db5..9136e15b17 100644 --- a/docker/images/n8n-custom/Dockerfile +++ b/docker/images/n8n-custom/Dockerfile @@ -29,7 +29,7 @@ FROM node:14.15-alpine USER root -RUN apk add --update graphicsmagick tzdata tini su-exec +RUN apk add --update graphicsmagick tzdata tini su-exec git WORKDIR /data diff --git a/docker/images/n8n-debian/Dockerfile b/docker/images/n8n-debian/Dockerfile index bfe10565aa..95c79828e4 100644 --- a/docker/images/n8n-debian/Dockerfile +++ b/docker/images/n8n-debian/Dockerfile @@ -6,7 +6,7 @@ RUN if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" RUN \ apt-get update && \ - apt-get -y install graphicsmagick gosu + apt-get -y install graphicsmagick gosu git # Set a custom user to not have n8n run as root USER root diff --git a/docker/images/n8n-rpi/Dockerfile b/docker/images/n8n-rpi/Dockerfile deleted file mode 100644 index 329b7e7dfe..0000000000 --- a/docker/images/n8n-rpi/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM arm32v7/node:14.15 - -ARG N8N_VERSION - -RUN if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" ; exit 1; fi - -RUN \ - apt-get update && \ - apt-get -y install graphicsmagick gosu - -RUN npm_config_user=root npm install -g full-icu n8n@${N8N_VERSION} - -ENV NODE_ICU_DATA /usr/local/lib/node_modules/full-icu -ENV NODE_ENV production - -WORKDIR /data - -USER root - -CMD chown -R node:node /home/node/.n8n \ -&& gosu node n8n diff --git a/docker/images/n8n-rpi/README.md b/docker/images/n8n-rpi/README.md deleted file mode 100644 index cc15b0c9d8..0000000000 --- a/docker/images/n8n-rpi/README.md +++ /dev/null @@ -1,22 +0,0 @@ -## n8n - Raspberry PI Docker Image - -Dockerfile to build n8n for Raspberry PI. - -For information about how to run n8n with Docker check the generic -[Docker-Readme](https://github.com/n8n-io/n8n/tree/master/docker/images/n8n/README.md) - - -``` -docker build --build-arg N8N_VERSION= -t n8nio/n8n: . - -# For example: -docker build --build-arg N8N_VERSION=0.43.0 -t n8nio/n8n:0.43.0-rpi . -``` - -``` -docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -v ~/.n8n:/home/node/.n8n \ - n8nio/n8n:0.70.0-rpi -``` diff --git a/docker/images/n8n/Dockerfile b/docker/images/n8n/Dockerfile index 810b224495..9abd32faed 100644 --- a/docker/images/n8n/Dockerfile +++ b/docker/images/n8n/Dockerfile @@ -14,14 +14,16 @@ USER root # it needs to build it correctly. RUN apk --update add --virtual build-dependencies python build-base ca-certificates && \ npm_config_user=root npm install -g full-icu n8n@${N8N_VERSION} && \ - apk del build-dependencies + apk del build-dependencies \ + && rm -rf /root /tmp/* /var/cache/apk/* && mkdir /root; # Install fonts RUN apk --no-cache add --virtual fonts msttcorefonts-installer fontconfig && \ update-ms-fonts && \ fc-cache -f && \ apk del fonts && \ - find /usr/share/fonts/truetype/msttcorefonts/ -type l -exec unlink {} \; + find /usr/share/fonts/truetype/msttcorefonts/ -type l -exec unlink {} \; \ + && rm -rf /root /tmp/* /var/cache/apk/* && mkdir /root ENV NODE_ICU_DATA /usr/local/lib/node_modules/full-icu diff --git a/docker/images/n8n/README.md b/docker/images/n8n/README.md index cb484addb0..7e6b5812b0 100644 --- a/docker/images/n8n/README.md +++ b/docker/images/n8n/README.md @@ -227,10 +227,10 @@ docker run -it --rm \ ## Build Docker-Image ``` -docker build --build-arg N8N_VERSION= -t n8nio/n8n: . +docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg N8N_VERSION= -t n8nio/n8n: . # For example: -docker build --build-arg N8N_VERSION=0.18.1 -t n8nio/n8n:0.18.1 . +docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg N8N_VERSION=0.114.0 -t n8nio/n8n:0.114.0 . ``` diff --git a/packages/cli/package.json b/packages/cli/package.json index bb8802bd53..4c095c3960 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.122.3", + "version": "0.126.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -82,7 +82,7 @@ "dependencies": { "@oclif/command": "^1.5.18", "@oclif/errors": "^1.2.2", - "@types/jsonwebtoken": "^8.3.4", + "@types/jsonwebtoken": "^8.5.2", "basic-auth": "^2.0.1", "bcryptjs": "^2.4.3", "body-parser": "^1.18.3", @@ -106,10 +106,10 @@ "localtunnel": "^2.0.0", "lodash.get": "^4.4.2", "mysql2": "~2.2.0", - "n8n-core": "~0.73.0", - "n8n-editor-ui": "~0.92.2", - "n8n-nodes-base": "~0.119.2", - "n8n-workflow": "~0.60.0", + "n8n-core": "~0.75.0", + "n8n-editor-ui": "~0.96.0", + "n8n-nodes-base": "~0.123.0", + "n8n-workflow": "~0.62.0", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", "pg": "^8.3.0", diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index c323e8373e..66d4ec02e2 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -313,7 +313,6 @@ export class ActiveWorkflowRunner { try { await Db.collections.Webhook?.insert(webhook); - const webhookExists = await workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, activation, false); if (webhookExists !== true) { // If webhook does not exist yet create it @@ -341,7 +340,7 @@ export class ActiveWorkflowRunner { errorMessage = error.message; } - throw new Error(errorMessage); + throw error; } } // Save static data! diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts index 2b1263e6d0..777fa0bb00 100644 --- a/packages/cli/src/LoadNodesAndCredentials.ts +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -3,6 +3,7 @@ import { UserSettings, } from 'n8n-core'; import { + CodexData, ICredentialType, ILogger, INodeType, @@ -25,6 +26,8 @@ import { import * as glob from 'glob-promise'; import * as path from 'path'; +const CUSTOM_NODES_CATEGORY = 'Custom Nodes'; + class LoadNodesAndCredentialsClass { nodeTypes: INodeTypeData = {}; @@ -133,7 +136,6 @@ class LoadNodesAndCredentialsClass { * @param {string} credentialName The name of the credentials * @param {string} filePath The file to read credentials from * @returns {Promise} - * @memberof N8nPackagesInformationClass */ async loadCredentialsFromFile(credentialName: string, filePath: string): Promise { const tempModule = require(filePath); @@ -160,7 +162,6 @@ class LoadNodesAndCredentialsClass { * @param {string} nodeName Tha name of the node * @param {string} filePath The file to read node from * @returns {Promise} - * @memberof N8nPackagesInformationClass */ async loadNodeFromFile(packageName: string, nodeName: string, filePath: string): Promise { let tempNode: INodeType; @@ -169,6 +170,7 @@ class LoadNodesAndCredentialsClass { const tempModule = require(filePath); try { tempNode = new tempModule[nodeName]() as INodeType; + this.addCodex({ node: tempNode, filePath, isCustom: packageName === 'CUSTOM' }); } catch (error) { console.error(`Error loading node "${nodeName}" from: "${filePath}"`); throw error; @@ -202,6 +204,57 @@ class LoadNodesAndCredentialsClass { }; } + /** + * Retrieves `categories`, `subcategories` and alias (if defined) + * from the codex data for the node at the given file path. + * + * @param {string} filePath The file path to a `*.node.js` file + * @returns {CodexData} + */ + getCodex(filePath: string): CodexData { + const { categories, subcategories, alias } = require(`${filePath}on`); // .js to .json + return { + ...(categories && { categories }), + ...(subcategories && { subcategories }), + ...(alias && { alias }), + }; + } + + /** + * Adds a node codex `categories` and `subcategories` (if defined) + * to a node description `codex` property. + * + * @param {object} obj + * @param obj.node Node to add categories to + * @param obj.filePath Path to the built node + * @param obj.isCustom Whether the node is custom + * @returns {void} + */ + addCodex({ node, filePath, isCustom }: { + node: INodeType; + filePath: string; + isCustom: boolean; + }) { + try { + const codex = this.getCodex(filePath); + + if (isCustom) { + codex.categories = codex.categories + ? codex.categories.concat(CUSTOM_NODES_CATEGORY) + : [CUSTOM_NODES_CATEGORY]; + } + + node.description.codex = codex; + } catch (_) { + this.logger.debug(`No codex available for: ${filePath.split('/').pop()}`); + + if (isCustom) { + node.description.codex = { + categories: [CUSTOM_NODES_CATEGORY], + }; + } + } + } /** * Loads nodes and credentials from the given directory @@ -209,7 +262,6 @@ class LoadNodesAndCredentialsClass { * @param {string} setPackageName The package name to set for the found nodes * @param {string} directory The directory to look in * @returns {Promise} - * @memberof N8nPackagesInformationClass */ async loadDataFromDirectory(setPackageName: string, directory: string): Promise { const files = await glob(path.join(directory, '**/*\.@(node|credentials)\.js')); @@ -237,7 +289,6 @@ class LoadNodesAndCredentialsClass { * * @param {string} packageName The name to read data from * @returns {Promise} - * @memberof N8nPackagesInformationClass */ async loadDataFromPackage(packageName: string): Promise { // Get the absolute path of the package diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index 014589e0b8..465fdb5dde 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -93,6 +93,10 @@ export function sendErrorResponse(res: Response, error: ResponseError) { message: 'Unknown error', }; + if (error.name === 'NodeApiError') { + Object.assign(response, error); + } + if (error.errorCode) { response.code = error.errorCode; } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index b7ce5394b5..0a31144999 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -673,10 +673,13 @@ class App { await WorkflowHelpers.validateWorkflow(updateData); await Db.collections.Workflow!.update(id, updateData).catch(WorkflowHelpers.throwDuplicateEntryError); - const tablePrefix = config.get('database.tablePrefix'); - await TagHelpers.removeRelations(req.params.id, tablePrefix); - if (tags?.length) { - await TagHelpers.createRelations(req.params.id, tags, tablePrefix); + if (tags) { + const tablePrefix = config.get('database.tablePrefix'); + await TagHelpers.removeRelations(req.params.id, tablePrefix); + + if (tags.length) { + await TagHelpers.createRelations(req.params.id, tags, tablePrefix); + } } // We sadly get nothing back from "update". Neither if it updated a record @@ -944,6 +947,9 @@ class App { const filepath = nodeType.description.icon.substr(5); + const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days + res.setHeader('Cache-control', `private max-age=${maxAge}`); + res.sendFile(filepath); }); diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index a03574752c..9d0478d997 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -387,7 +387,12 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { } // Leave log message before flatten as that operation increased memory usage a lot and the chance of a crash is highest here - Logger.debug(`Save execution data to database for execution ID ${this.executionId}`, { executionId: this.executionId, workflowId: this.workflowData.id }); + Logger.debug(`Save execution data to database for execution ID ${this.executionId}`, { + executionId: this.executionId, + workflowId: this.workflowData.id, + finished: fullExecutionData.finished, + stoppedAt: fullExecutionData.stoppedAt, + }); const executionData = ResponseHelper.flattenExecutionData(fullExecutionData); @@ -404,6 +409,12 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { executeErrorWorkflow(this.workflowData, fullRunData, this.mode, this.executionId, this.retryOf); } } catch (error) { + Logger.error(`Failed saving execution data to DB on execution ID ${this.executionId}`, { + executionId: this.executionId, + workflowId: this.workflowData.id, + error, + }); + if (!isManualMode) { executeErrorWorkflow(this.workflowData, fullRunData, this.mode, undefined, this.retryOf); } @@ -656,7 +667,7 @@ export async function executeWorkflow(workflowInfo: IExecuteWorkflowInfo, additi } -export function sendMessageToUI(source: string, message: string) { +export function sendMessageToUI(source: string, message: any) { // tslint:disable-line:no-any if (this.sessionId === undefined) { return; } diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index 109bfda98c..3dbc77cf6c 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -137,7 +137,7 @@ export class WorkflowRunnerProcess { const additionalData = await WorkflowExecuteAdditionalData.getBase(this.data.credentials, undefined, workflowTimeout <= 0 ? undefined : Date.now() + workflowTimeout * 1000); additionalData.hooks = this.getProcessForwardHooks(); - additionalData.sendMessageToUI = async (source: string, message: string) => { + additionalData.sendMessageToUI = async (source: string, message: any) => { // tslint:disable-line:no-any if (workflowRunner.data!.executionMode !== 'manual') { return; } diff --git a/packages/cli/src/databases/mysqldb/migrations/1623936588000-CertifyCorrectCollation.ts b/packages/cli/src/databases/mysqldb/migrations/1623936588000-CertifyCorrectCollation.ts new file mode 100644 index 0000000000..079cd9cc70 --- /dev/null +++ b/packages/cli/src/databases/mysqldb/migrations/1623936588000-CertifyCorrectCollation.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import config = require('../../../../config'); + +export class CertifyCorrectCollation1623936588000 implements MigrationInterface { + name = 'CertifyCorrectCollation1623936588000'; + + async up(queryRunner: QueryRunner): Promise { + const tablePrefix = config.get('database.tablePrefix'); + const databaseType = config.get('database.type'); + + if (databaseType === 'mariadb') { + // This applies to MySQL only. + return; + } + + const checkCollationExistence = await queryRunner.query(`show collation where collation like 'utf8mb4_0900_ai_ci';`); + let collation = 'utf8mb4_general_ci'; + if (checkCollationExistence.length > 0) { + collation = 'utf8mb4_0900_ai_ci'; + } + + const databaseName = config.get(`database.mysqldb.database`); + + await queryRunner.query(`ALTER DATABASE \`${databaseName}\` CHARACTER SET utf8mb4 COLLATE ${collation};`); + + for (const tableName of [ + 'credentials_entity', + 'execution_entity', + 'tag_entity', + 'webhook_entity', + 'workflow_entity', + 'workflows_tags', + ]) { + await queryRunner.query(`ALTER TABLE ${tablePrefix}${tableName} CONVERT TO CHARACTER SET utf8mb4 COLLATE ${collation};`); + } + } + + async down(queryRunner: QueryRunner): Promise { + // There is nothing to undo in this case as we already expect default collation to be utf8mb4 + // This migration exists simply to enforce that n8n will work with + // older mysql versions + } + +} diff --git a/packages/cli/src/databases/mysqldb/migrations/index.ts b/packages/cli/src/databases/mysqldb/migrations/index.ts index 943359a9f8..1054d68cfc 100644 --- a/packages/cli/src/databases/mysqldb/migrations/index.ts +++ b/packages/cli/src/databases/mysqldb/migrations/index.ts @@ -7,6 +7,7 @@ import { ChangeDataSize1615306975123 } from './1615306975123-ChangeDataSize'; import { ChangeCredentialDataSize1620729500000 } from './1620729500000-ChangeCredentialDataSize'; import { CreateTagEntity1617268711084 } from './1617268711084-CreateTagEntity'; import { UniqueWorkflowNames1620826335440 } from './1620826335440-UniqueWorkflowNames'; +import { CertifyCorrectCollation1623936588000 } from './1623936588000-CertifyCorrectCollation'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -18,4 +19,5 @@ export const mysqlMigrations = [ ChangeCredentialDataSize1620729500000, CreateTagEntity1617268711084, UniqueWorkflowNames1620826335440, + CertifyCorrectCollation1623936588000, ]; diff --git a/packages/core/package.json b/packages/core/package.json index 5924681d11..71871514bf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.73.0", + "version": "0.75.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -47,7 +47,7 @@ "file-type": "^14.6.2", "lodash.get": "^4.4.2", "mime-types": "^2.1.27", - "n8n-workflow": "~0.60.0", + "n8n-workflow": "~0.62.0", "oauth-1.0a": "^2.2.6", "p-cancelable": "^2.0.0", "request": "^2.88.2", diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 5b5698bf18..218eb097d4 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -749,7 +749,7 @@ export function getExecuteFunctions(workflow: Workflow, runExecutionData: IRunEx return workflow.getStaticData(type, node); }, prepareOutputData: NodeHelpers.prepareOutputData, - sendMessageToUI(message: string): void { + sendMessageToUI(message: any): void { // tslint:disable-line:no-any if (mode !== 'manual') { return; } diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index c50d0ebdd0..f829f339f0 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.92.2", + "version": "0.96.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -40,6 +40,7 @@ "@types/lodash.set": "^4.3.6", "@types/node": "^14.14.40", "@types/quill": "^2.0.1", + "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "^2.13.0", "@typescript-eslint/parser": "^2.13.0", "@vue/cli-plugin-babel": "^4.1.2", @@ -67,7 +68,7 @@ "lodash.debounce": "^4.0.8", "lodash.get": "^4.4.2", "lodash.set": "^4.3.2", - "n8n-workflow": "~0.60.0", + "n8n-workflow": "~0.62.0", "node-sass": "^4.12.0", "normalize-wheel": "^1.0.1", "prismjs": "^1.17.1", @@ -78,7 +79,7 @@ "ts-jest": "^26.3.0", "tslint": "^6.1.2", "typescript": "~3.9.7", - "uuid": "^8.1.0", + "uuid": "^8.3.0", "vue": "^2.6.9", "vue-cli-plugin-webpack-bundle-analyzer": "^2.0.0", "vue-json-pretty": "1.7.1", @@ -86,7 +87,7 @@ "vue-router": "^3.0.6", "vue-template-compiler": "^2.5.17", "vue-typed-mixins": "^0.2.0", - "vue2-touch-events": "^2.3.2", + "vue2-touch-events": "^3.2.1", "vuex": "^3.1.1" } } diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 3dab3901ee..f119699f6e 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -221,6 +221,15 @@ export interface IWorkflowDataUpdate { tags?: ITag[] | string[]; // string[] when store or requested, ITag[] from API response } +export interface IWorkflowTemplate { + id: string; + name: string; + workflow: { + nodes: INodeUi[]; + connections: IConnections; + }; +} + // Almost identical to cli.Interfaces.ts export interface IWorkflowDb { id: string; @@ -489,6 +498,39 @@ export interface ILinkMenuItemProperties { newWindow?: boolean; } +export interface ISubcategoryItemProps { + subcategory: string; + description: string; +} + +export interface INodeItemProps { + subcategory: string; + nodeType: INodeTypeDescription; +} + +export interface ICategoryItemProps { + expanded: boolean; +} + +export interface INodeCreateElement { + type: 'node' | 'category' | 'subcategory'; + category: string; + key: string; + includedByTrigger?: boolean; + includedByRegular?: boolean; + properties: ISubcategoryItemProps | INodeItemProps | ICategoryItemProps; +} + +export interface ICategoriesWithNodes { + [category: string]: { + [subcategory: string]: { + regularCount: number; + triggerCount: number; + nodes: INodeCreateElement[]; + }; + }; +} + export interface ITag { id: string; name: string; @@ -569,3 +611,8 @@ export interface IRestApiContext { baseUrl: string; sessionId: string; } + +export interface IZoomConfig { + scale: number; + offset: XYPositon; +} diff --git a/packages/editor-ui/src/api/helpers.ts b/packages/editor-ui/src/api/helpers.ts index ea4d16e10c..739242c12a 100644 --- a/packages/editor-ui/src/api/helpers.ts +++ b/packages/editor-ui/src/api/helpers.ts @@ -42,15 +42,13 @@ class ResponseError extends Error { } } -export async function makeRestApiRequest(context: IRestApiContext, method: Method, endpoint: string, data?: IDataObject) { - const { baseUrl, sessionId } = context; +async function request(config: {method: Method, baseURL: string, endpoint: string, headers?: IDataObject, data?: IDataObject}) { + const { method, baseURL, endpoint, headers, data } = config; const options: AxiosRequestConfig = { method, url: endpoint, - baseURL: baseUrl, - headers: { - sessionid: sessionId, - }, + baseURL, + headers, }; if (['PATCH', 'POST', 'PUT'].includes(method)) { options.data = data; @@ -60,7 +58,7 @@ export async function makeRestApiRequest(context: IRestApiContext, method: Metho try { const response = await axios.request(options); - return response.data.data; + return response.data; } catch (error) { if (error.message === 'Network Error') { throw new ResponseError('API-Server can not be reached. It is probably down.'); @@ -68,9 +66,31 @@ export async function makeRestApiRequest(context: IRestApiContext, method: Metho const errorResponseData = error.response.data; if (errorResponseData !== undefined && errorResponseData.message !== undefined) { + if (errorResponseData.name === 'NodeApiError') { + errorResponseData.httpStatusCode = error.response.status; + throw errorResponseData; + } + throw new ResponseError(errorResponseData.message, {errorCode: errorResponseData.code, httpStatusCode: error.response.status, stack: errorResponseData.stack}); } throw error; } -} \ No newline at end of file +} + +export async function makeRestApiRequest(context: IRestApiContext, method: Method, endpoint: string, data?: IDataObject) { + const response = await request({ + method, + baseURL: context.baseUrl, + endpoint, + headers: {sessionid: context.sessionId}, + data, + }); + + // @ts-ignore all cli rest api endpoints return data wrapped in `data` key + return response.data; +} + +export async function get(baseURL: string, endpoint: string, params?: IDataObject) { + return await request({method: 'GET', baseURL, endpoint, data: params}); +} diff --git a/packages/editor-ui/src/api/workflows.ts b/packages/editor-ui/src/api/workflows.ts index b8cf319efd..89c2569347 100644 --- a/packages/editor-ui/src/api/workflows.ts +++ b/packages/editor-ui/src/api/workflows.ts @@ -1,6 +1,11 @@ -import { IRestApiContext } from '@/Interface'; -import { makeRestApiRequest } from './helpers'; +import { IRestApiContext, IWorkflowTemplate } from '@/Interface'; +import { makeRestApiRequest, get } from './helpers'; +import { TEMPLATES_BASE_URL } from '@/constants'; export async function getNewWorkflow(context: IRestApiContext, name?: string) { return await makeRestApiRequest(context, 'GET', `/workflows/new`, name ? { name } : {}); -} \ No newline at end of file +} + +export async function getWorkflowTemplate(templateId: string): Promise { + return await get(TEMPLATES_BASE_URL, `/workflows/templates/${templateId}`); +} diff --git a/packages/editor-ui/src/components/DataDisplay.vue b/packages/editor-ui/src/components/DataDisplay.vue index 5102566efb..c32dbcecfe 100644 --- a/packages/editor-ui/src/components/DataDisplay.vue +++ b/packages/editor-ui/src/components/DataDisplay.vue @@ -86,11 +86,9 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({ return this.$store.getters.activeNode; }, nodeType (): INodeTypeDescription | null { - const activeNode = this.node; if (this.node) { return this.$store.getters.nodeType(this.node.type); } - return null; }, }, @@ -111,6 +109,7 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({ close (e: MouseEvent) { // @ts-ignore if (e.target.className && e.target.className.includes && e.target.className.includes('close-on-click')) { + this.$externalHooks().run('dataDisplay.nodeEditingFinished'); this.showDocumentHelp = false; this.$store.commit('setActiveNode', null); } diff --git a/packages/editor-ui/src/components/Error/NodeViewError.vue b/packages/editor-ui/src/components/Error/NodeViewError.vue index 2c8cc2f056..4c86eb2571 100644 --- a/packages/editor-ui/src/components/Error/NodeViewError.vue +++ b/packages/editor-ui/src/components/Error/NodeViewError.vue @@ -37,10 +37,11 @@ Data below may contain sensitive information. Proceed with caution when sharing.
- + + + The error cause is too large to be displayed. +
@@ -67,13 +71,14 @@ - - diff --git a/packages/editor-ui/src/components/NodeCreateList.vue b/packages/editor-ui/src/components/NodeCreateList.vue deleted file mode 100644 index a00b3ff2d9..0000000000 --- a/packages/editor-ui/src/components/NodeCreateList.vue +++ /dev/null @@ -1,172 +0,0 @@ - - - - - diff --git a/packages/editor-ui/src/components/NodeCreator.vue b/packages/editor-ui/src/components/NodeCreator.vue deleted file mode 100644 index 7090d5749c..0000000000 --- a/packages/editor-ui/src/components/NodeCreator.vue +++ /dev/null @@ -1,104 +0,0 @@ - - - - - diff --git a/packages/editor-ui/src/components/NodeCreator/CategoryItem.vue b/packages/editor-ui/src/components/NodeCreator/CategoryItem.vue new file mode 100644 index 0000000000..3f4dff39ba --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/CategoryItem.vue @@ -0,0 +1,42 @@ + + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/NodeCreator/CreatorItem.vue b/packages/editor-ui/src/components/NodeCreator/CreatorItem.vue new file mode 100644 index 0000000000..a024c2ec4d --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/CreatorItem.vue @@ -0,0 +1,57 @@ + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/NodeCreator/ItemIterator.vue b/packages/editor-ui/src/components/NodeCreator/ItemIterator.vue new file mode 100644 index 0000000000..7cf00057ef --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/ItemIterator.vue @@ -0,0 +1,94 @@ + + + + + + diff --git a/packages/editor-ui/src/components/NodeCreator/MainPanel.vue b/packages/editor-ui/src/components/NodeCreator/MainPanel.vue new file mode 100644 index 0000000000..bd7f665dbd --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/MainPanel.vue @@ -0,0 +1,332 @@ + + + + + diff --git a/packages/editor-ui/src/components/NodeCreator/NoResults.vue b/packages/editor-ui/src/components/NodeCreator/NoResults.vue new file mode 100644 index 0000000000..c03823bfb2 --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/NoResults.vue @@ -0,0 +1,126 @@ + + + + + + diff --git a/packages/editor-ui/src/components/NodeCreator/NoResultsIcon.vue b/packages/editor-ui/src/components/NodeCreator/NoResultsIcon.vue new file mode 100644 index 0000000000..697b1a42c7 --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/NoResultsIcon.vue @@ -0,0 +1,22 @@ + \ No newline at end of file diff --git a/packages/editor-ui/src/components/NodeCreator/NodeCreator.vue b/packages/editor-ui/src/components/NodeCreator/NodeCreator.vue new file mode 100644 index 0000000000..4c9eccf5e0 --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/NodeCreator.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/packages/editor-ui/src/components/NodeCreator/NodeItem.vue b/packages/editor-ui/src/components/NodeCreator/NodeItem.vue new file mode 100644 index 0000000000..06f4d4f4be --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/NodeItem.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/packages/editor-ui/src/components/NodeCreator/SearchBar.vue b/packages/editor-ui/src/components/NodeCreator/SearchBar.vue new file mode 100644 index 0000000000..482650f31a --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/SearchBar.vue @@ -0,0 +1,124 @@ + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/NodeCreator/SubcategoryItem.vue b/packages/editor-ui/src/components/NodeCreator/SubcategoryItem.vue new file mode 100644 index 0000000000..2ca06e3a21 --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/SubcategoryItem.vue @@ -0,0 +1,58 @@ + + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/NodeCreator/SubcategoryPanel.vue b/packages/editor-ui/src/components/NodeCreator/SubcategoryPanel.vue new file mode 100644 index 0000000000..01af81b4a1 --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/SubcategoryPanel.vue @@ -0,0 +1,96 @@ + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/NodeCreator/helpers.ts b/packages/editor-ui/src/components/NodeCreator/helpers.ts new file mode 100644 index 0000000000..ae194443b6 --- /dev/null +++ b/packages/editor-ui/src/components/NodeCreator/helpers.ts @@ -0,0 +1,176 @@ +import { CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, SUBCATEGORY_DESCRIPTIONS, UNCATEGORIZED_CATEGORY, UNCATEGORIZED_SUBCATEGORY, REGULAR_NODE_FILTER, TRIGGER_NODE_FILTER, ALL_NODE_FILTER } from '@/constants'; +import { INodeCreateElement, ICategoriesWithNodes, INodeItemProps } from '@/Interface'; +import { INodeTypeDescription } from 'n8n-workflow'; + + +export const getCategoriesWithNodes = (nodeTypes: INodeTypeDescription[]): ICategoriesWithNodes => { + return nodeTypes.reduce( + (accu: ICategoriesWithNodes, nodeType: INodeTypeDescription) => { + if (!nodeType.codex || !nodeType.codex.categories) { + accu[UNCATEGORIZED_CATEGORY][UNCATEGORIZED_SUBCATEGORY].nodes.push({ + type: 'node', + category: UNCATEGORIZED_CATEGORY, + key: `${UNCATEGORIZED_CATEGORY}_${nodeType.name}`, + properties: { + subcategory: UNCATEGORIZED_SUBCATEGORY, + nodeType, + }, + includedByTrigger: nodeType.group.includes('trigger'), + includedByRegular: !nodeType.group.includes('trigger'), + }); + return accu; + } + nodeType.codex.categories.forEach((_category: string) => { + const category = _category.trim(); + const subcategory = + nodeType.codex && + nodeType.codex.subcategories && + nodeType.codex.subcategories[category] + ? nodeType.codex.subcategories[category][0] + : UNCATEGORIZED_SUBCATEGORY; + if (!accu[category]) { + accu[category] = {}; + } + if (!accu[category][subcategory]) { + accu[category][subcategory] = { + triggerCount: 0, + regularCount: 0, + nodes: [], + }; + } + const isTrigger = nodeType.group.includes('trigger'); + if (isTrigger) { + accu[category][subcategory].triggerCount++; + } + if (!isTrigger) { + accu[category][subcategory].regularCount++; + } + accu[category][subcategory].nodes.push({ + type: 'node', + key: `${category}_${nodeType.name}`, + category, + properties: { + nodeType, + subcategory, + }, + includedByTrigger: isTrigger, + includedByRegular: !isTrigger, + }); + }); + return accu; + }, + { + [UNCATEGORIZED_CATEGORY]: { + [UNCATEGORIZED_SUBCATEGORY]: { + triggerCount: 0, + regularCount: 0, + nodes: [], + }, + }, + }, + ); +}; + +const getCategories = (categoriesWithNodes: ICategoriesWithNodes): string[] => { + const categories = Object.keys(categoriesWithNodes); + const sorted = categories.filter( + (category: string) => + category !== CORE_NODES_CATEGORY && category !== CUSTOM_NODES_CATEGORY && category !== UNCATEGORIZED_CATEGORY, + ); + sorted.sort(); + + return [CORE_NODES_CATEGORY, CUSTOM_NODES_CATEGORY, ...sorted, UNCATEGORIZED_CATEGORY]; +}; + +export const getCategorizedList = (categoriesWithNodes: ICategoriesWithNodes): INodeCreateElement[] => { + const categories = getCategories(categoriesWithNodes); + + return categories.reduce( + (accu: INodeCreateElement[], category: string) => { + if (!categoriesWithNodes[category]) { + return accu; + } + + const categoryEl: INodeCreateElement = { + type: 'category', + key: category, + category, + properties: { + expanded: false, + }, + }; + + const subcategories = Object.keys(categoriesWithNodes[category]); + if (subcategories.length === 1) { + const subcategory = categoriesWithNodes[category][ + subcategories[0] + ]; + if (subcategory.triggerCount > 0) { + categoryEl.includedByTrigger = subcategory.triggerCount > 0; + } + if (subcategory.regularCount > 0) { + categoryEl.includedByRegular = subcategory.regularCount > 0; + } + return [...accu, categoryEl, ...subcategory.nodes]; + } + + subcategories.sort(); + const subcategorized = subcategories.reduce( + (accu: INodeCreateElement[], subcategory: string) => { + const subcategoryEl: INodeCreateElement = { + type: 'subcategory', + key: `${category}_${subcategory}`, + category, + properties: { + subcategory, + description: SUBCATEGORY_DESCRIPTIONS[category][subcategory], + }, + includedByTrigger: categoriesWithNodes[category][subcategory].triggerCount > 0, + includedByRegular: categoriesWithNodes[category][subcategory].regularCount > 0, + }; + + if (subcategoryEl.includedByTrigger) { + categoryEl.includedByTrigger = true; + } + if (subcategoryEl.includedByRegular) { + categoryEl.includedByRegular = true; + } + + accu.push(subcategoryEl); + return accu; + }, + [], + ); + + return [...accu, categoryEl, ...subcategorized]; + }, + [], + ); +}; + +export const matchesSelectType = (el: INodeCreateElement, selectedType: string) => { + if (selectedType === REGULAR_NODE_FILTER && el.includedByRegular) { + return true; + } + if (selectedType === TRIGGER_NODE_FILTER && el.includedByTrigger) { + return true; + } + + return selectedType === ALL_NODE_FILTER; +}; + +const matchesAlias = (nodeType: INodeTypeDescription, filter: string): boolean => { + if (!nodeType.codex || !nodeType.codex.alias) { + return false; + } + + return nodeType.codex.alias.reduce((accu: boolean, alias: string) => { + return accu || alias.toLowerCase().indexOf(filter) > -1; + }, false); +}; + +export const matchesNodeType = (el: INodeCreateElement, filter: string) => { + const nodeType = (el.properties as INodeItemProps).nodeType; + + return nodeType.displayName.toLowerCase().indexOf(filter) !== -1 || matchesAlias(nodeType, filter); +}; \ No newline at end of file diff --git a/packages/editor-ui/src/components/NodeIcon.vue b/packages/editor-ui/src/components/NodeIcon.vue index d32a1a40ef..bf13e2ef7b 100644 --- a/packages/editor-ui/src/components/NodeIcon.vue +++ b/packages/editor-ui/src/components/NodeIcon.vue @@ -1,7 +1,7 @@