Merge branch 'master' of github.com:n8n-io/n8n into ask-assistant

This commit is contained in:
Mutasem Aldmour 2024-07-10 17:29:22 +02:00
commit fe219c50fa
No known key found for this signature in database
GPG key ID: 3DFA8122BB7FD6B8
96 changed files with 1365 additions and 2190 deletions

View file

@ -0,0 +1,44 @@
import { writeFile, readFile, copyFile } from 'fs/promises';
import { resolve, dirname } from 'path';
import child_process from 'child_process';
import { fileURLToPath } from 'url';
import { promisify } from 'util';
const exec = promisify(child_process.exec);
const commonFiles = ['LICENSE.md', 'LICENSE_EE.md'];
const baseDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const packages = JSON.parse((await exec('pnpm ls -r --only-projects --json')).stdout);
for (let { name, path, version, private: isPrivate } of packages) {
if (isPrivate) continue;
const packageFile = resolve(path, 'package.json');
const packageJson = {
...JSON.parse(await readFile(packageFile, 'utf-8')),
// Add these fields to all published package.json files to ensure provenance checks pass
license: 'SEE LICENSE IN LICENSE.md',
homepage: 'https://n8n.io',
author: {
name: 'Jan Oberhauser',
email: 'jan@n8n.io',
},
repository: {
type: 'git',
url: 'git+https://github.com/n8n-io/n8n.git',
},
};
// Copy over LICENSE.md and LICENSE_EE.md into every published package, and ensure they get included in the published package
await Promise.all(
commonFiles.map(async (file) => {
await copyFile(resolve(baseDir, file), resolve(path, file));
if (packageJson.files && !packageJson.files.includes(file)) {
packageJson.files.push(file);
}
}),
);
await writeFile(packageFile, JSON.stringify(packageJson, null, 2) + '\n');
}

View file

@ -1,12 +1,15 @@
const { writeFileSync } = require('fs');
const { resolve } = require('path');
const baseDir = resolve(__dirname, '..');
const baseDir = resolve(__dirname, '../..');
const trimPackageJson = (packageName) => {
const filePath = resolve(baseDir, 'packages', packageName, 'package.json');
const { scripts, peerDependencies, devDependencies, dependencies, ...packageJson } = require(
filePath,
);
if (packageName === '@n8n/chat') {
packageJson.dependencies = dependencies;
}
writeFileSync(filePath, JSON.stringify(packageJson, null, 2) + '\n', 'utf-8');
};

View file

@ -45,7 +45,8 @@ jobs:
- name: Publish to NPM
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
node scripts/trim-fe-packageJson.js
node .github/scripts/trim-fe-packageJson.js
node .github/scripts/ensure-provenance-fields.mjs
sed -i "s/default: 'dev'/default: 'stable'/g" packages/cli/dist/config/schema.js
pnpm publish -r --publish-branch ${{github.event.pull_request.base.ref}} --access public --tag rc --no-git-checks
npm dist-tag rm n8n rc

View file

@ -2,7 +2,6 @@
"name": "n8n-monorepo",
"version": "1.49.0",
"private": true,
"homepage": "https://n8n.io",
"engines": {
"node": ">=18.10",
"pnpm": ">=9.1"

View file

@ -1,86 +0,0 @@
# License
Portions of this software are licensed as follows:
- Content of branches other than the main branch (i.e. "master") are not licensed.
- Source code files that contain ".ee." in their filename are NOT licensed under the Sustainable Use License.
To use source code files that contain ".ee." in their filename you must hold a valid n8n Enterprise License
specifically allowing you access to such source code files and as defined in "LICENSE_EE.md".
- All third party components incorporated into the n8n Software are licensed under the original license
provided by the owner of the applicable component.
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
License" as defined below.
## Sustainable Use License
Version 1.0
### Acceptance
By using the software, you agree to all of the terms and conditions below.
### Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
to the limitations below.
### Limitations
You may use or modify the software only for your own internal business purposes or for non-commercial or
personal use. You may distribute the software or provide it to others only if you do so free of charge for
non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
the licensor in the software. Any use of the licensors trademarks is subject to applicable law.
### Patents
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
subject to the limitations and conditions in this license. This license does not cover any patent claims that
you cause to be infringed by modifications or additions to the software. If you or your company make any
written claim that the software infringes or contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If your company makes such a claim, your patent
license ends immediately for work on behalf of your company.
### Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
terms. If you modify the software, you must include in any modified copies of the software a prominent notice
stating that you have modified the software.
### No Other Rights
These terms do not imply any licenses other than those expressly granted in these terms.
### Termination
If you use the software in violation of these terms, such use is not licensed, and your license will
automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
violation of this license no later than 30 days after you receive that notice, your license will be reinstated
retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
terms will cause your license to terminate automatically and permanently.
### No Liability
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
not be liable to you for any damages arising out of these terms or the use or nature of the software, under
any kind of legal claim.
### Definitions
The “licensor” is the entity offering these terms.
The “software” is the software the licensor makes available under these terms, including any portion of it.
“You” refers to the individual or entity agreeing to these terms.
“Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
all organizations that have control over, are under the control of, or are under common control with that
organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
management and policies by vote, contract, or otherwise. Control can be direct or indirect.
“Your license” is the license granted to you for the software under these terms.
“Use” means anything you do with the software requiring your license.
“Trademark” means trademarks, service marks, and similar rights.

View file

@ -14,8 +14,7 @@
"lintfix": "eslint . --ext .js,.ts,.vue --fix",
"format": "prettier --write src/",
"storybook": "storybook dev -p 6006 --no-open",
"build:storybook": "storybook build",
"release": "pnpm run build:full && cd dist && pnpm publish"
"build:storybook": "storybook build"
},
"main": "./dist/chat.umd.js",
"module": "./dist/chat.es.js",
@ -52,13 +51,6 @@
},
"files": [
"README.md",
"LICENSE.md",
"dist"
],
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"
},
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io"
]
}

View file

@ -1,10 +1,6 @@
{
"name": "@n8n/client-oauth2",
"version": "0.18.0",
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"
},
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -1,10 +1,6 @@
{
"name": "@n8n/imap",
"version": "0.5.0",
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"
},
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -1,86 +0,0 @@
# License
Portions of this software are licensed as follows:
- Content of branches other than the main branch (i.e. "master") are not licensed.
- Source code files that contain ".ee." in their filename are NOT licensed under the Sustainable Use License.
To use source code files that contain ".ee." in their filename you must hold a valid n8n Enterprise License
specifically allowing you access to such source code files and as defined in "LICENSE_EE.md".
- All third party components incorporated into the n8n Software are licensed under the original license
provided by the owner of the applicable component.
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
License" as defined below.
## Sustainable Use License
Version 1.0
### Acceptance
By using the software, you agree to all of the terms and conditions below.
### Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
to the limitations below.
### Limitations
You may use or modify the software only for your own internal business purposes or for non-commercial or
personal use. You may distribute the software or provide it to others only if you do so free of charge for
non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
the licensor in the software. Any use of the licensors trademarks is subject to applicable law.
### Patents
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
subject to the limitations and conditions in this license. This license does not cover any patent claims that
you cause to be infringed by modifications or additions to the software. If you or your company make any
written claim that the software infringes or contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If your company makes such a claim, your patent
license ends immediately for work on behalf of your company.
### Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
terms. If you modify the software, you must include in any modified copies of the software a prominent notice
stating that you have modified the software.
### No Other Rights
These terms do not imply any licenses other than those expressly granted in these terms.
### Termination
If you use the software in violation of these terms, such use is not licensed, and your license will
automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
violation of this license no later than 30 days after you receive that notice, your license will be reinstated
retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
terms will cause your license to terminate automatically and permanently.
### No Liability
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
not be liable to you for any damages arising out of these terms or the use or nature of the software, under
any kind of legal claim.
### Definitions
The “licensor” is the entity offering these terms.
The “software” is the software the licensor makes available under these terms, including any portion of it.
“You” refers to the individual or entity agreeing to these terms.
“Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
all organizations that have control over, are under the control of, or are under common control with that
organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
management and policies by vote, contract, or otherwise. Control can be direct or indirect.
“Your license” is the license granted to you for the software under these terms.
“Use” means anything you do with the software requiring your license.
“Trademark” means trademarks, service marks, and similar rights.

View file

@ -2,12 +2,6 @@
"name": "@n8n/n8n-nodes-langchain",
"version": "1.49.0",
"description": "",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"
},
"main": "index.js",
"scripts": {
"clean": "rimraf dist .turbo",

View file

@ -1,10 +1,6 @@
{
"name": "@n8n/permissions",
"version": "0.10.0",
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"
},
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -1,86 +0,0 @@
# License
Portions of this software are licensed as follows:
- Content of branches other than the main branch (i.e. "master") are not licensed.
- Source code files that contain ".ee." in their filename are NOT licensed under the Sustainable Use License.
To use source code files that contain ".ee." in their filename you must hold a valid n8n Enterprise License
specifically allowing you access to such source code files and as defined in "LICENSE_EE.md".
- All third party components incorporated into the n8n Software are licensed under the original license
provided by the owner of the applicable component.
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
License" as defined below.
## Sustainable Use License
Version 1.0
### Acceptance
By using the software, you agree to all of the terms and conditions below.
### Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
to the limitations below.
### Limitations
You may use or modify the software only for your own internal business purposes or for non-commercial or
personal use. You may distribute the software or provide it to others only if you do so free of charge for
non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
the licensor in the software. Any use of the licensors trademarks is subject to applicable law.
### Patents
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
subject to the limitations and conditions in this license. This license does not cover any patent claims that
you cause to be infringed by modifications or additions to the software. If you or your company make any
written claim that the software infringes or contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If your company makes such a claim, your patent
license ends immediately for work on behalf of your company.
### Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
terms. If you modify the software, you must include in any modified copies of the software a prominent notice
stating that you have modified the software.
### No Other Rights
These terms do not imply any licenses other than those expressly granted in these terms.
### Termination
If you use the software in violation of these terms, such use is not licensed, and your license will
automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
violation of this license no later than 30 days after you receive that notice, your license will be reinstated
retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
terms will cause your license to terminate automatically and permanently.
### No Liability
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
not be liable to you for any damages arising out of these terms or the use or nature of the software, under
any kind of legal claim.
### Definitions
The “licensor” is the entity offering these terms.
The “software” is the software the licensor makes available under these terms, including any portion of it.
“You” refers to the individual or entity agreeing to these terms.
“Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
all organizations that have control over, are under the control of, or are under common control with that
organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
management and policies by vote, contract, or otherwise. Control can be direct or indirect.
“Your license” is the license granted to you for the software under these terms.
“Use” means anything you do with the software requiring your license.
“Trademark” means trademarks, service marks, and similar rights.

View file

@ -1,27 +0,0 @@
# The n8n Enterprise License (the “Enterprise License”)
Copyright (c) 2022-present n8n GmbH.
With regard to the n8n Software:
This software and associated documentation files (the "Software") may only be used in production, if
you (and any entity that you represent) hold a valid n8n Enterprise license corresponding to your
usage. Subject to the foregoing sentence, you are free to modify this Software and publish patches
to the Software. You agree that n8n and/or its licensors (as applicable) retain all right, title and
interest in and to all such modifications and/or patches, and all such modifications and/or patches
may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid n8n
Enterprise license for the corresponding usage. Notwithstanding the foregoing, you may copy and
modify the Software for development and testing purposes, without requiring a subscription. You
agree that n8n and/or its licensors (as applicable) retain all right, title and interest in and to
all such modifications. You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or
sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For all third party components incorporated into the n8n Software, those components are licensed
under the original license provided by the owner of the applicable component.

View file

@ -2,16 +2,6 @@
"name": "n8n",
"version": "1.49.0",
"description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
"author": {
"name": "Jan Oberhauser",
"email": "jan@n8n.io"
},
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"
},
"main": "dist/index",
"types": "dist/index.d.ts",
"oclif": {

View file

@ -335,6 +335,7 @@ export class Server extends AbstractServer {
// Route all UI urls to index.html to support history-api
const nonUIRoutes: Readonly<string[]> = [
'assets',
'static',
'types',
'healthz',
'metrics',
@ -364,12 +365,20 @@ export class Server extends AbstractServer {
next();
}
};
const setCustomCacheHeader = (res: express.Response) => {
if (/^\/types\/(nodes|credentials).json$/.test(res.req.url)) {
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
}
};
this.app.use(
'/',
express.static(staticCacheDir, cacheOptions),
express.static(EDITOR_UI_DIST_DIR, cacheOptions),
historyApiHandler,
express.static(staticCacheDir, {
...cacheOptions,
setHeaders: setCustomCacheHeader,
}),
express.static(EDITOR_UI_DIST_DIR, cacheOptions),
);
} else {
this.app.use('/', express.static(staticCacheDir, cacheOptions));

View file

@ -13,6 +13,8 @@ export class Column {
private defaultValue: unknown;
private primaryKeyConstraintName: string | undefined;
constructor(private name: string) {}
get bool() {
@ -57,6 +59,12 @@ export class Column {
return this;
}
primaryWithName(name?: string) {
this.isPrimary = true;
this.primaryKeyConstraintName = name;
return this;
}
get notNull() {
this.isNullable = false;
return this;
@ -74,12 +82,14 @@ export class Column {
// eslint-disable-next-line complexity
toOptions(driver: Driver): TableColumnOptions {
const { name, type, isNullable, isPrimary, isGenerated, length } = this;
const { name, type, isNullable, isPrimary, isGenerated, length, primaryKeyConstraintName } =
this;
const isMysql = 'mysql' in driver;
const isPostgres = 'postgres' in driver;
const isSqlite = 'sqlite' in driver;
const options: TableColumnOptions = {
primaryKeyConstraintName,
name,
isNullable,
isPrimary,

View file

@ -1,4 +1,4 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, RelationId } from '@n8n/typeorm';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from '@n8n/typeorm';
import { ExecutionEntity } from './ExecutionEntity';
@Entity()
@ -11,8 +11,8 @@ export class ExecutionMetadata {
})
execution: ExecutionEntity;
@RelationId((executionMetadata: ExecutionMetadata) => executionMetadata.execution)
executionId: number;
@Column()
executionId: string;
@Column('text')
key: string;

View file

@ -0,0 +1,112 @@
import type { MigrationContext, ReversibleMigration } from '@db/types';
import { nanoid } from 'nanoid';
export class AddConstraintToExecutionMetadata1720101653148 implements ReversibleMigration {
async up(context: MigrationContext) {
const { createTable, dropTable, column } = context.schemaBuilder;
const { escape } = context;
const executionMetadataTableRaw = 'execution_metadata';
const executionMetadataTable = escape.tableName(executionMetadataTableRaw);
const executionMetadataTableTempRaw = 'execution_metadata_temp';
const executionMetadataTableTemp = escape.tableName(executionMetadataTableTempRaw);
const id = escape.columnName('id');
const executionId = escape.columnName('executionId');
const key = escape.columnName('key');
const value = escape.columnName('value');
await createTable(executionMetadataTableTempRaw)
.withColumns(
column('id').int.notNull.primary.autoGenerate,
column('executionId').int.notNull,
// NOTE: This is a varchar(255) instead of text, because a unique index
// on text is not supported on mysql, also why should we support
// arbitrary length keys?
column('key').varchar(255).notNull,
column('value').text.notNull,
)
.withForeignKey('executionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'CASCADE',
// In MySQL foreignKey names must be unique across all tables and
// TypeORM creates predictable names based on the columnName.
// So the temp table's foreignKey clashes with the current table's.
name: context.isMysql ? nanoid() : undefined,
})
.withIndexOn(['executionId', 'key'], true);
if (context.isMysql) {
await context.runQuery(`
INSERT INTO ${executionMetadataTableTemp} (${id}, ${executionId}, ${key}, ${value})
SELECT MAX(${id}) as ${id}, ${executionId}, ${key}, MAX(${value})
FROM ${executionMetadataTable}
GROUP BY ${executionId}, ${key}
ON DUPLICATE KEY UPDATE
id = IF(VALUES(${id}) > ${executionMetadataTableTemp}.${id}, VALUES(${id}), ${executionMetadataTableTemp}.${id}),
value = IF(VALUES(${id}) > ${executionMetadataTableTemp}.${id}, VALUES(${value}), ${executionMetadataTableTemp}.${value});
`);
} else {
await context.runQuery(`
INSERT INTO ${executionMetadataTableTemp} (${id}, ${executionId}, ${key}, ${value})
SELECT MAX(${id}) as ${id}, ${executionId}, ${key}, MAX(${value})
FROM ${executionMetadataTable}
GROUP BY ${executionId}, ${key}
ON CONFLICT (${executionId}, ${key}) DO UPDATE SET
id = EXCLUDED.id,
value = EXCLUDED.value
WHERE EXCLUDED.id > ${executionMetadataTableTemp}.id;
`);
}
await dropTable(executionMetadataTableRaw);
await context.runQuery(
`ALTER TABLE ${executionMetadataTableTemp} RENAME TO ${executionMetadataTable};`,
);
}
async down(context: MigrationContext) {
const { createTable, dropTable, column } = context.schemaBuilder;
const { escape } = context;
const executionMetadataTableRaw = 'execution_metadata';
const executionMetadataTable = escape.tableName(executionMetadataTableRaw);
const executionMetadataTableTempRaw = 'execution_metadata_temp';
const executionMetadataTableTemp = escape.tableName(executionMetadataTableTempRaw);
const id = escape.columnName('id');
const executionId = escape.columnName('executionId');
const key = escape.columnName('key');
const value = escape.columnName('value');
await createTable(executionMetadataTableTempRaw)
.withColumns(
// INFO: The PK names that TypeORM creates are predictable and thus it
// will create a PK name which already exists in the current
// execution_metadata table. That's why we have to randomize the PK name
// here.
column('id').int.notNull.primaryWithName(nanoid()).autoGenerate,
column('executionId').int.notNull,
column('key').text.notNull,
column('value').text.notNull,
)
.withForeignKey('executionId', {
tableName: 'execution_entity',
columnName: 'id',
onDelete: 'CASCADE',
// In MySQL foreignKey names must be unique across all tables and
// TypeORM creates predictable names based on the columnName.
// So the temp table's foreignKey clashes with the current table's.
name: context.isMysql ? nanoid() : undefined,
});
await context.runQuery(`
INSERT INTO ${executionMetadataTableTemp} (${id}, ${executionId}, ${key}, ${value})
SELECT ${id}, ${executionId}, ${key}, ${value} FROM ${executionMetadataTable};
`);
await dropTable(executionMetadataTableRaw);
await context.runQuery(
`ALTER TABLE ${executionMetadataTableTemp} RENAME TO ${executionMetadataTable};`,
);
}
}

View file

@ -58,6 +58,7 @@ import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-Move
import { RemoveNodesAccess1712044305787 } from '../common/1712044305787-RemoveNodesAccess';
import { MakeExecutionStatusNonNullable1714133768521 } from '../common/1714133768521-MakeExecutionStatusNonNullable';
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata';
export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238,
@ -119,4 +120,5 @@ export const mysqlMigrations: Migration[] = [
CreateProject1714133768519,
MakeExecutionStatusNonNullable1714133768521,
AddActivatedAtUserSetting1717498465931,
AddConstraintToExecutionMetadata1720101653148,
];

View file

@ -57,6 +57,7 @@ import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-Move
import { RemoveNodesAccess1712044305787 } from '../common/1712044305787-RemoveNodesAccess';
import { MakeExecutionStatusNonNullable1714133768521 } from '../common/1714133768521-MakeExecutionStatusNonNullable';
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata';
export const postgresMigrations: Migration[] = [
InitialMigration1587669153312,
@ -117,4 +118,5 @@ export const postgresMigrations: Migration[] = [
CreateProject1714133768519,
MakeExecutionStatusNonNullable1714133768521,
AddActivatedAtUserSetting1717498465931,
AddConstraintToExecutionMetadata1720101653148,
];

View file

@ -55,6 +55,7 @@ import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-Move
import { RemoveNodesAccess1712044305787 } from '../common/1712044305787-RemoveNodesAccess';
import { MakeExecutionStatusNonNullable1714133768521 } from '../common/1714133768521-MakeExecutionStatusNonNullable';
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
@ -113,6 +114,7 @@ const sqliteMigrations: Migration[] = [
CreateProject1714133768519,
MakeExecutionStatusNonNullable1714133768521,
AddActivatedAtUserSetting1717498465931,
AddConstraintToExecutionMetadata1720101653148,
];
export { sqliteMigrations };

View file

@ -235,6 +235,7 @@ describe('ExecutionService', () => {
* Assert
*/
expect(waitTracker.stopExecution).not.toHaveBeenCalled();
expect(activeExecutions.stopExecution).toHaveBeenCalled();
expect(queue.findRunningJobBy).toBeCalledWith({ executionId: execution.id });
expect(queue.stopJob).toHaveBeenCalled();
expect(executionRepository.stopDuringRun).toHaveBeenCalled();
@ -260,6 +261,7 @@ describe('ExecutionService', () => {
* Assert
*/
expect(waitTracker.stopExecution).toHaveBeenCalledWith(execution.id);
expect(activeExecutions.stopExecution).toHaveBeenCalled();
expect(queue.findRunningJobBy).toBeCalledWith({ executionId: execution.id });
expect(queue.stopJob).toHaveBeenCalled();
expect(executionRepository.stopDuringRun).toHaveBeenCalled();

View file

@ -460,6 +460,10 @@ export class ExecutionService {
return await this.stopInRegularMode(execution);
}
if (this.activeExecutions.has(execution.id)) {
await this.activeExecutions.stopExecution(execution.id);
}
if (this.waitTracker.has(execution.id)) {
await this.waitTracker.stopExecution(execution.id);
}

View file

@ -6,19 +6,18 @@ import type { ExecutionMetadata } from '@db/entities/ExecutionMetadata';
export class ExecutionMetadataService {
constructor(private readonly executionMetadataRepository: ExecutionMetadataRepository) {}
async save(
executionId: string,
executionMetadata: Record<string, string>,
): Promise<ExecutionMetadata[]> {
const metadataRows = [];
async save(executionId: string, executionMetadata: Record<string, string>): Promise<void> {
const metadataRows: Array<Pick<ExecutionMetadata, 'executionId' | 'key' | 'value'>> = [];
for (const [key, value] of Object.entries(executionMetadata)) {
metadataRows.push({
execution: { id: executionId },
executionId,
key,
value,
});
}
return await this.executionMetadataRepository.save(metadataRows);
await this.executionMetadataRepository.upsert(metadataRows, {
conflictPaths: { executionId: true, key: true },
});
}
}

View file

@ -0,0 +1,55 @@
import * as testDb from '../shared/testDb';
import Container from 'typedi';
import { ExecutionMetadataRepository } from '@/databases/repositories/executionMetadata.repository';
import { ExecutionMetadataService } from '@/services/executionMetadata.service';
import { createExecution } from '@test-integration/db/executions';
import { createWorkflow } from '@test-integration/db/workflows';
let executionMetadataRepository: ExecutionMetadataRepository;
let executionMetadataService: ExecutionMetadataService;
beforeAll(async () => {
await testDb.init();
executionMetadataRepository = Container.get(ExecutionMetadataRepository);
executionMetadataService = Container.get(ExecutionMetadataService);
});
afterAll(async () => {
await testDb.terminate();
});
afterEach(async () => {
await testDb.truncate(['User']);
});
describe('ProjectService', () => {
describe('save', () => {
it('should deduplicate entries by exeuctionId and key, keeping the latest one', async () => {
//
// ARRANGE
//
const workflow = await createWorkflow();
const execution = await createExecution({}, workflow);
const key = 'key';
const value1 = 'value1';
const value2 = 'value2';
//
// ACT
//
await executionMetadataService.save(execution.id, { [key]: value1 });
await executionMetadataService.save(execution.id, { [key]: value2 });
//
// ASSERT
//
const rows = await executionMetadataRepository.find({
where: { executionId: execution.id, key },
});
expect(rows).toHaveLength(1);
expect(rows[0]).toHaveProperty('value', value2);
});
});
});

View file

@ -15,20 +15,26 @@ describe('ExecutionMetadataService', () => {
await Container.get(ExecutionMetadataService).save(executionId, toSave);
expect(repository.save).toHaveBeenCalledTimes(1);
expect(repository.save.mock.calls[0]).toEqual([
expect(repository.upsert).toHaveBeenCalledTimes(1);
expect(repository.upsert.mock.calls[0]).toEqual([
[
{
execution: { id: executionId },
executionId,
key: 'test1',
value: 'value1',
},
{
execution: { id: executionId },
executionId,
key: 'test2',
value: 'value2',
},
],
{
conflictPaths: {
executionId: true,
key: true,
},
},
]);
});
});

View file

@ -1,86 +0,0 @@
# License
Portions of this software are licensed as follows:
- Content of branches other than the main branch (i.e. "master") are not licensed.
- Source code files that contain ".ee." in their filename are NOT licensed under the Sustainable Use License.
To use source code files that contain ".ee." in their filename you must hold a valid n8n Enterprise License
specifically allowing you access to such source code files and as defined in "LICENSE_EE.md".
- All third party components incorporated into the n8n Software are licensed under the original license
provided by the owner of the applicable component.
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
License" as defined below.
## Sustainable Use License
Version 1.0
### Acceptance
By using the software, you agree to all of the terms and conditions below.
### Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
to the limitations below.
### Limitations
You may use or modify the software only for your own internal business purposes or for non-commercial or
personal use. You may distribute the software or provide it to others only if you do so free of charge for
non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
the licensor in the software. Any use of the licensors trademarks is subject to applicable law.
### Patents
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
subject to the limitations and conditions in this license. This license does not cover any patent claims that
you cause to be infringed by modifications or additions to the software. If you or your company make any
written claim that the software infringes or contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If your company makes such a claim, your patent
license ends immediately for work on behalf of your company.
### Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
terms. If you modify the software, you must include in any modified copies of the software a prominent notice
stating that you have modified the software.
### No Other Rights
These terms do not imply any licenses other than those expressly granted in these terms.
### Termination
If you use the software in violation of these terms, such use is not licensed, and your license will
automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
violation of this license no later than 30 days after you receive that notice, your license will be reinstated
retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
terms will cause your license to terminate automatically and permanently.
### No Liability
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
not be liable to you for any damages arising out of these terms or the use or nature of the software, under
any kind of legal claim.
### Definitions
The “licensor” is the entity offering these terms.
The “software” is the software the licensor makes available under these terms, including any portion of it.
“You” refers to the individual or entity agreeing to these terms.
“Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
all organizations that have control over, are under the control of, or are under common control with that
organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
management and policies by vote, contract, or otherwise. Control can be direct or indirect.
“Your license” is the license granted to you for the software under these terms.
“Use” means anything you do with the software requiring your license.
“Trademark” means trademarks, service marks, and similar rights.

View file

@ -1,27 +0,0 @@
# The n8n Enterprise License (the “Enterprise License”)
Copyright (c) 2022-present n8n GmbH.
With regard to the n8n Software:
This software and associated documentation files (the "Software") may only be used in production, if
you (and any entity that you represent) hold a valid n8n Enterprise license corresponding to your
usage. Subject to the foregoing sentence, you are free to modify this Software and publish patches
to the Software. You agree that n8n and/or its licensors (as applicable) retain all right, title and
interest in and to all such modifications and/or patches, and all such modifications and/or patches
may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid n8n
Enterprise license for the corresponding usage. Notwithstanding the foregoing, you may copy and
modify the Software for development and testing purposes, without requiring a subscription. You
agree that n8n and/or its licensors (as applicable) retain all right, title and interest in and to
all such modifications. You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or
sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For all third party components incorporated into the n8n Software, those components are licensed
under the original license provided by the owner of the applicable component.

View file

@ -2,16 +2,6 @@
"name": "n8n-core",
"version": "1.49.0",
"description": "Core functionality of n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
"author": {
"name": "Jan Oberhauser",
"email": "jan@n8n.io"
},
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"
},
"main": "dist/index",
"types": "dist/index.d.ts",
"bin": {

View file

@ -1,86 +0,0 @@
# License
Portions of this software are licensed as follows:
- Content of branches other than the main branch (i.e. "master") are not licensed.
- Source code files that contain ".ee." in their filename are NOT licensed under the Sustainable Use License.
To use source code files that contain ".ee." in their filename you must hold a valid n8n Enterprise License
specifically allowing you access to such source code files and as defined in "LICENSE_EE.md".
- All third party components incorporated into the n8n Software are licensed under the original license
provided by the owner of the applicable component.
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
License" as defined below.
## Sustainable Use License
Version 1.0
### Acceptance
By using the software, you agree to all of the terms and conditions below.
### Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
to the limitations below.
### Limitations
You may use or modify the software only for your own internal business purposes or for non-commercial or
personal use. You may distribute the software or provide it to others only if you do so free of charge for
non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
the licensor in the software. Any use of the licensors trademarks is subject to applicable law.
### Patents
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
subject to the limitations and conditions in this license. This license does not cover any patent claims that
you cause to be infringed by modifications or additions to the software. If you or your company make any
written claim that the software infringes or contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If your company makes such a claim, your patent
license ends immediately for work on behalf of your company.
### Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
terms. If you modify the software, you must include in any modified copies of the software a prominent notice
stating that you have modified the software.
### No Other Rights
These terms do not imply any licenses other than those expressly granted in these terms.
### Termination
If you use the software in violation of these terms, such use is not licensed, and your license will
automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
violation of this license no later than 30 days after you receive that notice, your license will be reinstated
retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
terms will cause your license to terminate automatically and permanently.
### No Liability
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
not be liable to you for any damages arising out of these terms or the use or nature of the software, under
any kind of legal claim.
### Definitions
The “licensor” is the entity offering these terms.
The “software” is the software the licensor makes available under these terms, including any portion of it.
“You” refers to the individual or entity agreeing to these terms.
“Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
all organizations that have control over, are under the control of, or are under common control with that
organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
management and policies by vote, contract, or otherwise. Control can be direct or indirect.
“Your license” is the license granted to you for the software under these terms.
“Use” means anything you do with the software requiring your license.
“Trademark” means trademarks, service marks, and similar rights.

View file

@ -1,27 +0,0 @@
# The n8n Enterprise License (the “Enterprise License”)
Copyright (c) 2022-present n8n GmbH.
With regard to the n8n Software:
This software and associated documentation files (the "Software") may only be used in production, if
you (and any entity that you represent) hold a valid n8n Enterprise license corresponding to your
usage. Subject to the foregoing sentence, you are free to modify this Software and publish patches
to the Software. You agree that n8n and/or its licensors (as applicable) retain all right, title and
interest in and to all such modifications and/or patches, and all such modifications and/or patches
may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid n8n
Enterprise license for the corresponding usage. Notwithstanding the foregoing, you may copy and
modify the Software for development and testing purposes, without requiring a subscription. You
agree that n8n and/or its licensors (as applicable) retain all right, title and interest in and to
all such modifications. You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or
sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For all third party components incorporated into the n8n Software, those components are licensed
under the original license provided by the owner of the applicable component.

View file

@ -1,18 +1,8 @@
{
"name": "n8n-design-system",
"version": "1.39.0",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
"author": {
"name": "Mutasem Aldmour",
"email": "mutasem@n8n.io"
},
"main": "src/main.ts",
"import": "src/main.ts",
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"
},
"scripts": {
"dev": "pnpm run storybook",
"clean": "rimraf dist .turbo",

View file

@ -85,6 +85,7 @@
--color-node-executing-background: var(--color-primary-tint-3);
--color-node-executing-other-background: var(--color-primary-tint-3);
--color-node-pinned-border: var(--color-secondary);
--color-node-running-border: var(--color-primary);
--node-type-main-color: var(--prim-gray-490);
// Sticky

View file

@ -1,5 +1,6 @@
/src
/tests
/.turbo
!dist
.browserslistrc
@ -8,3 +9,4 @@ postcss.config.js
vue.config.js
dist/report.html
public/
.eslintrc.js

View file

@ -1,86 +0,0 @@
# License
Portions of this software are licensed as follows:
- Content of branches other than the main branch (i.e. "master") are not licensed.
- Source code files that contain ".ee." in their filename are NOT licensed under the Sustainable Use License.
To use source code files that contain ".ee." in their filename you must hold a valid n8n Enterprise License
specifically allowing you access to such source code files and as defined in "LICENSE_EE.md".
- All third party components incorporated into the n8n Software are licensed under the original license
provided by the owner of the applicable component.
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
License" as defined below.
## Sustainable Use License
Version 1.0
### Acceptance
By using the software, you agree to all of the terms and conditions below.
### Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
to the limitations below.
### Limitations
You may use or modify the software only for your own internal business purposes or for non-commercial or
personal use. You may distribute the software or provide it to others only if you do so free of charge for
non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
the licensor in the software. Any use of the licensors trademarks is subject to applicable law.
### Patents
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
subject to the limitations and conditions in this license. This license does not cover any patent claims that
you cause to be infringed by modifications or additions to the software. If you or your company make any
written claim that the software infringes or contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If your company makes such a claim, your patent
license ends immediately for work on behalf of your company.
### Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
terms. If you modify the software, you must include in any modified copies of the software a prominent notice
stating that you have modified the software.
### No Other Rights
These terms do not imply any licenses other than those expressly granted in these terms.
### Termination
If you use the software in violation of these terms, such use is not licensed, and your license will
automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
violation of this license no later than 30 days after you receive that notice, your license will be reinstated
retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
terms will cause your license to terminate automatically and permanently.
### No Liability
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
not be liable to you for any damages arising out of these terms or the use or nature of the software, under
any kind of legal claim.
### Definitions
The “licensor” is the entity offering these terms.
The “software” is the software the licensor makes available under these terms, including any portion of it.
“You” refers to the individual or entity agreeing to these terms.
“Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
all organizations that have control over, are under the control of, or are under common control with that
organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
management and policies by vote, contract, or otherwise. Control can be direct or indirect.
“Your license” is the license granted to you for the software under these terms.
“Use” means anything you do with the software requiring your license.
“Trademark” means trademarks, service marks, and similar rights.

View file

@ -1,27 +0,0 @@
# The n8n Enterprise License (the “Enterprise License”)
Copyright (c) 2022-present n8n GmbH.
With regard to the n8n Software:
This software and associated documentation files (the "Software") may only be used in production, if
you (and any entity that you represent) hold a valid n8n Enterprise license corresponding to your
usage. Subject to the foregoing sentence, you are free to modify this Software and publish patches
to the Software. You agree that n8n and/or its licensors (as applicable) retain all right, title and
interest in and to all such modifications and/or patches, and all such modifications and/or patches
may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid n8n
Enterprise license for the corresponding usage. Notwithstanding the foregoing, you may copy and
modify the Software for development and testing purposes, without requiring a subscription. You
agree that n8n and/or its licensors (as applicable) retain all right, title and interest in and to
all such modifications. You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or
sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For all third party components incorporated into the n8n Software, those components are licensed
under the original license provided by the owner of the applicable component.

View file

@ -2,17 +2,7 @@
"name": "n8n-editor-ui",
"version": "1.49.0",
"description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
"author": {
"name": "Jan Oberhauser",
"email": "jan@n8n.io"
},
"main": "index.js",
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"
},
"scripts": {
"clean": "rimraf dist .turbo",
"build": "cross-env VUE_APP_PUBLIC_PATH=\"/{{BASE_PATH}}/\" NODE_OPTIONS=\"--max-old-space-size=8192\" vite build",

View file

@ -10,11 +10,14 @@ export function createCanvasNodeData({
inputs = [],
outputs = [],
connections = { input: {}, output: {} },
execution = {},
execution = { running: false },
issues = { items: [], visible: false },
pinnedData = { count: 0, visible: false },
runData = { count: 0, visible: false },
renderType = 'default',
render = {
type: 'default',
options: { configurable: false, configuration: false, trigger: false },
},
}: Partial<CanvasElementData> = {}): CanvasElementData {
return {
execution,
@ -28,7 +31,7 @@ export function createCanvasNodeData({
inputs,
outputs,
connections,
renderType,
render,
};
}
@ -55,7 +58,7 @@ export function createCanvasNodeProps({
label = 'Test Node',
selected = false,
data = {},
} = {}) {
}: { id?: string; label?: string; selected?: boolean; data?: Partial<CanvasElementData> } = {}) {
return {
id,
label,
@ -69,7 +72,7 @@ export function createCanvasNodeProvide({
label = 'Test Node',
selected = false,
data = {},
} = {}) {
}: { id?: string; label?: string; selected?: boolean; data?: Partial<CanvasElementData> } = {}) {
const props = createCanvasNodeProps({ id, label, selected, data });
return {
[`${CanvasNodeKey}`]: {

View file

@ -107,6 +107,7 @@ describe('Canvas', () => {
});
await fireEvent.mouseUp(node, { view: window });
expect(emitted()['update:node:position']).toEqual([['1', { x: 100, y: 100 }]]);
// Snap to 16px grid: 100 -> 96
expect(emitted()['update:node:position']).toEqual([['1', { x: 96, y: 96 }]]);
});
});

View file

@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { CanvasConnection, CanvasElement } from '@/types';
import type { CanvasConnection, CanvasElement, ConnectStartEvent } from '@/types';
import type { EdgeMouseEvent, NodeDragEvent, Connection, XYPosition } from '@vue-flow/core';
import { useVueFlow, VueFlow, PanelPosition } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
@ -17,9 +17,13 @@ const emit = defineEmits<{
'update:node:active': [id: string];
'update:node:enabled': [id: string];
'update:node:selected': [id?: string];
'run:node': [id: string];
'delete:node': [id: string];
'delete:connection': [connection: Connection];
'create:connection:start': [handle: ConnectStartEvent];
'create:connection': [connection: Connection];
'create:connection:end': [connection: Connection];
'create:connection:cancelled': [handle: ConnectStartEvent];
'click:pane': [position: XYPosition];
}>();
@ -79,12 +83,48 @@ function onDeleteNode(id: string) {
emit('delete:node', id);
}
/**
* Connections
*/
const connectionCreated = ref(false);
const connectionEventData = ref<ConnectStartEvent | Connection>();
const isConnection = (data: ConnectStartEvent | Connection | undefined): data is Connection =>
!!data && connectionCreated.value;
const isConnectionCancelled = (
data: ConnectStartEvent | Connection | undefined,
): data is ConnectStartEvent => !!data && !connectionCreated.value;
function onConnectStart(handle: ConnectStartEvent) {
emit('create:connection:start', handle);
connectionEventData.value = handle;
connectionCreated.value = false;
}
function onConnect(connection: Connection) {
emit('create:connection', connection);
connectionEventData.value = connection;
connectionCreated.value = true;
}
function onConnectEnd() {
if (isConnection(connectionEventData.value)) {
emit('create:connection:end', connectionEventData.value);
} else if (isConnectionCancelled(connectionEventData.value)) {
emit('create:connection:cancelled', connectionEventData.value);
}
}
function onDeleteConnection(connection: Connection) {
emit('delete:connection', connection);
}
function onConnect(...args: unknown[]) {
emit('create:connection', args[0] as Connection);
function onRunNode(id: string) {
emit('run:node', id);
}
function onKeyDown(e: KeyboardEvent) {
@ -121,6 +161,8 @@ function onClickPane(event: MouseEvent) {
:apply-changes="false"
fit-view-on-init
pan-on-scroll
snap-to-grid
:snap-grid="[16, 16]"
:min-zoom="0.2"
:max-zoom="2"
data-test-id="canvas"
@ -128,13 +170,16 @@ function onClickPane(event: MouseEvent) {
@selection-drag-stop="onSelectionDragStop"
@edge-mouse-enter="onMouseEnterEdge"
@edge-mouse-leave="onMouseLeaveEdge"
@pane-click="onClickPane"
@connect-start="onConnectStart"
@connect="onConnect"
@connect-end="onConnectEnd"
@pane-click="onClickPane"
>
<template #node-canvas-node="canvasNodeProps">
<CanvasNode
v-bind="canvasNodeProps"
@delete="onDeleteNode"
@run="onRunNode"
@select="onSelectNode"
@toggle="onToggleNodeEnabled"
@activate="onSetNodeActive"

View file

@ -47,30 +47,4 @@ const { elements, connections } = useCanvasMapping({ workflow, workflowObject })
position: relative;
display: block;
}
.executionButtons {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
left: 50%;
transform: translateX(-50%);
bottom: var(--spacing-l);
width: auto;
@media (max-width: $breakpoint-2xs) {
bottom: 150px;
}
button {
display: flex;
justify-content: center;
align-items: center;
margin-left: 0.625rem;
&:first-child {
margin: 0;
}
}
}
</style>

View file

@ -0,0 +1,33 @@
import { createComponentRenderer } from '@/__tests__/render';
import CanvasExecuteWorkflowButton from './CanvasExecuteWorkflowButton.vue';
const renderComponent = createComponentRenderer(CanvasExecuteWorkflowButton);
describe('CanvasExecuteWorkflowButton', () => {
it('should render correctly', () => {
const wrapper = renderComponent();
expect(wrapper.html()).toMatchSnapshot();
});
it('should render different label when executing', () => {
const wrapper = renderComponent({
props: {
executing: true,
},
});
expect(wrapper.getAllByText('Executing workflow')).toHaveLength(2);
});
it('should render different label when executing and waiting for webhook', () => {
const wrapper = renderComponent({
props: {
executing: true,
waitingForWebhook: true,
},
});
expect(wrapper.getAllByText('Waiting for trigger event')).toHaveLength(2);
});
});

View file

@ -2,31 +2,36 @@
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { useUIStore } from '@/stores/ui.store';
defineEmits<{
click: [event: MouseEvent];
}>();
const uiStore = useUIStore();
const locale = useI18n();
const props = defineProps<{
waitingForWebhook: boolean;
executing: boolean;
}>();
const workflowRunning = computed(() => uiStore.isActionActive['workflowRunning']);
const i18n = useI18n();
const runButtonText = computed(() => {
if (!workflowRunning.value) {
return locale.baseText('nodeView.runButtonText.executeWorkflow');
const label = computed(() => {
if (!props.executing) {
return i18n.baseText('nodeView.runButtonText.executeWorkflow');
}
return locale.baseText('nodeView.runButtonText.executingWorkflow');
if (props.waitingForWebhook) {
return i18n.baseText('nodeView.runButtonText.waitingForTriggerEvent');
}
return i18n.baseText('nodeView.runButtonText.executingWorkflow');
});
</script>
<template>
<KeyboardShortcutTooltip :label="runButtonText" :shortcut="{ metaKey: true, keys: ['↵'] }">
<KeyboardShortcutTooltip :label="label" :shortcut="{ metaKey: true, keys: ['↵'] }">
<N8nButton
:loading="workflowRunning"
:label="runButtonText"
:loading="executing"
:label="label"
size="large"
icon="flask"
type="primary"

View file

@ -0,0 +1,22 @@
import { createComponentRenderer } from '@/__tests__/render';
import CanvasStopCurrentExecutionButton from './CanvasStopCurrentExecutionButton.vue';
const renderComponent = createComponentRenderer(CanvasStopCurrentExecutionButton);
describe('CanvasStopCurrentExecutionButton', () => {
it('should render correctly', () => {
const wrapper = renderComponent();
expect(wrapper.html()).toMatchSnapshot();
});
it('should render different title when loading', () => {
const wrapper = renderComponent({
props: {
stopping: true,
},
});
expect(wrapper.getByTitle('Stopping current execution')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,28 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { computed } from 'vue';
const props = defineProps<{
stopping?: boolean;
}>();
const i18n = useI18n();
const title = computed(() =>
props.stopping
? i18n.baseText('nodeView.stoppingCurrentExecution')
: i18n.baseText('nodeView.stopCurrentExecution'),
);
</script>
<template>
<n8n-icon-button
icon="stop"
size="large"
class="stop-execution"
type="secondary"
:title="title"
:loading="stopping"
data-test-id="stop-execution-button"
/>
</template>

View file

@ -0,0 +1,12 @@
import { createComponentRenderer } from '@/__tests__/render';
import CanvasStopWaitingForWebhookButton from './CanvasStopWaitingForWebhookButton.vue';
const renderComponent = createComponentRenderer(CanvasStopWaitingForWebhookButton);
describe('CanvasStopCurrentExecutionButton', () => {
it('should render correctly', () => {
const wrapper = renderComponent();
expect(wrapper.html()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,15 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
const i18n = useI18n();
</script>
<template>
<n8n-icon-button
class="stop-execution"
icon="stop"
size="large"
:title="i18n.baseText('nodeView.stopWaitingForWebhookCall')"
type="secondary"
data-test-id="stop-execution-waiting-for-webhook-button"
/>
</template>

View file

@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasExecuteWorkflowButton > should render correctly 1`] = `
"<button class="button button primary large withIcon el-tooltip__trigger el-tooltip__trigger" aria-live="polite" data-test-id="execute-workflow-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-flask fa-w-14 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="flask" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path class="" fill="currentColor" d="M437.2 403.5L320 215V64h8c13.3 0 24-10.7 24-24V24c0-13.3-10.7-24-24-24H120c-13.3 0-24 10.7-24 24v16c0 13.3 10.7 24 24 24h8v151L10.8 403.5C-18.5 450.6 15.3 512 70.9 512h306.2c55.7 0 89.4-61.5 60.1-108.5zM137.9 320l48.2-77.6c3.7-5.2 5.8-11.6 5.8-18.4V64h64v160c0 6.9 2.2 13.2 5.8 18.4l48.2 77.6h-172z"></path></svg></span></span><span>Test workflow</span></button>
<!--teleport start-->
<!--teleport end-->"
`;

View file

@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasStopCurrentExecutionButton > should render correctly 1`] = `
"<button class="button button secondary large withIcon square stop-execution stop-execution" aria-live="polite" title="Stop current execution" data-test-id="stop-execution-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-stop fa-w-14 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="stop" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path class="" fill="currentColor" d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48z"></path></svg></span></span>
<!--v-if-->
</button>"
`;

View file

@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasStopCurrentExecutionButton > should render correctly 1`] = `
"<button class="button button secondary large withIcon square stop-execution stop-execution" aria-live="polite" title="Stop waiting for webhook call" data-test-id="stop-execution-waiting-for-webhook-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-stop fa-w-14 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="stop" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path class="" fill="currentColor" d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48z"></path></svg></span></span>
<!--v-if-->
</button>"
`;

View file

@ -1,21 +1,25 @@
import { fireEvent } from '@testing-library/vue';
import CanvasEdge from './CanvasEdge.vue';
import CanvasEdge, { type CanvasEdgeProps } from './CanvasEdge.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { Position } from '@vue-flow/core';
const renderComponent = createComponentRenderer(CanvasEdge, {
props: {
sourceX: 0,
sourceY: 0,
sourcePosition: 'top',
targetX: 100,
targetY: 100,
targetPosition: 'bottom',
data: {
status: undefined,
},
const DEFAULT_PROPS = {
sourceX: 0,
sourceY: 0,
sourcePosition: Position.Top,
targetX: 100,
targetY: 100,
targetPosition: Position.Bottom,
data: {
status: undefined,
source: { index: 0, type: 'main' },
target: { index: 0, type: 'main' },
},
} satisfies Partial<CanvasEdgeProps>;
const renderComponent = createComponentRenderer(CanvasEdge, {
props: DEFAULT_PROPS,
});
beforeEach(() => {
@ -42,4 +46,28 @@ describe('CanvasEdge', () => {
stroke: 'var(--color-foreground-xdark)',
});
});
it('should correctly style a running connection', () => {
const { container } = renderComponent({
props: { ...DEFAULT_PROPS, data: { ...DEFAULT_PROPS.data, status: 'running' } },
});
const edge = container.querySelector('.vue-flow__edge-path');
expect(edge).toHaveStyle({
stroke: 'var(--color-primary)',
});
});
it('should correctly style a pinned connection', () => {
const { container } = renderComponent({
props: { ...DEFAULT_PROPS, data: { ...DEFAULT_PROPS.data, status: 'pinned' } },
});
const edge = container.querySelector('.vue-flow__edge-path');
expect(edge).toHaveStyle({
stroke: 'var(--color-secondary)',
});
});
});

View file

@ -4,16 +4,17 @@ import type { Connection, EdgeProps } from '@vue-flow/core';
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from '@vue-flow/core';
import CanvasEdgeToolbar from './CanvasEdgeToolbar.vue';
import { computed, useCssModule } from 'vue';
import type { CanvasConnectionData } from '@/types';
const emit = defineEmits<{
delete: [connection: Connection];
}>();
const props = defineProps<
EdgeProps & {
hovered?: boolean;
}
>();
export type CanvasEdgeProps = EdgeProps<CanvasConnectionData> & {
hovered?: boolean;
};
const props = defineProps<CanvasEdgeProps>();
const $style = useCssModule();
@ -27,6 +28,8 @@ const statusColor = computed(() => {
return 'var(--color-success)';
} else if (status.value === 'pinned') {
return 'var(--color-secondary)';
} else if (status.value === 'running') {
return 'var(--color-primary)';
} else {
return 'var(--color-foreground-xdark)';
}

View file

@ -53,11 +53,14 @@ describe('CanvasNode', () => {
...createCanvasNodeProps({
data: {
inputs: [
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 0 },
],
outputs: [
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 0 },
],
outputs: [{ type: NodeConnectionType.Main }, { type: NodeConnectionType.Main }],
},
}),
},

View file

@ -17,6 +17,7 @@ import type { NodeProps } from '@vue-flow/core';
const emit = defineEmits<{
delete: [id: string];
run: [id: string];
select: [id: string, selected: boolean];
toggle: [id: string];
activate: [id: string];
@ -110,6 +111,10 @@ function onDelete() {
emit('delete', props.id);
}
function onRun() {
emit('run', props.id);
}
function onDisabledToggle() {
emit('toggle', props.id);
}
@ -151,6 +156,7 @@ function onActivate() {
:class="$style.canvasNodeToolbar"
@delete="onDelete"
@toggle="onDisabledToggle"
@run="onRun"
/>
<CanvasNodeRenderer v-if="nodeType" @dblclick="onActivate">
@ -164,19 +170,21 @@ function onActivate() {
.canvasNode {
&:hover {
.canvasNodeToolbar {
display: flex;
opacity: 1;
}
}
}
.canvasNodeToolbar {
display: none;
transition: opacity 0.1s ease-in;
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.canvasNodeToolbar:focus-within {
opacity: 1;
}
</style>

View file

@ -21,7 +21,7 @@ describe('CanvasNodeRenderer', () => {
},
});
expect(getByTestId('canvas-node-default')).toBeInTheDocument();
expect(getByTestId('canvas-default-node')).toBeInTheDocument();
});
it('should render configuration node correctly', async () => {
@ -30,14 +30,17 @@ describe('CanvasNodeRenderer', () => {
provide: {
...createCanvasNodeProvide({
data: {
renderType: 'configuration',
render: {
type: 'default',
options: { configuration: true },
},
},
}),
},
},
});
expect(getByTestId('canvas-node-configuration')).toBeInTheDocument();
expect(getByTestId('canvas-configuration-node')).toBeInTheDocument();
});
it('should render configurable node correctly', async () => {
@ -46,13 +49,16 @@ describe('CanvasNodeRenderer', () => {
provide: {
...createCanvasNodeProvide({
data: {
renderType: 'configurable',
render: {
type: 'default',
options: { configurable: true },
},
},
}),
},
},
});
expect(getByTestId('canvas-node-configurable')).toBeInTheDocument();
expect(getByTestId('canvas-configurable-node')).toBeInTheDocument();
});
});

View file

@ -1,8 +1,6 @@
<script lang="ts" setup>
import { h, inject } from 'vue';
import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue';
import CanvasNodeConfiguration from '@/components/canvas/elements/nodes/render-types/CanvasNodeConfiguration.vue';
import CanvasNodeConfigurable from '@/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.vue';
import { CanvasNodeKey } from '@/constants';
const node = inject(CanvasNodeKey);
@ -13,19 +11,8 @@ const slots = defineSlots<{
const Render = () => {
let Component;
switch (node?.data.value.renderType) {
case 'configurable':
Component = CanvasNodeConfigurable;
break;
case 'configuration':
Component = CanvasNodeConfiguration;
break;
case 'trigger':
Component = CanvasNodeDefault;
break;
switch (node?.data.value.render.type) {
// @TODO Add support for sticky notes here
default:
Component = CanvasNodeDefault;
}

View file

@ -24,7 +24,10 @@ describe('CanvasNodeToolbar', () => {
provide: {
...createCanvasNodeProvide({
data: {
renderType: 'configuration',
render: {
type: 'default',
options: { configuration: true },
},
},
}),
},

View file

@ -1,18 +1,18 @@
<script setup lang="ts">
import { computed, inject, useCssModule } from 'vue';
import { CanvasNodeKey } from '@/constants';
import { useCssModule } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { useCanvasNode } from '@/composables/useCanvasNode';
const emit = defineEmits<{
delete: [];
toggle: [];
run: [];
}>();
const $style = useCssModule();
const i18n = useI18n();
const node = inject(CanvasNodeKey);
const data = computed(() => node?.data.value);
const { renderOptions } = useCanvasNode();
// @TODO
const workflowRunning = false;
@ -20,8 +20,9 @@ const workflowRunning = false;
// @TODO
const nodeDisabledTitle = 'Test';
// @TODO
function executeNode() {}
function executeNode() {
emit('run');
}
function onToggleNode() {
emit('toggle');
@ -39,7 +40,7 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
<div :class="$style.canvasNodeToolbar">
<div :class="$style.canvasNodeToolbarItems">
<N8nIconButton
v-if="data?.renderType !== 'configuration'"
v-if="!renderOptions.configuration"
data-test-id="execute-node-button"
type="tertiary"
text
@ -81,12 +82,17 @@ function openContextMenu(_e: MouseEvent, _type: string) {}
<style lang="scss" module>
.canvasNodeToolbar {
padding-bottom: var(--spacing-3xs);
padding-bottom: var(--spacing-2xs);
}
.canvasNodeToolbarItems {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-canvas-background);
:global(.button) {
--button-font-color: var(--color-text-light);
}
}
</style>

View file

@ -1,107 +0,0 @@
import CanvasNodeConfigurable from '@/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { NodeConnectionType } from 'n8n-workflow';
import { createCanvasNodeProvide } from '@/__tests__/data';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
const renderComponent = createComponentRenderer(CanvasNodeConfigurable);
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
});
describe('CanvasNodeConfigurable', () => {
it('should render node correctly', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node')).toBeInTheDocument();
});
describe('selected', () => {
it('should apply selected class when node is selected', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
selected: true,
}),
},
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('selected');
});
it('should not apply selected class when node is not selected', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node').closest('.node')).not.toHaveClass('selected');
});
});
describe('disabled', () => {
it('should apply disabled class when node is disabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
disabled: true,
},
}),
},
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('disabled');
expect(getByText('(Deactivated)')).toBeVisible();
});
it('should not apply disabled class when node is enabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node').closest('.node')).not.toHaveClass('disabled');
});
});
describe('inputs', () => {
it('should adjust width css variable based on the number of non-main inputs', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
inputs: [
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.AiTool },
{ type: NodeConnectionType.AiDocument, required: true },
{ type: NodeConnectionType.AiMemory, required: true },
],
},
}),
},
},
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--configurable-node-input-count': '3' });
});
});
});

View file

@ -1,134 +0,0 @@
<script lang="ts" setup>
import { computed, useCssModule } from 'vue';
import { NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
import { useNodeConnections } from '@/composables/useNodeConnections';
import { useI18n } from '@/composables/useI18n';
import CanvasNodeDisabledStrikeThrough from './parts/CanvasNodeDisabledStrikeThrough.vue';
import CanvasNodeStatusIcons from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue';
import { useCanvasNode } from '@/composables/useCanvasNode';
const $style = useCssModule();
const i18n = useI18n();
const {
label,
inputs,
outputs,
connections,
isDisabled,
isSelected,
hasPinnedData,
hasRunData,
hasIssues,
} = useCanvasNode();
const { nonMainInputs, requiredNonMainInputs } = useNodeConnections({
inputs,
outputs,
connections,
});
const classes = computed(() => {
return {
[$style.node]: true,
[$style.selected]: isSelected.value,
[$style.disabled]: isDisabled.value,
[$style.success]: hasRunData.value,
[$style.error]: hasIssues.value,
[$style.pinned]: hasPinnedData.value,
};
});
const styles = computed(() => {
const stylesObject: {
[key: string]: string | number;
} = {};
if (requiredNonMainInputs.value.length > 0) {
let spacerCount = 0;
if (NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS) {
const requiredNonMainInputsCount = requiredNonMainInputs.value.length;
const optionalNonMainInputsCount = nonMainInputs.value.length - requiredNonMainInputsCount;
spacerCount = requiredNonMainInputsCount > 0 && optionalNonMainInputsCount > 0 ? 1 : 0;
}
stylesObject['--configurable-node-input-count'] = nonMainInputs.value.length + spacerCount;
}
return stylesObject;
});
</script>
<template>
<div :class="classes" :style="styles" data-test-id="canvas-node-configurable">
<slot />
<CanvasNodeStatusIcons :class="$style.statusIcons" />
<CanvasNodeDisabledStrikeThrough v-if="isDisabled" />
<div :class="$style.label">
{{ label }}
<div v-if="isDisabled">({{ i18n.baseText('node.disabled') }})</div>
</div>
</div>
</template>
<style lang="scss" module>
.node {
--configurable-node-min-input-count: 4;
--configurable-node-input-width: 65px;
--canvas-node--height: 100px;
--canvas-node--width: calc(
max(var(--configurable-node-input-count, 5), var(--configurable-node-min-input-count)) *
var(--configurable-node-input-width)
);
width: var(--canvas-node--width);
height: var(--canvas-node--height);
display: flex;
align-items: center;
justify-content: center;
background: var(--canvas-node--background, var(--color-node-background));
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-xdark));
border-radius: var(--border-radius-large);
&.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
/**
* State classes
* The reverse order defines the priority in case multiple states are active
*/
&.error {
border-color: var(--color-canvas-node-error-border-color, var(--color-danger));
}
&.success {
border-color: var(--color-canvas-node-success-border-color, var(--color-success));
}
&.pinned {
border-color: var(--color-canvas-node-pinned-border, var(--color-node-pinned-border));
}
&.disabled {
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
}
}
.label {
top: 100%;
font-size: var(--font-size-m);
text-align: center;
margin-left: var(--spacing-s);
max-width: calc(
var(--node-width) - var(--configurable-node-icon-offset) - var(--configurable-node-icon-size) -
2 * var(--spacing-s)
);
}
.statusIcons {
position: absolute;
top: calc(var(--canvas-node--height) - 24px);
right: var(--spacing-xs);
}
</style>

View file

@ -1,80 +0,0 @@
import CanvasNodeConfiguration from '@/components/canvas/elements/nodes/render-types/CanvasNodeConfiguration.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
const renderComponent = createComponentRenderer(CanvasNodeConfiguration);
beforeEach(() => {
const pinia = createTestingPinia();
setActivePinia(pinia);
});
describe('CanvasNodeConfiguration', () => {
it('should render node correctly', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node')).toBeInTheDocument();
});
describe('selected', () => {
it('should apply selected class when node is selected', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({ selected: true }),
},
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('selected');
});
it('should not apply selected class when node is not selected', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node').closest('.node')).not.toHaveClass('selected');
});
});
describe('disabled', () => {
it('should apply disabled class when node is disabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
disabled: true,
},
}),
},
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('disabled');
expect(getByText('(Deactivated)')).toBeVisible();
});
it('should not apply disabled class when node is enabled', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
},
},
});
expect(getByText('Test Node').closest('.node')).not.toHaveClass('disabled');
});
});
});

View file

@ -1,79 +0,0 @@
<script lang="ts" setup>
import { computed, useCssModule } from 'vue';
import { useI18n } from '@/composables/useI18n';
import CanvasNodeStatusIcons from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue';
import { useCanvasNode } from '@/composables/useCanvasNode';
const $style = useCssModule();
const i18n = useI18n();
const { label, isDisabled, isSelected, hasIssues } = useCanvasNode();
const classes = computed(() => {
return {
[$style.node]: true,
[$style.selected]: isSelected.value,
[$style.disabled]: isDisabled.value,
[$style.error]: hasIssues.value,
};
});
</script>
<template>
<div :class="classes" data-test-id="canvas-node-configuration">
<slot />
<CanvasNodeStatusIcons :class="$style.statusIcons" />
<div v-if="label" :class="$style.label">
{{ label }}
<div v-if="isDisabled">({{ i18n.baseText('node.disabled') }})</div>
</div>
</div>
</template>
<style lang="scss" module>
.node {
--canvas-node--width: 75px;
--canvas-node--height: 75px;
width: var(--canvas-node--width);
height: var(--canvas-node--height);
display: flex;
align-items: center;
justify-content: center;
background: var(--canvas-node--background, var(--node-type-supplemental-background));
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-dark));
border-radius: 50%;
/**
* State classes
* The reverse order defines the priority in case multiple states are active
*/
&.selected {
box-shadow: 0 0 0 4px var(--color-canvas-selected);
}
&.error {
border-color: var(--color-canvas-node-error-border-color, var(--color-danger));
}
&.disabled {
border-color: var(--color-canvas-node-disabled-border, var(--color-foreground-base));
}
}
.label {
top: 100%;
position: absolute;
font-size: var(--font-size-m);
text-align: center;
width: 100%;
min-width: 200px;
margin-top: var(--spacing-2xs);
}
.statusIcons {
position: absolute;
top: calc(var(--canvas-node--height) - 24px);
}
</style>

View file

@ -14,7 +14,7 @@ beforeEach(() => {
describe('CanvasNodeDefault', () => {
it('should render node correctly', () => {
const { getByText } = renderComponent({
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide(),
@ -22,7 +22,7 @@ describe('CanvasNodeDefault', () => {
},
});
expect(getByText('Test Node')).toBeInTheDocument();
expect(getByTestId('canvas-default-node')).toMatchSnapshot();
});
describe('outputs', () => {
@ -32,7 +32,7 @@ describe('CanvasNodeDefault', () => {
provide: {
...createCanvasNodeProvide({
data: {
outputs: [{ type: NodeConnectionType.Main }],
outputs: [{ type: NodeConnectionType.Main, index: 0 }],
},
}),
},
@ -40,7 +40,7 @@ describe('CanvasNodeDefault', () => {
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--node-main-output-count': '1' }); // height calculation based on the number of outputs
expect(nodeElement).toHaveStyle({ '--canvas-node--main-output-count': '1' }); // height calculation based on the number of outputs
});
it('should adjust height css variable based on the number of outputs (multiple outputs)', () => {
@ -50,9 +50,9 @@ describe('CanvasNodeDefault', () => {
...createCanvasNodeProvide({
data: {
outputs: [
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.Main },
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.Main, index: 0 },
],
},
}),
@ -61,7 +61,7 @@ describe('CanvasNodeDefault', () => {
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--node-main-output-count': '3' }); // height calculation based on the number of outputs
expect(nodeElement).toHaveStyle({ '--canvas-node--main-output-count': '3' }); // height calculation based on the number of outputs
});
});
@ -118,4 +118,108 @@ describe('CanvasNodeDefault', () => {
expect(getByText('Test Node').closest('.node')).not.toHaveClass('disabled');
});
});
describe('running', () => {
it('should apply running class when node is running', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({ data: { execution: { running: true } } }),
},
},
});
expect(getByText('Test Node').closest('.node')).toHaveClass('running');
});
});
describe('configurable', () => {
it('should render configurable node correctly', () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
render: {
type: 'default',
options: { configurable: true },
},
},
}),
},
},
});
expect(getByTestId('canvas-configurable-node')).toMatchSnapshot();
});
describe('inputs', () => {
it('should adjust width css variable based on the number of non-main inputs', () => {
const { getByText } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
inputs: [
{ type: NodeConnectionType.Main, index: 0 },
{ type: NodeConnectionType.AiTool, index: 0 },
{ type: NodeConnectionType.AiDocument, index: 0, required: true },
{ type: NodeConnectionType.AiMemory, index: 0, required: true },
],
render: {
type: 'default',
options: {
configurable: true,
},
},
},
}),
},
},
});
const nodeElement = getByText('Test Node').closest('.node');
expect(nodeElement).toHaveStyle({ '--configurable-node--input-count': '3' });
});
});
});
describe('configuration', () => {
it('should render configuration node correctly', () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
render: {
type: 'default',
options: { configuration: true },
},
},
}),
},
},
});
expect(getByTestId('canvas-configuration-node')).toMatchSnapshot();
});
it('should render configurable configuration node correctly', () => {
const { getByTestId } = renderComponent({
global: {
provide: {
...createCanvasNodeProvide({
data: {
render: {
type: 'default',
options: { configurable: true, configuration: true },
},
},
}),
},
},
});
expect(getByTestId('canvas-configurable-node')).toMatchSnapshot();
});
});
});

View file

@ -5,6 +5,7 @@ import { useI18n } from '@/composables/useI18n';
import CanvasNodeDisabledStrikeThrough from './parts/CanvasNodeDisabledStrikeThrough.vue';
import CanvasNodeStatusIcons from '@/components/canvas/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue';
import { useCanvasNode } from '@/composables/useCanvasNode';
import { NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
const $style = useCssModule();
const i18n = useI18n();
@ -17,10 +18,12 @@ const {
isDisabled,
isSelected,
hasPinnedData,
executionRunning,
hasRunData,
hasIssues,
renderOptions,
} = useCanvasNode();
const { mainOutputs } = useNodeConnections({
const { mainOutputs, nonMainInputs, requiredNonMainInputs } = useNodeConnections({
inputs,
outputs,
connections,
@ -34,18 +37,48 @@ const classes = computed(() => {
[$style.success]: hasRunData.value,
[$style.error]: hasIssues.value,
[$style.pinned]: hasPinnedData.value,
[$style.running]: executionRunning.value,
[$style.configurable]: renderOptions.value.configurable,
[$style.configuration]: renderOptions.value.configuration,
[$style.trigger]: renderOptions.value.trigger,
};
});
const styles = computed(() => {
return {
'--node-main-output-count': mainOutputs.value.length,
};
const stylesObject: Record<string, string | number> = {};
if (renderOptions.value.configurable && requiredNonMainInputs.value.length > 0) {
let spacerCount = 0;
if (NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS) {
const requiredNonMainInputsCount = requiredNonMainInputs.value.length;
const optionalNonMainInputsCount = nonMainInputs.value.length - requiredNonMainInputsCount;
spacerCount = requiredNonMainInputsCount > 0 && optionalNonMainInputsCount > 0 ? 1 : 0;
}
stylesObject['--configurable-node--input-count'] = nonMainInputs.value.length + spacerCount;
}
stylesObject['--canvas-node--main-output-count'] = mainOutputs.value.length;
return stylesObject;
});
const dataTestId = computed(() => {
let type = 'default';
if (renderOptions.value.configurable) {
type = 'configurable';
} else if (renderOptions.value.configuration) {
type = 'configuration';
} else if (renderOptions.value.trigger) {
type = 'trigger';
}
return `canvas-${type}-node`;
});
</script>
<template>
<div :class="classes" :style="styles" data-test-id="canvas-node-default">
<div :class="classes" :style="styles" :data-test-id="dataTestId">
<slot />
<CanvasNodeStatusIcons :class="$style.statusIcons" />
<CanvasNodeDisabledStrikeThrough v-if="isDisabled" />
@ -58,8 +91,12 @@ const styles = computed(() => {
<style lang="scss" module>
.node {
--canvas-node--height: calc(100px + max(0, var(--node-main-output-count, 1) - 4) * 50px);
--canvas-node--height: calc(100px + max(0, var(--canvas-node--main-output-count, 1) - 4) * 50px);
--canvas-node--width: 100px;
--configurable-node--min-input-count: 4;
--configurable-node--input-width: 65px;
--configurable-node--icon-offset: 40px;
--configurable-node--icon-size: 30px;
height: var(--canvas-node--height);
width: var(--canvas-node--width);
@ -70,6 +107,44 @@ const styles = computed(() => {
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-xdark));
border-radius: var(--border-radius-large);
/**
* Node types
*/
&.configuration {
--canvas-node--width: 75px;
--canvas-node--height: 75px;
background: var(--canvas-node--background, var(--node-type-supplemental-background));
border: 2px solid var(--canvas-node--border-color, var(--color-foreground-dark));
border-radius: 50px;
.statusIcons {
right: unset;
}
}
&.configurable {
--canvas-node--height: 100px;
--canvas-node--width: calc(
max(var(--configurable-node--input-count, 5), var(--configurable-node--min-input-count)) *
var(--configurable-node--input-width)
);
.label {
top: unset;
position: relative;
margin-left: var(--spacing-s);
width: auto;
min-width: unset;
max-width: calc(
var(--canvas-node--width) - var(--configurable-node--icon-offset) - var(
--configurable-node--icon-size
) - 2 * var(--spacing-s)
);
}
}
/**
* State classes
* The reverse order defines the priority in case multiple states are active
@ -94,6 +169,11 @@ const styles = computed(() => {
&.disabled {
border-color: var(--color-canvas-node-disabled-border-color, var(--color-foreground-base));
}
&.running {
background-color: var(--color-node-executing-background);
border-color: var(--color-canvas-node-running-border-color, var(--color-node-running-border));
}
}
.label {
@ -108,7 +188,7 @@ const styles = computed(() => {
.statusIcons {
position: absolute;
top: calc(var(--canvas-node--height) - 24px);
right: var(--spacing-xs);
bottom: var(--spacing-2xs);
right: var(--spacing-2xs);
}
</style>

View file

@ -0,0 +1,77 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasNodeDefault > configurable > should render configurable node correctly 1`] = `
<div
class="node configurable"
data-test-id="canvas-configurable-node"
style="--canvas-node--main-output-count: 0;"
>
<!--v-if-->
<!--v-if-->
<div
class="label"
>
Test Node
<!--v-if-->
</div>
</div>
`;
exports[`CanvasNodeDefault > configuration > should render configurable configuration node correctly 1`] = `
<div
class="node configurable configuration"
data-test-id="canvas-configurable-node"
style="--canvas-node--main-output-count: 0;"
>
<!--v-if-->
<!--v-if-->
<div
class="label"
>
Test Node
<!--v-if-->
</div>
</div>
`;
exports[`CanvasNodeDefault > configuration > should render configuration node correctly 1`] = `
<div
class="node configuration"
data-test-id="canvas-configuration-node"
style="--canvas-node--main-output-count: 0;"
>
<!--v-if-->
<!--v-if-->
<div
class="label"
>
Test Node
<!--v-if-->
</div>
</div>
`;
exports[`CanvasNodeDefault > should render node correctly 1`] = `
<div
class="node"
data-test-id="canvas-default-node"
style="--canvas-node--main-output-count: 0;"
>
<!--v-if-->
<!--v-if-->
<div
class="label"
>
Test Node
<!--v-if-->
</div>
</div>
`;

View file

@ -0,0 +1,40 @@
import CanvasNodeStatusIcons from './CanvasNodeStatusIcons.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide } from '@/__tests__/data';
import { createTestingPinia } from '@pinia/testing';
const renderComponent = createComponentRenderer(CanvasNodeStatusIcons, {
pinia: createTestingPinia(),
});
describe('CanvasNodeStatusIcons', () => {
it('should render correctly for a pinned node', () => {
const { getByTestId } = renderComponent({
global: {
provide: createCanvasNodeProvide({ data: { pinnedData: { count: 5, visible: true } } }),
},
});
expect(getByTestId('canvas-node-status-pinned')).toHaveTextContent('5');
});
it('should render correctly for a running node', () => {
const { getByTestId } = renderComponent({
global: {
provide: createCanvasNodeProvide({ data: { execution: { running: true } } }),
},
});
expect(getByTestId('canvas-node-status-running')).toBeInTheDocument();
});
it('should render correctly for a node that ran successfully', () => {
const { getByTestId } = renderComponent({
global: {
provide: createCanvasNodeProvide({ data: { runData: { count: 15, visible: true } } }),
},
});
expect(getByTestId('canvas-node-status-success')).toHaveTextContent('15');
});
});

View file

@ -13,6 +13,7 @@ const {
hasIssues,
executionStatus,
executionWaiting,
executionRunning,
hasRunData,
runDataCount,
} = useCanvasNode();
@ -21,46 +22,62 @@ const hideNodeIssues = computed(() => false); // @TODO Implement this
</script>
<template>
<div :class="$style.canvasNodeStatusIcons">
<div v-if="hasIssues && !hideNodeIssues" :class="$style.issues" data-test-id="node-issues">
<N8nTooltip :show-after="500" placement="bottom">
<template #content>
<TitledList :title="`${$locale.baseText('node.issues')}:`" :items="issues" />
</template>
<FontAwesomeIcon icon="exclamation-triangle" />
</N8nTooltip>
</div>
<div v-else-if="executionWaiting || executionStatus === 'waiting'" class="waiting">
<N8nTooltip placement="bottom">
<template #content>
<div v-text="executionWaiting"></div>
</template>
<FontAwesomeIcon icon="clock" />
</N8nTooltip>
</div>
<span
v-else-if="hasPinnedData && !nodeHelpers.isProductionExecutionPreview.value"
:class="$style.pinnedData"
>
<FontAwesomeIcon icon="thumbtack" />
<span v-if="pinnedDataCount > 1" class="items-count"> {{ pinnedDataCount }}</span>
</span>
<span v-else-if="executionStatus === 'unknown'">
<!-- Do nothing, unknown means the node never executed -->
</span>
<span v-else-if="hasRunData" :class="$style.runData">
<FontAwesomeIcon icon="check" />
<span v-if="runDataCount > 1" :class="$style.itemsCount"> {{ runDataCount }}</span>
</span>
<div
v-if="hasIssues && !hideNodeIssues"
:class="[$style.status, $style.issues]"
data-test-id="node-issues"
>
<N8nTooltip :show-after="500" placement="bottom">
<template #content>
<TitledList :title="`${$locale.baseText('node.issues')}:`" :items="issues" />
</template>
<FontAwesomeIcon icon="exclamation-triangle" />
</N8nTooltip>
</div>
<div
v-else-if="executionWaiting || executionStatus === 'waiting'"
:class="[$style.status, $style.waiting]"
>
<N8nTooltip placement="bottom">
<template #content>
<div v-text="executionWaiting"></div>
</template>
<FontAwesomeIcon icon="clock" />
</N8nTooltip>
</div>
<div
v-else-if="hasPinnedData && !nodeHelpers.isProductionExecutionPreview.value"
data-test-id="canvas-node-status-pinned"
:class="[$style.status, $style.pinnedData]"
>
<FontAwesomeIcon icon="thumbtack" />
<span v-if="pinnedDataCount > 1" :class="$style.count"> {{ pinnedDataCount }}</span>
</div>
<div v-else-if="executionStatus === 'unknown'">
<!-- Do nothing, unknown means the node never executed -->
</div>
<div
v-else-if="executionRunning || executionStatus === 'running'"
data-test-id="canvas-node-status-running"
:class="[$style.status, $style.running]"
>
<FontAwesomeIcon icon="sync-alt" spin />
</div>
<div
v-else-if="hasRunData"
data-test-id="canvas-node-status-success"
:class="[$style.status, $style.runData]"
>
<FontAwesomeIcon icon="check" />
<span v-if="runDataCount > 1" :class="$style.count"> {{ runDataCount }}</span>
</div>
</template>
<style lang="scss" module>
.canvasNodeStatusIcons {
.status {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-5xs);
}
.runData {
@ -72,12 +89,16 @@ const hideNodeIssues = computed(() => false); // @TODO Implement this
color: var(--color-secondary);
}
.running {
color: var(--color-primary);
}
.issues {
color: var(--color-danger);
cursor: default;
}
.itemsCount {
.count {
font-size: var(--font-size-s);
}
</style>

View file

@ -1,6 +1,6 @@
import { createPinia, setActivePinia } from 'pinia';
import type { Connection } from '@vue-flow/core';
import type { IConnection } from 'n8n-workflow';
import type { IConnection, Workflow } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import type { CanvasElement } from '@/types';
@ -65,7 +65,7 @@ describe('useCanvasOperations', () => {
usedCredentials: [],
});
workflowsStore.workflowsById[workflowId] = workflow;
await workflowHelpers.initState(workflow, true);
await workflowHelpers.initState(workflow);
canvasOperations = useCanvasOperations({ router, lastClickPosition });
});
@ -248,8 +248,8 @@ describe('useCanvasOperations', () => {
it('should add nodes at current position when position is not specified', async () => {
const nodeTypeName = 'type';
const nodes = [
mockNode({ name: 'Node 1', type: nodeTypeName, position: [40, 40] }),
mockNode({ name: 'Node 2', type: nodeTypeName, position: [100, 240] }),
mockNode({ name: 'Node 1', type: nodeTypeName, position: [120, 120] }),
mockNode({ name: 'Node 2', type: nodeTypeName, position: [180, 320] }),
];
const workflowStoreAddNodeSpy = vi.spyOn(workflowsStore, 'addNode');
@ -292,9 +292,16 @@ describe('useCanvasOperations', () => {
}),
]);
canvasOperations.editableWorkflowObject.value.getParentNodesByDepth = vi
.fn()
.mockReturnValue(nodes.map((node) => node.name));
vi.spyOn(workflowsStore, 'getCurrentWorkflow').mockImplementation(() =>
mock<Workflow>({
getParentNodesByDepth: () =>
nodes.map((node) => ({
name: node.name,
depth: 0,
indicies: [],
})),
}),
);
await canvasOperations.addNodes(nodes, {});

View file

@ -15,6 +15,7 @@ import {
} from '@/__tests__/mocks';
import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE } from '@/constants';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '../stores/workflows.store';
beforeEach(() => {
const pinia = createPinia();
@ -80,6 +81,7 @@ describe('useCanvasMapping', () => {
disabled: false,
execution: {
status: 'new',
running: false,
waiting: undefined,
},
issues: {
@ -112,7 +114,14 @@ describe('useCanvasMapping', () => {
input: {},
output: {},
},
renderType: 'trigger',
render: {
type: 'default',
options: {
configurable: false,
configuration: false,
trigger: true,
},
},
},
},
]);
@ -137,6 +146,27 @@ describe('useCanvasMapping', () => {
expect(elements.value[0]?.data?.disabled).toEqual(true);
});
it('should handle execution state', () => {
const manualTriggerNode = mockNode({
name: 'Manual Trigger',
type: MANUAL_TRIGGER_NODE_TYPE,
disabled: true,
});
const workflow = mock<IWorkflowDb>({
nodes: [manualTriggerNode],
});
const workflowObject = createTestWorkflowObject(workflow);
useWorkflowsStore().addExecutingNode(manualTriggerNode.name);
const { elements } = useCanvasMapping({
workflow: ref(workflow),
workflowObject: ref(workflowObject) as Ref<Workflow>,
});
expect(elements.value[0]?.data?.execution.running).toEqual(true);
});
it('should handle input and output connections', () => {
const [manualTriggerNode, setNode] = mockNodes.slice(0, 2);
const workflow = mock<IWorkflowDb>({
@ -217,6 +247,7 @@ describe('useCanvasMapping', () => {
target: setNode.id,
targetHandle: `inputs/${NodeConnectionType.Main}/0`,
type: 'canvas-edge',
animated: false,
},
]);
});
@ -264,6 +295,7 @@ describe('useCanvasMapping', () => {
target: setNode.id,
targetHandle: `inputs/${NodeConnectionType.AiTool}/0`,
type: 'canvas-edge',
animated: false,
},
{
data: {
@ -285,6 +317,7 @@ describe('useCanvasMapping', () => {
target: setNode.id,
targetHandle: `inputs/${NodeConnectionType.AiDocument}/1`,
type: 'canvas-edge',
animated: false,
},
]);
});

View file

@ -44,21 +44,18 @@ export function useCanvasMapping({
const renderTypeByNodeType = computed(
() =>
workflow.value.nodes.reduce<Record<string, CanvasElementData['renderType']>>((acc, node) => {
let renderType: CanvasElementData['renderType'] = 'default';
switch (true) {
case nodeTypesStore.isTriggerNode(node.type):
renderType = 'trigger';
break;
case nodeTypesStore.isConfigNode(workflowObject.value, node, node.type):
renderType = 'configuration';
break;
case nodeTypesStore.isConfigurableNode(workflowObject.value, node, node.type):
renderType = 'configurable';
break;
}
workflow.value.nodes.reduce<Record<string, CanvasElementData['render']>>((acc, node) => {
// @TODO Add support for sticky notes here
acc[node.type] = {
type: 'default',
options: {
trigger: nodeTypesStore.isTriggerNode(node.type),
configuration: nodeTypesStore.isConfigNode(workflowObject.value, node, node.type),
configurable: nodeTypesStore.isConfigurableNode(workflowObject.value, node, node.type),
},
};
acc[node.type] = renderType;
return acc;
}, {}) ?? {},
);
@ -110,6 +107,13 @@ export function useCanvasMapping({
}, {}),
);
const nodeExecutionRunningById = computed(() =>
workflow.value.nodes.reduce<Record<string, boolean>>((acc, node) => {
acc[node.id] = workflowsStore.isNodeExecuting(node.name);
return acc;
}, {}),
);
const nodeExecutionStatusById = computed(() =>
workflow.value.nodes.reduce<Record<string, ExecutionStatus>>((acc, node) => {
acc[node.id] =
@ -221,12 +225,13 @@ export function useCanvasMapping({
execution: {
status: nodeExecutionStatusById.value[node.id],
waiting: nodeExecutionWaitingById.value[node.id],
running: nodeExecutionRunningById.value[node.id],
},
runData: {
count: nodeExecutionRunDataById.value[node.id]?.length ?? 0,
visible: !!nodeExecutionRunDataById.value[node.id],
},
renderType: renderTypeByNodeType.value[node.type] ?? 'default',
render: renderTypeByNodeType.value[node.type] ?? { type: 'default', options: {} },
};
return {
@ -255,6 +260,7 @@ export function useCanvasMapping({
data,
type,
label,
animated: data.status === 'running',
};
});
});
@ -266,7 +272,12 @@ export function useCanvasMapping({
let status: CanvasConnectionData['status'];
if (fromNode) {
if (nodePinnedDataById.value[fromNode.id] && nodeExecutionRunDataById.value[fromNode.id]) {
if (nodeExecutionRunningById.value[fromNode.id]) {
status = 'running';
} else if (
nodePinnedDataById.value[fromNode.id] &&
nodeExecutionRunDataById.value[fromNode.id]
) {
status = 'pinned';
} else if (nodeHasIssuesById.value[fromNode.id]) {
status = 'error';

View file

@ -1,5 +1,6 @@
import { useCanvasNode } from '@/composables/useCanvasNode';
import { inject, ref } from 'vue';
import type { CanvasNodeInjectionData } from '../types';
vi.mock('vue', async () => {
const actual = await vi.importActual('vue');
@ -27,38 +28,46 @@ describe('useCanvasNode', () => {
expect(result.hasIssues.value).toBe(false);
expect(result.executionStatus.value).toBeUndefined();
expect(result.executionWaiting.value).toBeUndefined();
expect(result.executionRunning.value).toBe(false);
expect(result.renderOptions.value).toEqual({});
});
it('should return node data when node is provided', () => {
const node = {
data: {
value: {
id: 'node1',
type: 'nodeType1',
typeVersion: 1,
disabled: true,
inputs: ['input1'],
outputs: ['output1'],
connections: { input: { '0': ['node2'] }, output: {} },
issues: { items: ['issue1'], visible: true },
execution: { status: 'running', waiting: false },
runData: { count: 1, visible: true },
pinnedData: { count: 1, visible: true },
renderType: 'default',
data: ref({
id: 'node1',
type: 'nodeType1',
typeVersion: 1,
disabled: true,
inputs: [{ type: 'main', index: 0 }],
outputs: [{ type: 'main', index: 0 }],
connections: { input: { '0': [] }, output: {} },
issues: { items: ['issue1'], visible: true },
execution: { status: 'running', waiting: 'waiting', running: true },
runData: { count: 1, visible: true },
pinnedData: { count: 1, visible: true },
render: {
type: 'default',
options: {
configurable: false,
configuration: false,
trigger: false,
},
},
},
}),
id: ref('1'),
label: ref('Node 1'),
selected: ref(true),
};
} satisfies Partial<CanvasNodeInjectionData>;
vi.mocked(inject).mockReturnValue(node);
const result = useCanvasNode();
expect(result.label.value).toBe('Node 1');
expect(result.inputs.value).toEqual(['input1']);
expect(result.outputs.value).toEqual(['output1']);
expect(result.connections.value).toEqual({ input: { '0': ['node2'] }, output: {} });
expect(result.inputs.value).toEqual([{ type: 'main', index: 0 }]);
expect(result.outputs.value).toEqual([{ type: 'main', index: 0 }]);
expect(result.connections.value).toEqual({ input: { '0': [] }, output: {} });
expect(result.isDisabled.value).toBe(true);
expect(result.isSelected.value).toBe(true);
expect(result.pinnedDataCount.value).toBe(1);
@ -68,6 +77,8 @@ describe('useCanvasNode', () => {
expect(result.issues.value).toEqual(['issue1']);
expect(result.hasIssues.value).toBe(true);
expect(result.executionStatus.value).toBe('running');
expect(result.executionWaiting.value).toBe(false);
expect(result.executionWaiting.value).toBe('waiting');
expect(result.executionRunning.value).toBe(true);
expect(result.renderOptions.value).toBe(node.data.value.render.options);
});
});

View file

@ -21,9 +21,14 @@ export function useCanvasNode() {
connections: { input: {}, output: {} },
issues: { items: [], visible: false },
pinnedData: { count: 0, visible: false },
execution: {},
execution: {
running: false,
},
runData: { count: 0, visible: false },
renderType: 'default',
render: {
type: 'default',
options: {},
},
},
);
@ -45,10 +50,13 @@ export function useCanvasNode() {
const executionStatus = computed(() => data.value.execution.status);
const executionWaiting = computed(() => data.value.execution.waiting);
const executionRunning = computed(() => data.value.execution.running);
const runDataCount = computed(() => data.value.runData.count);
const hasRunData = computed(() => data.value.runData.visible);
const renderOptions = computed(() => data.value.render.options);
return {
node,
label,
@ -65,5 +73,7 @@ export function useCanvasNode() {
hasIssues,
executionStatus,
executionWaiting,
executionRunning,
renderOptions,
};
}

View file

@ -11,13 +11,19 @@ import type {
INodeUpdatePropertiesInformation,
XYPosition,
} from '@/Interface';
import { QUICKSTART_NOTE_NAME, STICKY_NODE_TYPE } from '@/constants';
import {
FORM_TRIGGER_NODE_TYPE,
QUICKSTART_NOTE_NAME,
STICKY_NODE_TYPE,
WEBHOOK_NODE_TYPE,
} from '@/constants';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useHistoryStore } from '@/stores/history.store';
import { useUIStore } from '@/stores/ui.store';
import { useTelemetry } from '@/composables/useTelemetry';
import { useExternalHooks } from '@/composables/useExternalHooks';
import {
AddNodeCommand,
MoveNodeCommand,
RemoveConnectionCommand,
RemoveNodeCommand,
@ -53,10 +59,8 @@ import type { useRouter } from 'vue-router';
import { useCanvasStore } from '@/stores/canvas.store';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
type AddNodeData = {
name?: string;
type AddNodeData = Partial<INodeUi> & {
type: string;
position?: XYPosition;
};
type AddNodeOptions = {
@ -266,13 +270,12 @@ export function useCanvasOperations({
) {
let currentPosition = position;
let lastAddedNode: INodeUi | undefined;
for (const { type, name, position: nodePosition, isAutoAdd, openDetail } of nodes) {
for (const { isAutoAdd, openDetail, ...nodeData } of nodes) {
try {
await createNode(
{
name,
type,
position: nodePosition ?? currentPosition,
...nodeData,
position: nodeData.position ?? currentPosition,
},
{
dragAndDrop,
@ -328,14 +331,16 @@ export function useCanvasOperations({
workflowsStore.addNode(newNodeData);
// @TODO Figure out why this is needed and if we can do better...
// this.matchCredentials(node);
nodeHelpers.matchCredentials(newNodeData);
const lastSelectedNode = uiStore.getLastSelectedNode;
const lastSelectedNodeOutputIndex = uiStore.lastSelectedNodeOutputIndex;
const lastSelectedNodeEndpointUuid = uiStore.lastSelectedNodeEndpointUuid;
historyStore.startRecordingUndo();
if (options.trackHistory) {
historyStore.pushCommandToUndo(new AddNodeCommand(newNodeData));
}
const outputIndex = lastSelectedNodeOutputIndex ?? 0;
const targetEndpoint = lastSelectedNodeEndpointUuid ?? '';
@ -399,12 +404,14 @@ export function useCanvasOperations({
}
const newNodeData: INodeUi = {
id: uuid(),
...node,
id: node.id ?? uuid(),
name: node.name ?? (nodeTypeDescription.defaults.name as string),
type: nodeTypeDescription.name,
typeVersion: nodeVersion,
position: node.position ?? [0, 0],
parameters: {},
disabled: node.disabled ?? false,
parameters: node.parameters ?? {},
};
await loadNodeTypesProperties([{ name: newNodeData.type, version: newNodeData.typeVersion }]);
@ -664,6 +671,14 @@ export function useCanvasOperations({
newNodeData.webhookId = uuid();
}
// if it's a webhook and the path is empty set the UUID as the default path
if (
[WEBHOOK_NODE_TYPE, FORM_TRIGGER_NODE_TYPE].includes(newNodeData.type) &&
newNodeData.parameters.path === ''
) {
newNodeData.parameters.path = newNodeData.webhookId as string;
}
workflowsStore.setNodePristine(newNodeData.name, true);
uiStore.stateIsDirty = true;

View file

@ -1253,6 +1253,7 @@ export function useNodeHelpers() {
updateNodesCredentialsIssues,
getNodeInputData,
setSuccessOutput,
matchCredentials,
isInsertingNodes,
credentialsUpdated,
isProductionExecutionPreview,

View file

@ -279,9 +279,11 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
if (node.name === options.destinationNode || !node.disabled) {
let testUrl = '';
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (nodeType?.webhooks?.length) {
testUrl = workflowHelpers.getWebhookUrl(nodeType.webhooks[0], node, 'test');
if (node.type === FORM_TRIGGER_NODE_TYPE) {
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (nodeType?.webhooks?.length) {
testUrl = workflowHelpers.getWebhookUrl(nodeType.webhooks[0], node, 'test');
}
}
if (
@ -433,10 +435,20 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
}
}
async function stopWaitingForWebhook() {
try {
await workflowsStore.removeTestWebhook(workflowsStore.workflowId);
} catch (error) {
toast.showError(error, i18n.baseText('nodeView.showError.stopWaitingForWebhook.title'));
return;
}
}
return {
consolidateRunDataAndStartNodes,
runWorkflow,
runWorkflowApi,
stopCurrentExecution,
stopWaitingForWebhook,
};
}

View file

@ -1050,12 +1050,8 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
}
}
async function initState(workflowData: IWorkflowDb, set = false): Promise<void> {
async function initState(workflowData: IWorkflowDb): Promise<void> {
workflowsStore.addWorkflow(workflowData);
if (set) {
workflowsStore.setWorkflow(workflowData);
}
workflowsStore.setActive(workflowData.active || false);
workflowsStore.setWorkflowId(workflowData.id);
workflowsStore.setWorkflowName({

View file

@ -58,12 +58,16 @@ export interface CanvasElementData {
execution: {
status?: ExecutionStatus;
waiting?: string;
running: boolean;
};
runData: {
count: number;
visible: boolean;
};
renderType: 'default' | 'trigger' | 'configuration' | 'configurable';
render: {
type: 'default';
options: Record<string, unknown>;
};
}
export type CanvasElement = Node<CanvasElementData>;
@ -72,7 +76,7 @@ export interface CanvasConnectionData {
source: CanvasConnectionPort;
target: CanvasConnectionPort;
fromNodeName?: string;
status?: 'success' | 'error' | 'pinned';
status?: 'success' | 'error' | 'pinned' | 'running';
}
export type CanvasConnection = DefaultEdge<CanvasConnectionData>;
@ -96,3 +100,5 @@ export interface CanvasNodeInjectionData {
export interface CanvasNodeHandleInjectionData {
label: Ref<string | undefined>;
}
export type ConnectStartEvent = { handleId: string; handleType: string; nodeId: string };

View file

@ -27,13 +27,14 @@ import type {
XYPosition,
} from '@/Interface';
import type { Connection } from '@vue-flow/core';
import type { CanvasElement } from '@/types';
import type { CanvasElement, ConnectStartEvent } from '@/types';
import {
CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT,
EnterpriseEditionFeature,
MAIN_HEADER_TABS,
MODAL_CANCEL,
MODAL_CONFIRM,
NODE_CREATOR_OPEN_SOURCES,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
VIEWS,
} from '@/constants';
@ -73,7 +74,11 @@ import { useUsersStore } from '@/stores/users.store';
import { sourceControlEventBus } from '@/event-bus/source-control';
import { useTagsStore } from '@/stores/tags.store';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { useNDVStore } from '@/stores/ndv.store';
import { getNodeViewTab } from '@/utils/canvasUtils';
import { parseCanvasConnectionHandleString } from '@/utils/canvasUtilsV2';
import CanvasStopCurrentExecutionButton from '@/components/canvas/elements/buttons/CanvasStopCurrentExecutionButton.vue';
import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue';
const NodeCreation = defineAsyncComponent(
async () => await import('@/components/Node/NodeCreation.vue'),
@ -115,10 +120,11 @@ const projectsStore = useProjectsStore();
const usersStore = useUsersStore();
const tagsStore = useTagsStore();
const pushConnectionStore = usePushConnectionStore();
const ndvStore = useNDVStore();
const lastClickPosition = ref<XYPosition>([450, 450]);
const { runWorkflow } = useRunWorkflow({ router });
const { runWorkflow, stopCurrentExecution, stopWaitingForWebhook } = useRunWorkflow({ router });
const {
updateNodePosition,
renameNode,
@ -145,7 +151,6 @@ const readOnlyNotification = ref<null | { visible: boolean }>(null);
const isProductionExecutionPreview = ref(false);
const isExecutionPreview = ref(false);
const isExecutionWaitingForWebhook = ref(false);
const canOpenNDV = ref(true);
const hideNodeIssues = ref(false);
@ -318,7 +323,7 @@ async function runAutoAddManualTriggerExperiment() {
}
function resetWorkspace() {
onToggleNodeCreator({ createNodeActive: false });
onOpenNodeCreator({ createNodeActive: false });
nodeCreatorStore.setShowScrim(false);
// Make sure that if there is a waiting test-webhook that it gets removed
@ -346,7 +351,9 @@ async function openWorkflow(data: IWorkflowDb) {
resetWorkspace();
await workflowHelpers.initState(data, true);
await workflowHelpers.initState(data);
await addNodes(data.nodes);
workflowsStore.setConnections(data.connections);
if (data.sharedWithProjects) {
workflowsEEStore.setWorkflowSharedWith({
@ -483,6 +490,19 @@ function onCreateConnection(connection: Connection) {
createConnection(connection);
}
function onCreateConnectionCancelled(event: ConnectStartEvent) {
const { type, index } = parseCanvasConnectionHandleString(event.handleId);
setTimeout(() => {
nodeCreatorStore.openNodeCreatorForConnectingNode({
index,
endpointUuid: event.handleId,
eventSource: NODE_CREATOR_OPEN_SOURCES.NODE_CONNECTION_DROP,
outputType: type,
sourceId: event.nodeId,
});
});
}
function onDeleteConnection(connection: Connection) {
deleteConnection(connection, { trackHistory: true });
}
@ -522,11 +542,11 @@ async function onSwitchActiveNode(nodeName: string) {
setNodeActiveByName(nodeName);
}
async function onOpenConnectionNodeCreator(node: string, connectionType: NodeConnectionType) {
async function onOpenSelectiveNodeCreator(node: string, connectionType: NodeConnectionType) {
nodeCreatorStore.openSelectiveNodeCreator({ node, connectionType });
}
function onToggleNodeCreator(options: ToggleNodeCreatorOptions) {
function onOpenNodeCreator(options: ToggleNodeCreatorOptions) {
nodeCreatorStore.openNodeCreator(options);
}
@ -534,6 +554,18 @@ function onToggleNodeCreator(options: ToggleNodeCreatorOptions) {
* Executions
*/
const isStoppingExecution = ref(false);
const isWorkflowRunning = computed(() => uiStore.isActionActive.workflowRunning);
const isExecutionWaitingForWebhook = computed(() => workflowsStore.executionWaitingForWebhook);
const isStopExecutionButtonVisible = computed(
() => isWorkflowRunning.value && !isExecutionWaitingForWebhook.value,
);
const isStopWaitingForWebhookButtonVisible = computed(
() => isWorkflowRunning.value && isExecutionWaitingForWebhook.value,
);
async function onRunWorkflow() {
trackRunWorkflow();
@ -557,12 +589,42 @@ function trackRunWorkflow() {
});
}
async function onRunWorkflowToNode(id: string) {
const node = workflowsStore.getNodeById(id);
if (!node) return;
trackRunWorkflowToNode(node);
await runWorkflow({ destinationNode: node.name, source: 'Node.executeNode' });
}
function trackRunWorkflowToNode(node: INodeUi) {
const telemetryPayload = {
node_type: node.type,
workflow_id: workflowsStore.workflowId,
source: 'canvas',
push_ref: ndvStore.pushRef,
};
telemetry.track('User clicked execute node button', telemetryPayload);
void externalHooks.run('nodeView.onRunNode', telemetryPayload);
}
async function openExecution(_executionId: string) {
// @TODO
}
async function onStopExecution() {
isStoppingExecution.value = true;
await stopCurrentExecution();
isStoppingExecution.value = false;
}
async function onStopWaitingForWebhook() {
await stopWaitingForWebhook();
}
/**
* Keboard
* Keyboard
*/
function addKeyboardEventBindings() {
@ -909,20 +971,35 @@ onBeforeUnmount(() => {
@update:node:active="onSetNodeActive"
@update:node:selected="onSetNodeSelected"
@update:node:enabled="onToggleNodeDisabled"
@run:node="onRunWorkflowToNode"
@delete:node="onDeleteNode"
@create:connection="onCreateConnection"
@create:connection:cancelled="onCreateConnectionCancelled"
@delete:connection="onDeleteConnection"
@click:pane="onClickPane"
>
<div :class="$style.executionButtons">
<CanvasExecuteWorkflowButton @click="onRunWorkflow" />
<CanvasExecuteWorkflowButton
:waiting-for-webhook="isExecutionWaitingForWebhook"
:executing="isWorkflowRunning"
@click="onRunWorkflow"
/>
<CanvasStopCurrentExecutionButton
v-if="isStopExecutionButtonVisible"
:stopping="isStoppingExecution"
@click="onStopExecution"
/>
<CanvasStopWaitingForWebhookButton
v-if="isStopWaitingForWebhookButtonVisible"
@click="onStopWaitingForWebhook"
/>
</div>
<Suspense>
<NodeCreation
v-if="!isReadOnlyRoute && !isReadOnlyEnvironment"
:create-node-active="uiStore.isCreateNodeActive"
:node-view-scale="1"
@toggle-node-creator="onToggleNodeCreator"
@toggle-node-creator="onOpenNodeCreator"
@add-nodes="onAddNodesAndConnections"
/>
</Suspense>
@ -933,12 +1010,12 @@ onBeforeUnmount(() => {
:is-production-execution-preview="isProductionExecutionPreview"
:renaming="false"
@value-changed="onRenameNode"
@stop-execution="onStopExecution"
@switch-selected-node="onSwitchActiveNode"
@open-connection-node-creator="onOpenConnectionNodeCreator"
@open-connection-node-creator="onOpenSelectiveNodeCreator"
/>
<!--
:renaming="renamingActive"
@stop-execution="stopExecution"
@save-keyboard-shortcut="onSaveKeyboardShortcut"
-->
</Suspense>

View file

@ -1,86 +0,0 @@
# License
Portions of this software are licensed as follows:
- Content of branches other than the main branch (i.e. "master") are not licensed.
- Source code files that contain ".ee." in their filename are NOT licensed under the Sustainable Use License.
To use source code files that contain ".ee." in their filename you must hold a valid n8n Enterprise License
specifically allowing you access to such source code files and as defined in "LICENSE_EE.md".
- All third party components incorporated into the n8n Software are licensed under the original license
provided by the owner of the applicable component.
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
License" as defined below.
## Sustainable Use License
Version 1.0
### Acceptance
By using the software, you agree to all of the terms and conditions below.
### Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
to the limitations below.
### Limitations
You may use or modify the software only for your own internal business purposes or for non-commercial or
personal use. You may distribute the software or provide it to others only if you do so free of charge for
non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
the licensor in the software. Any use of the licensors trademarks is subject to applicable law.
### Patents
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
subject to the limitations and conditions in this license. This license does not cover any patent claims that
you cause to be infringed by modifications or additions to the software. If you or your company make any
written claim that the software infringes or contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If your company makes such a claim, your patent
license ends immediately for work on behalf of your company.
### Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
terms. If you modify the software, you must include in any modified copies of the software a prominent notice
stating that you have modified the software.
### No Other Rights
These terms do not imply any licenses other than those expressly granted in these terms.
### Termination
If you use the software in violation of these terms, such use is not licensed, and your license will
automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
violation of this license no later than 30 days after you receive that notice, your license will be reinstated
retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
terms will cause your license to terminate automatically and permanently.
### No Liability
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
not be liable to you for any damages arising out of these terms or the use or nature of the software, under
any kind of legal claim.
### Definitions
The “licensor” is the entity offering these terms.
The “software” is the software the licensor makes available under these terms, including any portion of it.
“You” refers to the individual or entity agreeing to these terms.
“Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
all organizations that have control over, are under the control of, or are under common control with that
organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
management and policies by vote, contract, or otherwise. Control can be direct or indirect.
“Your license” is the license granted to you for the software under these terms.
“Use” means anything you do with the software requiring your license.
“Trademark” means trademarks, service marks, and similar rights.

View file

@ -1,27 +0,0 @@
# The n8n Enterprise License (the “Enterprise License”)
Copyright (c) 2022-present n8n GmbH.
With regard to the n8n Software:
This software and associated documentation files (the "Software") may only be used in production, if
you (and any entity that you represent) hold a valid n8n Enterprise license corresponding to your
usage. Subject to the foregoing sentence, you are free to modify this Software and publish patches
to the Software. You agree that n8n and/or its licensors (as applicable) retain all right, title and
interest in and to all such modifications and/or patches, and all such modifications and/or patches
may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid n8n
Enterprise license for the corresponding usage. Notwithstanding the foregoing, you may copy and
modify the Software for development and testing purposes, without requiring a subscription. You
agree that n8n and/or its licensors (as applicable) retain all right, title and interest in and to
all such modifications. You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or
sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For all third party components incorporated into the n8n Software, those components are licensed
under the original license provided by the owner of the applicable component.

View file

@ -2,16 +2,6 @@
"name": "n8n-node-dev",
"version": "1.49.0",
"description": "CLI to simplify n8n credentials/node development",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
"author": {
"name": "Jan Oberhauser",
"email": "jan@n8n.io"
},
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"
},
"main": "dist/src/index",
"types": "dist/src/index.d.ts",
"oclif": {

View file

@ -1,86 +0,0 @@
# License
Portions of this software are licensed as follows:
- Content of branches other than the main branch (i.e. "master") are not licensed.
- Source code files that contain ".ee." in their filename are NOT licensed under the Sustainable Use License.
To use source code files that contain ".ee." in their filename you must hold a valid n8n Enterprise License
specifically allowing you access to such source code files and as defined in "LICENSE_EE.md".
- All third party components incorporated into the n8n Software are licensed under the original license
provided by the owner of the applicable component.
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
License" as defined below.
## Sustainable Use License
Version 1.0
### Acceptance
By using the software, you agree to all of the terms and conditions below.
### Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
to the limitations below.
### Limitations
You may use or modify the software only for your own internal business purposes or for non-commercial or
personal use. You may distribute the software or provide it to others only if you do so free of charge for
non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
the licensor in the software. Any use of the licensors trademarks is subject to applicable law.
### Patents
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
subject to the limitations and conditions in this license. This license does not cover any patent claims that
you cause to be infringed by modifications or additions to the software. If you or your company make any
written claim that the software infringes or contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If your company makes such a claim, your patent
license ends immediately for work on behalf of your company.
### Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
terms. If you modify the software, you must include in any modified copies of the software a prominent notice
stating that you have modified the software.
### No Other Rights
These terms do not imply any licenses other than those expressly granted in these terms.
### Termination
If you use the software in violation of these terms, such use is not licensed, and your license will
automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
violation of this license no later than 30 days after you receive that notice, your license will be reinstated
retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
terms will cause your license to terminate automatically and permanently.
### No Liability
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
not be liable to you for any damages arising out of these terms or the use or nature of the software, under
any kind of legal claim.
### Definitions
The “licensor” is the entity offering these terms.
The “software” is the software the licensor makes available under these terms, including any portion of it.
“You” refers to the individual or entity agreeing to these terms.
“Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
all organizations that have control over, are under the control of, or are under common control with that
organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
management and policies by vote, contract, or otherwise. Control can be direct or indirect.
“Your license” is the license granted to you for the software under these terms.
“Use” means anything you do with the software requiring your license.
“Trademark” means trademarks, service marks, and similar rights.

View file

@ -1,27 +0,0 @@
# The n8n Enterprise License (the “Enterprise License”)
Copyright (c) 2022-present n8n GmbH.
With regard to the n8n Software:
This software and associated documentation files (the "Software") may only be used in production, if
you (and any entity that you represent) hold a valid n8n Enterprise license corresponding to your
usage. Subject to the foregoing sentence, you are free to modify this Software and publish patches
to the Software. You agree that n8n and/or its licensors (as applicable) retain all right, title and
interest in and to all such modifications and/or patches, and all such modifications and/or patches
may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid n8n
Enterprise license for the corresponding usage. Notwithstanding the foregoing, you may copy and
modify the Software for development and testing purposes, without requiring a subscription. You
agree that n8n and/or its licensors (as applicable) retain all right, title and interest in and to
all such modifications. You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or
sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For all third party components incorporated into the n8n Software, those components are licensed
under the original license provided by the owner of the applicable component.

View file

@ -8,6 +8,13 @@ export class OrbitApi implements ICredentialType {
documentationUrl = 'orbit';
properties: INodeProperties[] = [
{
displayName:
'Orbit has been shutdown and will no longer function from July 11th, You can read more <a target="_blank" href="https://orbit.love/blog/orbit-is-joining-postman">here</a>.',
name: 'deprecated',
type: 'notice',
default: '',
},
{
displayName: 'API Token',
name: 'accessToken',

View file

@ -42,7 +42,7 @@ export const activityFields: INodeProperties[] = [
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
default: '',
default: 'Deprecated',
required: true,
displayOptions: {
show: {
@ -97,7 +97,7 @@ export const activityFields: INodeProperties[] = [
typeOptions: {
loadOptionsMethod: 'getActivityTypes',
},
default: '',
default: 'Deprecated',
description:
'A user-defined way to group activities of the same nature. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code-examples/expressions/">expression</a>.',
},
@ -151,7 +151,7 @@ export const activityFields: INodeProperties[] = [
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
default: '',
default: 'Deprecated',
required: true,
displayOptions: {
show: {

View file

@ -66,7 +66,7 @@ export const memberFields: INodeProperties[] = [
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
default: '',
default: 'Deprecated',
required: true,
displayOptions: {
show: {
@ -101,7 +101,7 @@ export const memberFields: INodeProperties[] = [
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
default: '',
default: 'Deprecated',
required: true,
displayOptions: {
show: {
@ -151,7 +151,7 @@ export const memberFields: INodeProperties[] = [
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
default: '',
default: 'Deprecated',
required: true,
displayOptions: {
show: {
@ -257,7 +257,7 @@ export const memberFields: INodeProperties[] = [
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
default: '',
default: 'Deprecated',
required: true,
displayOptions: {
show: {
@ -398,7 +398,7 @@ export const memberFields: INodeProperties[] = [
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
default: '',
default: 'Deprecated',
required: true,
displayOptions: {
show: {
@ -528,7 +528,7 @@ export const memberFields: INodeProperties[] = [
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
default: '',
default: 'Deprecated',
required: true,
displayOptions: {
show: {

View file

@ -48,7 +48,7 @@ export const noteFields: INodeProperties[] = [
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
default: '',
default: 'Deprecated',
required: true,
displayOptions: {
show: {
@ -96,7 +96,7 @@ export const noteFields: INodeProperties[] = [
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
default: '',
default: 'Deprecated',
required: true,
displayOptions: {
show: {
@ -174,7 +174,7 @@ export const noteFields: INodeProperties[] = [
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
default: '',
default: 'Deprecated',
required: true,
displayOptions: {
show: {

View file

@ -1,6 +1,5 @@
import type {
IExecuteFunctions,
IDataObject,
ILoadOptionsFunctions,
INodeExecutionData,
INodePropertyOptions,
@ -8,8 +7,7 @@ import type {
INodeTypeDescription,
} from 'n8n-workflow';
import moment from 'moment-timezone';
import { orbitApiRequest, orbitApiRequestAllItems, resolveIdentities } from './GenericFunctions';
import { NodeApiError } from 'n8n-workflow';
import { activityFields, activityOperations } from './ActivityDescription';
@ -19,8 +17,6 @@ import { noteFields, noteOperations } from './NoteDescription';
import { postFields, postOperations } from './PostDescription';
import type { IRelation } from './Interfaces';
export class Orbit implements INodeType {
description: INodeTypeDescription = {
displayName: 'Orbit',
@ -30,6 +26,7 @@ export class Orbit implements INodeType {
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Orbit API',
hidden: true,
defaults: {
name: 'Orbit',
},
@ -42,6 +39,13 @@ export class Orbit implements INodeType {
},
],
properties: [
{
displayName:
'Orbit has been shutdown and will no longer function from July 11th, You can read more <a target="_blank" href="https://orbit.love/blog/orbit-is-joining-postman">here</a>.',
name: 'deprecated',
type: 'notice',
default: '',
},
{
displayName: 'Resource',
name: 'resource',
@ -85,463 +89,18 @@ export class Orbit implements INodeType {
methods = {
loadOptions: {
async getWorkspaces(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const workspaces = await orbitApiRequest.call(this, 'GET', '/workspaces');
for (const workspace of workspaces.data) {
returnData.push({
name: workspace.attributes.name,
value: workspace.attributes.slug,
});
}
return returnData;
return [{ name: 'Deprecated', value: 'Deprecated' }];
},
async getActivityTypes(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { data } = await orbitApiRequest.call(this, 'GET', '/activity_types');
for (const activityType of data) {
returnData.push({
name: activityType.attributes.short_name,
value: activityType.id,
});
}
return returnData;
return [{ name: 'Deprecated', value: 'Deprecated' }];
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
const length = items.length;
const qs: IDataObject = {};
let responseData;
const resource = this.getNodeParameter('resource', 0);
const operation = this.getNodeParameter('operation', 0);
for (let i = 0; i < length; i++) {
try {
if (resource === 'activity') {
if (operation === 'create') {
const workspaceId = this.getNodeParameter('workspaceId', i) as string;
const memberId = this.getNodeParameter('memberId', i) as string;
const title = this.getNodeParameter('title', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i);
const body: IDataObject = {
title,
};
if (additionalFields.description) {
body.description = additionalFields.description as string;
}
if (additionalFields.link) {
body.link = additionalFields.link as string;
}
if (additionalFields.linkText) {
body.link_text = additionalFields.linkText as string;
}
if (additionalFields.activityType) {
body.activity_type = additionalFields.activityType as string;
}
if (additionalFields.key) {
body.key = additionalFields.key as string;
}
if (additionalFields.occurredAt) {
body.occurred_at = additionalFields.occurredAt as string;
}
responseData = await orbitApiRequest.call(
this,
'POST',
`/${workspaceId}/members/${memberId}/activities`,
body,
);
responseData = responseData.data;
}
if (operation === 'getAll') {
const workspaceId = this.getNodeParameter('workspaceId', i) as string;
const returnAll = this.getNodeParameter('returnAll', i);
const filters = this.getNodeParameter('filters', i);
let endpoint = `/${workspaceId}/activities`;
if (filters.memberId) {
endpoint = `/${workspaceId}/members/${filters.memberId}/activities`;
}
if (returnAll) {
responseData = await orbitApiRequestAllItems.call(
this,
'data',
'GET',
endpoint,
{},
qs,
);
} else {
qs.limit = this.getNodeParameter('limit', 0);
responseData = await orbitApiRequestAllItems.call(
this,
'data',
'GET',
endpoint,
{},
qs,
);
responseData = responseData.splice(0, qs.limit);
}
}
}
if (resource === 'member') {
if (operation === 'upsert') {
const workspaceId = this.getNodeParameter('workspaceId', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i);
const member: IDataObject = {};
const identity: IDataObject = {};
if (additionalFields.bio) {
member.bio = additionalFields.bio as string;
}
if (additionalFields.birthday) {
member.birthday = moment(additionalFields.birthday as string).format('MM-DD-YYYY');
}
if (additionalFields.company) {
member.company = additionalFields.company as string;
}
if (additionalFields.location) {
member.location = additionalFields.location as string;
}
if (additionalFields.name) {
member.name = additionalFields.name as string;
}
if (additionalFields.bio) {
member.bio = additionalFields.bio as string;
}
if (additionalFields.pronouns) {
member.pronouns = additionalFields.pronouns as string;
}
if (additionalFields.shippingAddress) {
member.shipping_address = additionalFields.shippingAddress as string;
}
if (additionalFields.slug) {
member.slug = additionalFields.slug as string;
}
if (additionalFields.tagsToAdd) {
member.tags_to_add = additionalFields.tagsToAdd as string;
}
if (additionalFields.tagList) {
member.tag_list = additionalFields.tagList as string;
}
if (additionalFields.tshirt) {
member.tshirt = additionalFields.tshirt as string;
}
if (additionalFields.hasOwnProperty('teammate')) {
member.teammate = additionalFields.teammate as boolean;
}
if (additionalFields.url) {
member.url = additionalFields.url as string;
}
const data = (this.getNodeParameter('identityUi', i) as IDataObject)
.identityValue as IDataObject;
if (data) {
if (['github', 'twitter', 'discourse'].includes(data.source as string)) {
identity.source = data.source as string;
const searchBy = data.searchBy as string;
if (searchBy === 'id') {
identity.uid = data.id as string;
} else {
identity.username = data.username as string;
}
if (data.source === 'discourse') {
identity.source_host = data.host as string;
}
} else {
//it's email
identity.email = data.email as string;
}
}
responseData = await orbitApiRequest.call(this, 'POST', `/${workspaceId}/members`, {
member,
identity,
});
responseData = responseData.data;
}
if (operation === 'delete') {
const workspaceId = this.getNodeParameter('workspaceId', i) as string;
const memberId = this.getNodeParameter('memberId', i) as string;
responseData = await orbitApiRequest.call(
this,
'DELETE',
`/${workspaceId}/members/${memberId}`,
);
responseData = { success: true };
}
if (operation === 'get') {
const workspaceId = this.getNodeParameter('workspaceId', i) as string;
const memberId = this.getNodeParameter('memberId', i) as string;
const resolve = this.getNodeParameter('resolveIdentities', 0) as boolean;
responseData = await orbitApiRequest.call(
this,
'GET',
`/${workspaceId}/members/${memberId}`,
);
if (resolve) {
resolveIdentities(responseData as IRelation);
}
responseData = responseData.data;
}
if (operation === 'getAll') {
const workspaceId = this.getNodeParameter('workspaceId', i) as string;
const returnAll = this.getNodeParameter('returnAll', 0);
const options = this.getNodeParameter('options', i);
Object.assign(qs, options);
qs.resolveIdentities = this.getNodeParameter('resolveIdentities', 0) as boolean;
if (returnAll) {
responseData = await orbitApiRequestAllItems.call(
this,
'data',
'GET',
`/${workspaceId}/members`,
{},
qs,
);
} else {
qs.limit = this.getNodeParameter('limit', 0);
responseData = await orbitApiRequestAllItems.call(
this,
'data',
'GET',
`/${workspaceId}/members`,
{},
qs,
);
responseData = responseData.splice(0, qs.limit);
}
}
if (operation === 'lookup') {
const workspaceId = this.getNodeParameter('workspaceId', i) as string;
const source = this.getNodeParameter('source', i) as string;
if (['github', 'twitter', 'discourse'].includes(source)) {
qs.source = this.getNodeParameter('source', i) as string;
const searchBy = this.getNodeParameter('searchBy', i) as string;
if (searchBy === 'id') {
qs.uid = this.getNodeParameter('id', i) as string;
} else {
qs.username = this.getNodeParameter('username', i) as string;
}
if (source === 'discourse') {
qs.source_host = this.getNodeParameter('host', i) as string;
}
} else {
//it's email
qs.email = this.getNodeParameter('email', i) as string;
}
responseData = await orbitApiRequest.call(
this,
'GET',
`/${workspaceId}/members/find`,
{},
qs,
);
responseData = responseData.data;
}
if (operation === 'update') {
const workspaceId = this.getNodeParameter('workspaceId', i) as string;
const memberId = this.getNodeParameter('memberId', i) as string;
const updateFields = this.getNodeParameter('updateFields', i);
const body: IDataObject = {};
if (updateFields.bio) {
body.bio = updateFields.bio as string;
}
if (updateFields.birthday) {
body.birthday = moment(updateFields.birthday as string).format('MM-DD-YYYY');
}
if (updateFields.company) {
body.company = updateFields.company as string;
}
if (updateFields.location) {
body.location = updateFields.location as string;
}
if (updateFields.name) {
body.name = updateFields.name as string;
}
if (updateFields.bio) {
body.bio = updateFields.bio as string;
}
if (updateFields.pronouns) {
body.pronouns = updateFields.pronouns as string;
}
if (updateFields.shippingAddress) {
body.shipping_address = updateFields.shippingAddress as string;
}
if (updateFields.slug) {
body.slug = updateFields.slug as string;
}
if (updateFields.tagsToAdd) {
body.tags_to_add = updateFields.tagsToAdd as string;
}
if (updateFields.tagList) {
body.tag_list = updateFields.tagList as string;
}
if (updateFields.tshirt) {
body.tshirt = updateFields.tshirt as string;
}
if (updateFields.hasOwnProperty('teammate')) {
body.teammate = updateFields.teammate as boolean;
}
if (updateFields.url) {
body.url = updateFields.url as string;
}
responseData = await orbitApiRequest.call(
this,
'PUT',
`/${workspaceId}/members/${memberId}`,
body,
);
responseData = { success: true };
}
}
if (resource === 'note') {
if (operation === 'create') {
const workspaceId = this.getNodeParameter('workspaceId', i) as string;
const memberId = this.getNodeParameter('memberId', i) as string;
const note = this.getNodeParameter('note', i) as string;
responseData = await orbitApiRequest.call(
this,
'POST',
`/${workspaceId}/members/${memberId}/notes`,
{ body: note },
);
responseData = responseData.data;
}
if (operation === 'getAll') {
const workspaceId = this.getNodeParameter('workspaceId', i) as string;
const memberId = this.getNodeParameter('memberId', i) as string;
const returnAll = this.getNodeParameter('returnAll', i);
qs.resolveMember = this.getNodeParameter('resolveMember', 0) as boolean;
if (returnAll) {
responseData = await orbitApiRequestAllItems.call(
this,
'data',
'GET',
`/${workspaceId}/members/${memberId}/notes`,
{},
qs,
);
} else {
qs.limit = this.getNodeParameter('limit', 0);
responseData = await orbitApiRequestAllItems.call(
this,
'data',
'GET',
`/${workspaceId}/members/${memberId}/notes`,
{},
qs,
);
responseData = responseData.splice(0, qs.limit);
}
}
if (operation === 'update') {
const workspaceId = this.getNodeParameter('workspaceId', i) as string;
const memberId = this.getNodeParameter('memberId', i) as string;
const noteId = this.getNodeParameter('noteId', i) as string;
const note = this.getNodeParameter('note', i) as string;
responseData = await orbitApiRequest.call(
this,
'PUT',
`/${workspaceId}/members/${memberId}/notes/${noteId}`,
{ body: note },
);
responseData = { success: true };
}
}
if (resource === 'post') {
if (operation === 'create') {
const workspaceId = this.getNodeParameter('workspaceId', i) as string;
const memberId = this.getNodeParameter('memberId', i) as string;
const url = this.getNodeParameter('url', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i);
const body: IDataObject = {
type: 'post',
activity_type: 'post',
url,
};
if (additionalFields.publishedAt) {
body.occurred_at = additionalFields.publishedAt as string;
delete body.publishedAt;
}
responseData = await orbitApiRequest.call(
this,
'POST',
`/${workspaceId}/members/${memberId}/activities/`,
body,
);
responseData = responseData.data;
}
if (operation === 'getAll') {
const workspaceId = this.getNodeParameter('workspaceId', i) as string;
const returnAll = this.getNodeParameter('returnAll', i);
const filters = this.getNodeParameter('filters', i);
let endpoint = `/${workspaceId}/activities`;
qs.type = 'content';
if (filters.memberId) {
endpoint = `/${workspaceId}/members/${filters.memberId}/activities`;
}
if (returnAll) {
responseData = await orbitApiRequestAllItems.call(
this,
'data',
'GET',
endpoint,
{},
qs,
);
} else {
qs.limit = this.getNodeParameter('limit', 0);
responseData = await orbitApiRequestAllItems.call(
this,
'data',
'GET',
endpoint,
{},
qs,
);
responseData = responseData.splice(0, qs.limit);
}
}
if (operation === 'delete') {
const workspaceId = this.getNodeParameter('workspaceId', i) as string;
const memberId = this.getNodeParameter('memberId', i) as string;
const postId = this.getNodeParameter('postId', i) as string;
responseData = await orbitApiRequest.call(
this,
'DELETE',
`/${workspaceId}/members/${memberId}/activities/${postId}`,
);
responseData = { success: true };
}
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData as IDataObject),
{ itemData: { item: i } },
);
returnData.push(...executionData);
} catch (error) {
if (this.continueOnFail(error)) {
const executionErrorData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray({ error: error.message }),
{ itemData: { item: i } },
);
returnData.push(...executionErrorData);
continue;
}
throw error;
}
}
return [returnData];
throw new NodeApiError(this.getNode(), {
message: 'Service is deprecated, From July 11th Orbit will no longer function.',
level: 'warning',
});
}
}

View file

@ -48,7 +48,7 @@ export const postFields: INodeProperties[] = [
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
default: '',
default: 'Deprecated',
required: true,
displayOptions: {
show: {
@ -119,7 +119,7 @@ export const postFields: INodeProperties[] = [
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
default: '',
default: 'Deprecated',
required: true,
displayOptions: {
show: {
@ -194,7 +194,7 @@ export const postFields: INodeProperties[] = [
typeOptions: {
loadOptionsMethod: 'getWorkspaces',
},
default: '',
default: 'Deprecated',
required: true,
displayOptions: {
show: {

View file

@ -1289,6 +1289,26 @@ export class Telegram implements INodeType {
default: '',
description: 'HTTP or tg:// URL to be opened when button is pressed',
},
{
displayName: 'Web App',
name: 'web_app',
type: 'collection',
placeholder: 'Set Telegram Web App URL',
typeOptions: {
multipleValues: false,
},
default: {},
options: [
{
displayName: 'URL',
name: 'url',
type: 'string',
default: '',
description: 'An HTTPS URL of a Web App to be opened',
},
],
description: 'Launch the Telegram Web App',
},
],
},
],
@ -1365,6 +1385,26 @@ export class Telegram implements INodeType {
default: false,
description: "Whether the user's request_location",
},
{
displayName: 'Web App',
name: 'web_app',
type: 'collection',
placeholder: 'Set Telegram Web App URL',
typeOptions: {
multipleValues: false,
},
default: {},
options: [
{
displayName: 'URL',
name: 'url',
type: 'string',
default: '',
description: 'An HTTPS URL of a Web App to be opened',
},
],
description: 'Launch the Telegram Web App',
},
],
},
],

View file

@ -2,17 +2,7 @@
"name": "n8n-nodes-base",
"version": "1.49.0",
"description": "Base nodes of n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
"author": {
"name": "Jan Oberhauser",
"email": "jan@n8n.io"
},
"main": "index.js",
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"
},
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -1,86 +0,0 @@
# License
Portions of this software are licensed as follows:
- Content of branches other than the main branch (i.e. "master") are not licensed.
- Source code files that contain ".ee." in their filename are NOT licensed under the Sustainable Use License.
To use source code files that contain ".ee." in their filename you must hold a valid n8n Enterprise License
specifically allowing you access to such source code files and as defined in "LICENSE_EE.md".
- All third party components incorporated into the n8n Software are licensed under the original license
provided by the owner of the applicable component.
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use
License" as defined below.
## Sustainable Use License
Version 1.0
### Acceptance
By using the software, you agree to all of the terms and conditions below.
### Copyright License
The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license
to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject
to the limitations below.
### Limitations
You may use or modify the software only for your own internal business purposes or for non-commercial or
personal use. You may distribute the software or provide it to others only if you do so free of charge for
non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of
the licensor in the software. Any use of the licensors trademarks is subject to applicable law.
### Patents
The licensor grants you a license, under any patent claims the licensor can license, or becomes able to
license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case
subject to the limitations and conditions in this license. This license does not cover any patent claims that
you cause to be infringed by modifications or additions to the software. If you or your company make any
written claim that the software infringes or contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If your company makes such a claim, your patent
license ends immediately for work on behalf of your company.
### Notices
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these
terms. If you modify the software, you must include in any modified copies of the software a prominent notice
stating that you have modified the software.
### No Other Rights
These terms do not imply any licenses other than those expressly granted in these terms.
### Termination
If you use the software in violation of these terms, such use is not licensed, and your license will
automatically terminate. If the licensor provides you with a notice of your violation, and you cease all
violation of this license no later than 30 days after you receive that notice, your license will be reinstated
retroactively. However, if you violate these terms after such reinstatement, any additional violation of these
terms will cause your license to terminate automatically and permanently.
### No Liability
As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will
not be liable to you for any damages arising out of these terms or the use or nature of the software, under
any kind of legal claim.
### Definitions
The “licensor” is the entity offering these terms.
The “software” is the software the licensor makes available under these terms, including any portion of it.
“You” refers to the individual or entity agreeing to these terms.
“Your company” is any legal entity, sole proprietorship, or other kind of organization that you work for, plus
all organizations that have control over, are under the control of, or are under common control with that
organization. Control means ownership of substantially all the assets of an entity, or the power to direct its
management and policies by vote, contract, or otherwise. Control can be direct or indirect.
“Your license” is the license granted to you for the software under these terms.
“Use” means anything you do with the software requiring your license.
“Trademark” means trademarks, service marks, and similar rights.

View file

@ -1,27 +0,0 @@
# The n8n Enterprise License (the “Enterprise License”)
Copyright (c) 2022-present n8n GmbH.
With regard to the n8n Software:
This software and associated documentation files (the "Software") may only be used in production, if
you (and any entity that you represent) hold a valid n8n Enterprise license corresponding to your
usage. Subject to the foregoing sentence, you are free to modify this Software and publish patches
to the Software. You agree that n8n and/or its licensors (as applicable) retain all right, title and
interest in and to all such modifications and/or patches, and all such modifications and/or patches
may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid n8n
Enterprise license for the corresponding usage. Notwithstanding the foregoing, you may copy and
modify the Software for development and testing purposes, without requiring a subscription. You
agree that n8n and/or its licensors (as applicable) retain all right, title and interest in and to
all such modifications. You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or
sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
For all third party components incorporated into the n8n Software, those components are licensed
under the original license provided by the owner of the applicable component.

View file

@ -2,16 +2,6 @@
"name": "n8n-workflow",
"version": "1.48.0",
"description": "Workflow base code of n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
"author": {
"name": "Jan Oberhauser",
"email": "jan@n8n.io"
},
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"
},
"main": "dist/index.js",
"module": "src/index.ts",
"types": "dist/index.d.ts",

View file

@ -1424,13 +1424,6 @@ export class Workflow {
return { data: null };
}
if (triggerResponse.manualTriggerFunction !== undefined) {
// If a manual trigger function is defined call it and wait till it did run
await triggerResponse.manualTriggerFunction();
}
const response = await triggerResponse.manualTriggerResponse!;
let closeFunction;
if (triggerResponse.closeFunction) {
// In manual mode we return the trigger closeFunction. That allows it to be called directly
@ -1439,8 +1432,18 @@ export class Workflow {
// If we would not be able to wait for it to close would it cause problems with "own" mode as the
// process would be killed directly after it and so the acknowledge would not have been finished yet.
closeFunction = triggerResponse.closeFunction;
// Manual testing of Trigger nodes creates an execution. If the execution is cancelled, `closeFunction` should be called to cleanup any open connections/consumers
abortSignal?.addEventListener('abort', closeFunction);
}
if (triggerResponse.manualTriggerFunction !== undefined) {
// If a manual trigger function is defined call it and wait till it did run
await triggerResponse.manualTriggerFunction();
}
const response = await triggerResponse.manualTriggerResponse!;
if (response.length === 0) {
return { data: null, closeFunction };
}

View file

@ -4,13 +4,18 @@ import type {
IBinaryKeyData,
IConnections,
IDataObject,
IExecuteData,
INode,
INodeExecuteFunctions,
INodeExecutionData,
INodeParameters,
INodeType,
INodeTypeDescription,
INodeTypes,
IRunExecutionData,
ITriggerFunctions,
ITriggerResponse,
IWorkflowExecuteAdditionalData,
NodeParameterValueType,
} from '@/Interfaces';
import { Workflow, type WorkflowParameters } from '@/Workflow';
@ -2015,4 +2020,67 @@ describe('Workflow', () => {
]);
});
});
describe('runNode', () => {
const nodeTypes = mock<INodeTypes>();
const triggerNode = mock<INode>();
const triggerResponse = mock<ITriggerResponse>({
closeFunction: jest.fn(),
// This node should never trigger, or return
manualTriggerFunction: async () => await new Promise(() => {}),
});
const triggerNodeType = mock<INodeType>({
description: {
properties: [],
},
execute: undefined,
poll: undefined,
webhook: undefined,
async trigger(this: ITriggerFunctions) {
return triggerResponse;
},
});
nodeTypes.getByNameAndVersion.mockReturnValue(triggerNodeType);
const workflow = new Workflow({
nodeTypes,
nodes: [triggerNode],
connections: {},
active: false,
});
const executionData = mock<IExecuteData>();
const runExecutionData = mock<IRunExecutionData>();
const additionalData = mock<IWorkflowExecuteAdditionalData>();
const nodeExecuteFunctions = mock<INodeExecuteFunctions>();
const triggerFunctions = mock<ITriggerFunctions>();
nodeExecuteFunctions.getExecuteTriggerFunctions.mockReturnValue(triggerFunctions);
const abortController = new AbortController();
test('should call closeFunction when manual trigger is aborted', async () => {
const runPromise = workflow.runNode(
executionData,
runExecutionData,
0,
additionalData,
nodeExecuteFunctions,
'manual',
abortController.signal,
);
// Yield back to the event-loop to let async parts of `runNode` execute
await new Promise((resolve) => setImmediate(resolve));
let isSettled = false;
void runPromise.then(() => {
isSettled = true;
});
expect(isSettled).toBe(false);
expect(abortController.signal.aborted).toBe(false);
expect(triggerResponse.closeFunction).not.toHaveBeenCalled();
abortController.abort();
expect(triggerResponse.closeFunction).toHaveBeenCalled();
});
});
});