refactor: Add node IDs (#3788)

* update type

* add id to new nodes

* update paste/import behavior

* update duplicate/copy

* update duplicate workflow

* update import functions + templates

* add instance id on copy

* on download add instance id

* simplify for testing

* update telemetry events

* add ids to nodegraph

* not if same instance

* update spacing

* fix tests

* update tests

* add uuid

* fix tests

update tests

add uuid

fix ts issue

* fix telemetry event

* update workflow import

* update public api

* add sqlit migration

* on workflow update

* add psql migration

* add mysql migration

* revert to title

* fix telemetry bug

* remove console log

* remove migration logs

* fix copy/paste bug

* replace node index with node id

* remove console log

* address PR feedback

* address comment

* fix type issue

* fix select

* update schema

* fix ts issue

* update tel helpers

* fix eslint issues
This commit is contained in:
Mutasem Aldmour 2022-08-03 13:06:53 +02:00 committed by GitHub
parent b5ea666ecf
commit 679a443a0c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 602 additions and 157 deletions

View file

@ -17,6 +17,7 @@ import fs from 'fs';
import glob from 'fast-glob';
import { UserSettings } from 'n8n-core';
import { EntityManager, getConnection } from 'typeorm';
import { v4 as uuid } from 'uuid';
import { getLogger } from '../../src/Logger';
import { Db, ICredentialsDb, IWorkflowToImport } from '../../src';
import { SharedWorkflow } from '../../src/databases/entities/SharedWorkflow';
@ -129,6 +130,11 @@ export class ImportWorkflowsCommand extends Command {
if (credentials.length > 0) {
workflow.nodes.forEach((node: INode) => {
this.transformCredentials(node, credentials);
if (!node.id) {
// eslint-disable-next-line no-param-reassign
node.id = uuid();
}
});
}
@ -157,6 +163,11 @@ export class ImportWorkflowsCommand extends Command {
if (credentials.length > 0) {
workflow.nodes.forEach((node: INode) => {
this.transformCredentials(node, credentials);
if (!node.id) {
// eslint-disable-next-line no-param-reassign
node.id = uuid();
}
});
}

View file

@ -586,6 +586,7 @@ export class CredentialsHelper extends ICredentialsHelper {
}
const node: INode = {
id: 'temp',
parameters: {},
name: 'Temp-Node',
type: nodeType.description.name,

View file

@ -1,6 +1,9 @@
type: object
additionalProperties: false
properties:
id:
type: string
example: 0f5532f9-36ba-4bef-86c7-30d607400b15
name:
type: string
example: Jira

View file

@ -7,7 +7,7 @@ import config = require('../../../../../config');
import { WorkflowEntity } from '../../../../databases/entities/WorkflowEntity';
import { InternalHooksManager } from '../../../../InternalHooksManager';
import { externalHooks } from '../../../../Server';
import { replaceInvalidCredentials } from '../../../../WorkflowHelpers';
import { addNodeIds, replaceInvalidCredentials } from '../../../../WorkflowHelpers';
import { WorkflowRequest } from '../../../types';
import { authorize, validCursor } from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service';
@ -42,6 +42,8 @@ export = {
await replaceInvalidCredentials(workflow);
addNodeIds(workflow);
const role = await getWorkflowOwnerRole();
const createdWorkflow = await createWorkflow(workflow, req.user, role);
@ -186,6 +188,7 @@ export = {
}
await replaceInvalidCredentials(updateData);
addNodeIds(updateData);
const workflowRunner = ActiveWorkflowRunner.getInstance();

View file

@ -1,6 +1,7 @@
import { FindManyOptions, In, UpdateResult } from 'typeorm';
import intersection from 'lodash.intersection';
import type { INode } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { Db } from '../../../..';
import { User } from '../../../../databases/entities/User';
@ -133,6 +134,7 @@ export function hasStartNode(workflow: WorkflowEntity): boolean {
export function getStartNode(): INode {
return {
id: uuid(),
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',

View file

@ -924,6 +924,8 @@ class App {
// check credentials for old format
await WorkflowHelpers.replaceInvalidCredentials(updateData);
WorkflowHelpers.addNodeIds(updateData);
await this.externalHooks.run('workflow.update', [updateData]);
if (shared.workflow.active) {

View file

@ -21,6 +21,7 @@ import {
LoggerProxy as Logger,
Workflow,
} from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
// eslint-disable-next-line import/no-cycle
import {
CredentialTypes,
@ -474,6 +475,22 @@ export async function getStaticDataById(workflowId: string | number) {
return workflowData.staticData || {};
}
/**
* Set node ids if not already set
*
* @param workflow
*/
export function addNodeIds(workflow: WorkflowEntity) {
const { nodes } = workflow;
if (!nodes) return;
nodes.forEach((node) => {
if (!node.id) {
node.id = uuid();
}
});
}
// Checking if credentials of old format are in use and run a DB check if they might exist uniquely
export async function replaceInvalidCredentials(workflow: WorkflowEntity): Promise<WorkflowEntity> {
const { nodes } = workflow;

View file

@ -43,6 +43,8 @@ workflowsController.post(
await WorkflowHelpers.replaceInvalidCredentials(newWorkflow);
WorkflowHelpers.addNodeIds(newWorkflow);
let savedWorkflow: undefined | WorkflowEntity;
await Db.transaction(async (transactionManager) => {

View file

@ -0,0 +1,76 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as config from '../../../../config';
import { runChunked } from '../../utils/migrationHelpers';
import { v4 as uuid } from 'uuid';
// add node ids in workflow objects
export class AddNodeIds1658932910559 implements MigrationInterface {
name = 'AddNodeIds1658932910559';
public async up(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`;
// @ts-ignore
await runChunked(queryRunner, workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
// @ts-ignore
nodes.forEach((node) => {
if (!node.id) {
node.id = uuid();
}
});
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
});
});
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`;
// @ts-ignore
await runChunked(queryRunner, workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
// @ts-ignore
nodes.forEach((node) => delete node.id );
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
});
});
}
}

View file

@ -17,6 +17,7 @@ import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
import { CommunityNodes1652254514003 } from './1652254514003-CommunityNodes';
import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
import { IntroducePinData1654090101303 } from './1654090101303-IntroducePinData';
import { AddNodeIds1658932910559 } from './1658932910559-AddNodeIds';
export const mysqlMigrations = [
InitialMigration1588157391238,
@ -38,4 +39,5 @@ export const mysqlMigrations = [
CommunityNodes1652254514003,
AddAPIKeyColumn1652905585850,
IntroducePinData1654090101303,
AddNodeIds1658932910559,
];

View file

@ -0,0 +1,88 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as config from '../../../../config';
import { runChunked } from '../../utils/migrationHelpers';
import { v4 as uuid } from 'uuid';
// add node ids in workflow objects
export class AddNodeIds1658932090381 implements MigrationInterface {
name = 'AddNodeIds1658932090381';
public async up(queryRunner: QueryRunner): Promise<void> {
let tablePrefix = config.getEnv('database.tablePrefix');
const schema = config.getEnv('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
await queryRunner.query(`SET search_path TO ${schema};`);
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`;
// @ts-ignore
await runChunked(queryRunner, workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
// @ts-ignore
nodes.forEach((node) => {
if (!node.id) {
node.id = uuid();
}
});
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
});
});
}
public async down(queryRunner: QueryRunner): Promise<void> {
let tablePrefix = config.getEnv('database.tablePrefix');
const schema = config.getEnv('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
await queryRunner.query(`SET search_path TO ${schema};`);
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`;
// @ts-ignore
await runChunked(queryRunner, workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
// @ts-ignore
nodes.forEach((node) => delete node.id );
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
});
});
}
}

View file

@ -15,6 +15,7 @@ import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
import { CommunityNodes1652254514002 } from './1652254514002-CommunityNodes';
import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
import { IntroducePinData1654090467022 } from './1654090467022-IntroducePinData';
import { AddNodeIds1658932090381 } from './1658932090381-AddNodeIds';
export const postgresMigrations = [
InitialMigration1587669153312,
@ -34,4 +35,5 @@ export const postgresMigrations = [
CommunityNodes1652254514002,
AddAPIKeyColumn1652905585850,
IntroducePinData1654090467022,
AddNodeIds1658932090381,
];

View file

@ -0,0 +1,82 @@
import { INode } from 'n8n-workflow';
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as config from '../../../../config';
import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
import { runChunked } from '../../utils/migrationHelpers';
import { v4 as uuid } from 'uuid';
// add node ids in workflow objects
export class AddNodeIds1658930531669 implements MigrationInterface {
name = 'AddNodeIds1658930531669';
public async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = config.getEnv('database.tablePrefix');
const workflowsQuery = `
SELECT id, nodes
FROM "${tablePrefix}workflow_entity"
`;
// @ts-ignore
await runChunked(queryRunner, workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes);
nodes.forEach((node: INode) => {
if (!node.id) {
node.id = uuid();
}
});
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}workflow_entity"
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
});
});
logMigrationEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');
const workflowsQuery = `
SELECT id, nodes
FROM "${tablePrefix}workflow_entity"
`;
// @ts-ignore
await runChunked(queryRunner, workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes);
// @ts-ignore
nodes.forEach((node) => delete node.id );
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}workflow_entity"
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
});
});
}
}

View file

@ -14,6 +14,7 @@ import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
import { CommunityNodes1652254514001 } from './1652254514001-CommunityNodes'
import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
import { IntroducePinData1654089251344 } from './1654089251344-IntroducePinData';
import { AddNodeIds1658930531669 } from './1658930531669-AddNodeIds';
const sqliteMigrations = [
InitialMigration1588102412422,
@ -32,6 +33,7 @@ const sqliteMigrations = [
CommunityNodes1652254514001,
AddAPIKeyColumn1652905585850,
IntroducePinData1654089251344,
AddNodeIds1658930531669,
];
export { sqliteMigrations };

View file

@ -951,6 +951,7 @@ test('POST /workflows should create workflow', async () => {
name: 'testing',
nodes: [
{
id: 'uuid-1234',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
@ -1047,6 +1048,7 @@ test('PUT /workflows/:id should fail due to non-existing workflow', async () =>
name: 'testing',
nodes: [
{
id: 'uuid-1234',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
@ -1082,6 +1084,7 @@ test('PUT /workflows/:id should fail due to invalid body', async () => {
const response = await authOwnerAgent.put(`/workflows/1`).send({
nodes: [
{
id: 'uuid-1234',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
@ -1120,6 +1123,7 @@ test('PUT /workflows/:id should update workflow', async () => {
name: 'name updated',
nodes: [
{
id: 'uuid-1234',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
@ -1127,6 +1131,7 @@ test('PUT /workflows/:id should update workflow', async () => {
position: [240, 300],
},
{
id: 'uuid-1234',
parameters: {},
name: 'Cron',
type: 'n8n-nodes-base.cron',
@ -1195,6 +1200,7 @@ test('PUT /workflows/:id should update non-owned workflow if owner', async () =>
name: 'name owner updated',
nodes: [
{
id: 'uuid-1',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
@ -1202,6 +1208,7 @@ test('PUT /workflows/:id should update non-owned workflow if owner', async () =>
position: [240, 300],
},
{
id: 'uuid-2',
parameters: {},
name: 'Cron',
type: 'n8n-nodes-base.cron',

View file

@ -522,6 +522,7 @@ export async function createWorkflow(attributes: Partial<WorkflowEntity> = {}, u
name: name ?? 'test workflow',
nodes: nodes ?? [
{
id: 'uuid-1234',
name: 'Start',
parameters: {},
position: [-20, 260],
@ -555,6 +556,7 @@ export async function createWorkflowWithTrigger(
{
nodes: [
{
id: 'uuid-1',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
@ -562,6 +564,7 @@ export async function createWorkflowWithTrigger(
position: [240, 300],
},
{
id: 'uuid-2',
parameters: { triggerTimes: { item: [{ mode: 'everyMinute' }] } },
name: 'Cron',
type: 'n8n-nodes-base.cron',
@ -569,6 +572,7 @@ export async function createWorkflowWithTrigger(
position: [500, 300],
},
{
id: 'uuid-3',
parameters: { options: {} },
name: 'Set',
type: 'n8n-nodes-base.set',

View file

@ -91,6 +91,7 @@ function makeWorkflow({ withPinData }: { withPinData: boolean }) {
workflow.connections = {};
workflow.nodes = [
{
id: 'uuid-1234',
name: 'Spotify',
type: 'n8n-nodes-base.spotify',
parameters: { resource: 'track', operation: 'get', id: '123' },

View file

@ -194,6 +194,7 @@ describe('CredentialsHelper', () => {
];
const node: INode = {
id: 'uuid-1',
parameters: {},
name: 'test',
type: 'test.set',

View file

@ -58,6 +58,7 @@ export class LoadNodeParameterOptions {
const nodeData: INode = {
parameters: currentNodeParameters,
id: 'uuid-1234',
name: TEMP_NODE_NAME,
type: nodeTypeNameAndVersion.name,
typeVersion: nodeTypeNameAndVersion.version,

View file

@ -37,6 +37,7 @@ describe('WorkflowExecute', () => {
workflowData: {
nodes: [
{
id: 'uuid-1',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
@ -44,6 +45,7 @@ describe('WorkflowExecute', () => {
position: [100, 300],
},
{
id: 'uuid-2',
parameters: {
values: {
number: [
@ -96,6 +98,7 @@ describe('WorkflowExecute', () => {
workflowData: {
nodes: [
{
id: 'uuid-1',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
@ -103,6 +106,7 @@ describe('WorkflowExecute', () => {
position: [100, 300],
},
{
id: 'uuid-2',
parameters: {
values: {
number: [
@ -119,6 +123,7 @@ describe('WorkflowExecute', () => {
position: [300, 250],
},
{
id: 'uuid-3',
parameters: {
values: {
number: [
@ -200,6 +205,7 @@ describe('WorkflowExecute', () => {
workflowData: {
nodes: [
{
id: 'uuid-1',
parameters: {
mode: 'passThrough',
},
@ -209,6 +215,7 @@ describe('WorkflowExecute', () => {
position: [1150, 500],
},
{
id: 'uuid-2',
parameters: {
values: {
number: [
@ -225,6 +232,7 @@ describe('WorkflowExecute', () => {
position: [290, 400],
},
{
id: 'uuid-3',
parameters: {
values: {
number: [
@ -241,6 +249,7 @@ describe('WorkflowExecute', () => {
position: [850, 200],
},
{
id: 'uuid-4',
parameters: {
values: {
number: [
@ -257,6 +266,7 @@ describe('WorkflowExecute', () => {
position: [650, 200],
},
{
id: 'uuid-5',
parameters: {
mode: 'passThrough',
},
@ -266,6 +276,7 @@ describe('WorkflowExecute', () => {
position: [1150, 500],
},
{
id: 'uuid-6',
parameters: {},
name: 'Merge3',
type: 'n8n-nodes-base.merge',
@ -273,6 +284,7 @@ describe('WorkflowExecute', () => {
position: [1000, 400],
},
{
id: 'uuid-7',
parameters: {
mode: 'passThrough',
output: 'input2',
@ -283,6 +295,7 @@ describe('WorkflowExecute', () => {
position: [700, 400],
},
{
id: 'uuid-8',
parameters: {},
name: 'Merge1',
type: 'n8n-nodes-base.merge',
@ -290,6 +303,7 @@ describe('WorkflowExecute', () => {
position: [500, 300],
},
{
id: 'uuid-9',
parameters: {
values: {
number: [
@ -306,6 +320,7 @@ describe('WorkflowExecute', () => {
position: [300, 200],
},
{
id: 'uuid-10',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
@ -526,6 +541,7 @@ describe('WorkflowExecute', () => {
name: 'Start',
type: 'n8n-nodes-base.start',
typeVersion: 1,
id: 'uuid-1',
position: [250, 450],
},
{
@ -543,6 +559,7 @@ describe('WorkflowExecute', () => {
name: 'IF',
type: 'n8n-nodes-base.if',
typeVersion: 1,
id: 'uuid-2',
position: [650, 350],
},
{
@ -550,6 +567,7 @@ describe('WorkflowExecute', () => {
name: 'Merge1',
type: 'n8n-nodes-base.merge',
typeVersion: 1,
id: 'uuid-3',
position: [1150, 450],
},
{
@ -567,6 +585,7 @@ describe('WorkflowExecute', () => {
name: 'Set1',
type: 'n8n-nodes-base.set',
typeVersion: 1,
id: 'uuid-4',
position: [450, 450],
},
{
@ -584,6 +603,7 @@ describe('WorkflowExecute', () => {
name: 'Set2',
type: 'n8n-nodes-base.set',
typeVersion: 1,
id: 'uuid-1',
position: [800, 250],
},
],
@ -672,6 +692,7 @@ describe('WorkflowExecute', () => {
workflowData: {
nodes: [
{
id: 'uuid-1',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
@ -679,6 +700,7 @@ describe('WorkflowExecute', () => {
position: [250, 300],
},
{
id: 'uuid-2',
parameters: {},
name: 'Merge',
type: 'n8n-nodes-base.merge',
@ -686,6 +708,7 @@ describe('WorkflowExecute', () => {
position: [800, 450],
},
{
id: 'uuid-3',
parameters: {},
name: 'Merge1',
type: 'n8n-nodes-base.merge',
@ -693,6 +716,7 @@ describe('WorkflowExecute', () => {
position: [1000, 300],
},
{
id: 'uuid-4',
parameters: {
conditions: {
boolean: [
@ -716,6 +740,7 @@ describe('WorkflowExecute', () => {
alwaysOutputData: false,
},
{
id: 'uuid-5',
parameters: {
values: {
number: [
@ -738,6 +763,7 @@ describe('WorkflowExecute', () => {
position: [450, 300],
},
{
id: 'uuid-6',
parameters: {
values: {
number: [
@ -761,6 +787,7 @@ describe('WorkflowExecute', () => {
position: [450, 450],
},
{
id: 'uuid-7',
parameters: {
values: {
number: [
@ -889,6 +916,7 @@ describe('WorkflowExecute', () => {
workflowData: {
nodes: [
{
id: 'uuid-1',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
@ -896,6 +924,7 @@ describe('WorkflowExecute', () => {
position: [250, 300],
},
{
id: 'uuid-2',
parameters: {
conditions: {
number: [
@ -913,6 +942,7 @@ describe('WorkflowExecute', () => {
position: [650, 300],
},
{
id: 'uuid-3',
parameters: {
values: {
string: [],
@ -931,6 +961,7 @@ describe('WorkflowExecute', () => {
position: [850, 450],
},
{
id: 'uuid-4',
parameters: {
values: {
number: [
@ -948,6 +979,7 @@ describe('WorkflowExecute', () => {
position: [450, 300],
},
{
id: 'uuid-5',
parameters: {},
name: 'Merge',
type: 'n8n-nodes-base.merge',
@ -1034,6 +1066,7 @@ describe('WorkflowExecute', () => {
workflowData: {
nodes: [
{
id: 'uuid-1',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
@ -1041,6 +1074,7 @@ describe('WorkflowExecute', () => {
position: [250, 300],
},
{
id: 'uuid-2',
parameters: {
values: {
number: [
@ -1057,6 +1091,7 @@ describe('WorkflowExecute', () => {
position: [450, 300],
},
{
id: 'uuid-3',
parameters: {},
name: 'Merge',
type: 'n8n-nodes-base.merge',
@ -1064,6 +1099,7 @@ describe('WorkflowExecute', () => {
position: [1050, 250],
},
{
id: 'uuid-4',
parameters: {
conditions: {
number: [
@ -1081,6 +1117,7 @@ describe('WorkflowExecute', () => {
position: [650, 300],
},
{
id: 'uuid-5',
parameters: {},
name: 'NoOpTrue',
type: 'n8n-nodes-base.noOp',
@ -1088,6 +1125,7 @@ describe('WorkflowExecute', () => {
position: [850, 150],
},
{
id: 'uuid-6',
parameters: {},
name: 'NoOpFalse',
type: 'n8n-nodes-base.noOp',
@ -1177,6 +1215,7 @@ describe('WorkflowExecute', () => {
workflowData: {
nodes: [
{
id: 'uuid-1',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
@ -1184,6 +1223,7 @@ describe('WorkflowExecute', () => {
position: [240, 300],
},
{
id: 'uuid-2',
parameters: {},
name: 'VersionTest1a',
type: 'n8n-nodes-base.versionTest',
@ -1191,6 +1231,7 @@ describe('WorkflowExecute', () => {
position: [460, 300],
},
{
id: 'uuid-3',
parameters: {
versionTest: 11,
},
@ -1200,6 +1241,7 @@ describe('WorkflowExecute', () => {
position: [680, 300],
},
{
id: 'uuid-4',
parameters: {},
name: 'VersionTest2a',
type: 'n8n-nodes-base.versionTest',
@ -1207,6 +1249,7 @@ describe('WorkflowExecute', () => {
position: [880, 300],
},
{
id: 'uuid-5',
parameters: {
versionTest: 22,
},

View file

@ -259,6 +259,12 @@ export interface IWorkflowDataUpdate {
pinData?: IPinData;
}
export interface IWorkflowToShare extends IWorkflowDataUpdate {
meta?: {
instanceId: string;
};
}
export interface IWorkflowTemplate {
id: number;
name: string;
@ -866,7 +872,6 @@ export interface IRootState {
workflowExecutionData: IExecutionResponse | null;
lastSelectedNode: string | null;
lastSelectedNodeOutputIndex: number | null;
nodeIndex: Array<string | null>;
nodeViewOffsetPosition: XYPosition;
nodeViewMoveInProgress: boolean;
selectedNodes: INodeUi[];

View file

@ -117,7 +117,7 @@ export default mixins(showMessage, workflowHelpers).extend({
this.$data.isSaving = true;
const saved = await this.saveAsNewWorkflow({name, tags: this.currentTagIds, resetWebhookUrls: true, openInNewWindow: true});
const saved = await this.saveAsNewWorkflow({name, tags: this.currentTagIds, resetWebhookUrls: true, openInNewWindow: true, resetNodeIds: true});
if (saved) {
this.closeDialog();

View file

@ -183,7 +183,7 @@ import {
IExecutionResponse,
IWorkflowDataUpdate,
IMenuItem,
IUser,
IWorkflowToShare,
} from '../Interface';
import ExecutionsList from '@/components/ExecutionsList.vue';
@ -442,7 +442,6 @@ export default mixins(
return;
}
this.$telemetry.track('User imported workflow', { source: 'file', workflow_id: this.$store.getters.workflowId });
this.$root.$emit('importWorkflowData', { data: worflowData });
};
@ -513,8 +512,11 @@ export default mixins(
data.id = parseInt(data.id, 10);
}
const exportData: IWorkflowDataUpdate = {
const exportData: IWorkflowToShare = {
...data,
meta: {
instanceId: this.$store.getters.instanceId,
},
tags: (tags || []).map(tagId => {
const {usageCount, ...tag} = this.$store.getters["tags/getTagById"](tagId);

View file

@ -1,5 +1,5 @@
<template>
<div class="node-wrapper" :style="nodePosition">
<div class="node-wrapper" :style="nodePosition" :id="nodeId">
<div class="select-background" v-show="isSelected"></div>
<div :class="{'node-default': true, 'touch-active': isTouchActive, 'is-touch-device': isTouchDevice}" :data-name="data.name" :ref="data.name">
<div :class="nodeClass" :style="nodeStyle" @dblclick="setNodeActive" @click.left="mouseLeftClick" v-touch:start="touchStart" v-touch:end="touchEnd">

View file

@ -1,5 +1,5 @@
<template>
<div class="sticky-wrapper" :style="stickyPosition">
<div class="sticky-wrapper" :style="stickyPosition" :id="nodeId">
<div
:class="{'sticky-default': true, 'touch-active': isTouchActive, 'is-touch-device': isTouchDevice}"
:style="stickySize"
@ -18,7 +18,7 @@
:height="node.parameters.height"
:width="node.parameters.width"
:scale="nodeViewScale"
:id="nodeIndex"
:id="node.id"
:readOnly="isReadOnly"
:defaultText="defaultText"
:editMode="isActive && !isReadOnly"
@ -165,9 +165,9 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
if (!this.isSelected && this.node) {
this.$emit('nodeSelected', this.node.name, false, true);
}
const nodeIndex = this.$store.getters.getNodeIndex(this.data.name);
const nodeIdName = `node-${nodeIndex}`;
this.instance.destroyDraggable(nodeIdName); // todo
if (this.node) {
this.instance.destroyDraggable(this.node.id); // todo avoid destroying if possible
}
},
onResize({height, width, dX, dY}: { width: number, height: number, dX: number, dY: number }) {
if (!this.node) {

View file

@ -3,12 +3,10 @@ import { INodeUi, XYPosition } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
import { nodeIndex } from '@/components/mixins/nodeIndex';
import { getMousePosition, getRelativePosition } from '@/views/canvasHelpers';
export const mouseSelect = mixins(
deviceSupportHelpers,
nodeIndex,
).extend({
data () {
return {
@ -171,18 +169,15 @@ export const mouseSelect = mixins(
this.updateSelectBox(e);
},
nodeDeselected (node: INodeUi) {
this.$store.commit('removeNodeFromSelection', node);
const nodeElement = `node-${this.getNodeIndex(node.name)}`;
// @ts-ignore
this.instance.removeFromDragSelection(nodeElement);
this.instance.removeFromDragSelection(node.id);
},
nodeSelected (node: INodeUi) {
this.$store.commit('addSelectedNode', node);
const nodeElement = `node-${this.getNodeIndex(node.name)}`;
// @ts-ignore
this.instance.addToDragSelection(nodeElement);
this.instance.addToDragSelection(node.id);
},
deselectAllNodes () {
// @ts-ignore

View file

@ -2,12 +2,10 @@ import mixins from 'vue-typed-mixins';
// @ts-ignore
import normalizeWheel from 'normalize-wheel';
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
import { nodeIndex } from '@/components/mixins/nodeIndex';
import { getMousePosition } from '@/views/canvasHelpers';
export const moveNodeWorkflow = mixins(
deviceSupportHelpers,
nodeIndex,
).extend({
data () {
return {

View file

@ -3,8 +3,7 @@ import { IEndpointOptions, INodeUi, XYPosition } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
import { nodeIndex } from '@/components/mixins/nodeIndex';
import { NODE_NAME_PREFIX, NO_OP_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
import { NO_OP_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
import * as CanvasHelpers from '@/views/canvasHelpers';
import { Endpoint } from 'jsplumb';
@ -15,7 +14,6 @@ import { getStyleTokenValue } from '../helpers';
export const nodeBase = mixins(
deviceSupportHelpers,
nodeIndex,
).extend({
mounted () {
// Initialize the node
@ -28,10 +26,7 @@ export const nodeBase = mixins(
return this.$store.getters.getNodeByName(this.name);
},
nodeId (): string {
return NODE_NAME_PREFIX + this.nodeIndex;
},
nodeIndex (): string {
return this.$store.getters.getNodeIndex(this.data.name).toString();
return this.data.id;
},
},
props: [
@ -62,7 +57,7 @@ export const nodeBase = mixins(
const anchorPosition = CanvasHelpers.ANCHOR_POSITIONS.input[nodeTypeData.inputs.length][index];
const newEndpointData: IEndpointOptions = {
uuid: CanvasHelpers.getInputEndpointUUID(this.nodeIndex, index),
uuid: CanvasHelpers.getInputEndpointUUID(this.nodeId, index),
anchor: anchorPosition,
maxConnections: -1,
endpoint: 'Rectangle',
@ -71,7 +66,7 @@ export const nodeBase = mixins(
isSource: false,
isTarget: !this.isReadOnly && nodeTypeData.inputs.length > 1, // only enabled for nodes with multiple inputs.. otherwise attachment handled by connectionDrag event in NodeView,
parameters: {
nodeIndex: this.nodeIndex,
nodeId: this.nodeId,
type: inputName,
index,
},
@ -130,7 +125,7 @@ export const nodeBase = mixins(
const anchorPosition = CanvasHelpers.ANCHOR_POSITIONS.output[nodeTypeData.outputs.length][index];
const newEndpointData: IEndpointOptions = {
uuid: CanvasHelpers.getOutputEndpointUUID(this.nodeIndex, index),
uuid: CanvasHelpers.getOutputEndpointUUID(this.nodeId, index),
anchor: anchorPosition,
maxConnections: -1,
endpoint: 'Dot',
@ -140,7 +135,7 @@ export const nodeBase = mixins(
isTarget: false,
enabled: !this.isReadOnly,
parameters: {
nodeIndex: this.nodeIndex,
nodeId: this.nodeId,
type: inputName,
index,
},
@ -166,7 +161,7 @@ export const nodeBase = mixins(
if (!this.isReadOnly) {
const plusEndpointData: IEndpointOptions = {
uuid: CanvasHelpers.getOutputEndpointUUID(this.nodeIndex, index),
uuid: CanvasHelpers.getOutputEndpointUUID(this.nodeId, index),
anchor: anchorPosition,
maxConnections: -1,
endpoint: 'N8nPlus',
@ -187,7 +182,7 @@ export const nodeBase = mixins(
hover: true, // hack to distinguish hover state
},
parameters: {
nodeIndex: this.nodeIndex,
nodeId: this.nodeId,
type: inputName,
index,
},
@ -258,8 +253,7 @@ export const nodeBase = mixins(
// create a proper solution
let newNodePositon: XYPosition;
moveNodes.forEach((node: INodeUi) => {
const nodeElement = `node-${this.getNodeIndex(node.name)}`;
const element = document.getElementById(nodeElement);
const element = document.getElementById(node.id);
if (element === null) {
return;
}

View file

@ -1,18 +0,0 @@
import Vue from 'vue';
export const nodeIndex = Vue.extend({
methods: {
getNodeIndex (nodeName: string): string {
let uniqueId = this.$store.getters.getNodeIndex(nodeName);
if (uniqueId === -1) {
this.$store.commit('addToNodeIndex', nodeName);
uniqueId = this.$store.getters.getNodeIndex(nodeName);
}
// We return as string as draggable and jsplumb seems to make problems
// when numbers are given
return uniqueId.toString();
},
},
});

View file

@ -53,7 +53,7 @@ import { showMessage } from '@/components/mixins/showMessage';
import { isEqual } from 'lodash';
import mixins from 'vue-typed-mixins';
import { v4 as uuidv4 } from 'uuid';
import { v4 as uuid } from 'uuid';
export const workflowHelpers = mixins(
externalHooks,
@ -666,7 +666,7 @@ export const workflowHelpers = mixins(
}
},
async saveAsNewWorkflow ({name, tags, resetWebhookUrls, openInNewWindow}: {name?: string, tags?: string[], resetWebhookUrls?: boolean, openInNewWindow?: boolean} = {}, redirect = true): Promise<boolean> {
async saveAsNewWorkflow ({name, tags, resetWebhookUrls, resetNodeIds, openInNewWindow}: {name?: string, tags?: string[], resetWebhookUrls?: boolean, openInNewWindow?: boolean, resetNodeIds?: boolean} = {}, redirect = true): Promise<boolean> {
try {
this.$store.commit('addActiveAction', 'workflowSaving');
@ -674,10 +674,19 @@ export const workflowHelpers = mixins(
// make sure that the new ones are not active
workflowDataRequest.active = false;
const changedNodes = {} as IDataObject;
if (resetNodeIds) {
workflowDataRequest.nodes = workflowDataRequest.nodes!.map(node => {
node.id = uuid();
return node;
});
}
if (resetWebhookUrls) {
workflowDataRequest.nodes = workflowDataRequest.nodes!.map(node => {
if (node.webhookId) {
node.webhookId = uuidv4();
node.webhookId = uuid();
changedNodes[node.name] = node.webhookId;
}
return node;

View file

@ -2,7 +2,6 @@ export const MAX_WORKFLOW_SIZE = 16777216; // Workflow size limit in bytes
export const MAX_WORKFLOW_PINNED_DATA_SIZE = 12582912; // Workflow pinned data size limit in bytes
export const MAX_DISPLAY_DATA_SIZE = 204800;
export const MAX_DISPLAY_ITEMS_AUTO_ALL = 250;
export const NODE_NAME_PREFIX = 'node-';
export const PLACEHOLDER_FILLED_AT_EXECUTION_TIME = '[filled at execution time]';

View file

@ -79,7 +79,6 @@ const state: IRootState = {
workflowExecutionData: null,
lastSelectedNode: null,
lastSelectedNodeOutputIndex: null,
nodeIndex: [],
nodeViewOffsetPosition: [0, 0],
nodeViewMoveInProgress: false,
selectedNodes: [],
@ -533,17 +532,6 @@ export const store = new Vuex.Store({
Vue.set(state.nodeMetadata[node.name], 'parametersLastUpdatedAt', Date.now());
},
// Node-Index
addToNodeIndex(state, nodeName: string) {
state.nodeIndex.push(nodeName);
},
setNodeIndex(state, newData: { index: number, name: string | null }) {
state.nodeIndex[newData.index] = newData.name;
},
resetNodeIndex(state) {
Vue.set(state, 'nodeIndex', []);
},
// Node-View
setNodeViewMoveInProgress(state, value: boolean) {
state.nodeViewMoveInProgress = value;
@ -821,14 +809,6 @@ export const store = new Vuex.Store({
});
},
// Node-Index
getNodeIndex: (state) => (nodeName: string): number => {
return state.nodeIndex.indexOf(nodeName);
},
getNodeNameByIndex: (state) => (index: number): string | null => {
return state.nodeIndex[index];
},
getNodeViewOffsetPosition: (state): XYPosition => {
return state.nodeViewOffsetPosition;
},
@ -838,7 +818,16 @@ export const store = new Vuex.Store({
// Selected Nodes
getSelectedNodes: (state): INodeUi[] => {
return state.selectedNodes;
const seen = new Set();
return state.selectedNodes.filter((node: INodeUi) => {
// dedupe for instances when same node is selected in different ways
if (!seen.has(node.id)) {
seen.add(node.id);
return true;
}
return false;
});
},
isNodeSelected: (state) => (nodeName: string): boolean => {
let index;
@ -874,6 +863,9 @@ export const store = new Vuex.Store({
getNodeByName: (state, getters) => (nodeName: string): INodeUi | null => {
return getters.nodesByName[nodeName] || null;
},
getNodeById: (state, getters) => (nodeId: string): INodeUi | undefined => {
return state.workflow.nodes.find((node: INodeUi) => node.id === nodeId);
},
nodesIssuesExist: (state): boolean => {
for (const node of state.workflow.nodes) {
if (node.issues === undefined || Object.keys(node.issues).length === 0) {

View file

@ -21,7 +21,7 @@
class="node-view"
:style="workflowStyle"
>
<div v-for="nodeData in nodes" :key="getNodeIndex(nodeData.name)">
<div v-for="nodeData in nodes" :key="nodeData.id">
<node
v-if="nodeData.type !== STICKY_NODE_TYPE"
@duplicateNode="duplicateNode"
@ -32,8 +32,7 @@
@runWorkflow="onRunNode"
@moved="onNodeMoved"
@run="onNodeRun"
:id="'node-' + getNodeIndex(nodeData.name)"
:key="getNodeIndex(nodeData.name)"
:key="nodeData.id"
:name="nodeData.name"
:isReadOnly="isReadOnly"
:instance="instance"
@ -46,7 +45,7 @@
@deselectNode="nodeDeselectedByName"
@nodeSelected="nodeSelectedByName"
@removeNode="removeNode"
:id="'node-' + getNodeIndex(nodeData.name)"
:key="nodeData.id"
:name="nodeData.name"
:isReadOnly="isReadOnly"
:instance="instance"
@ -161,7 +160,6 @@ import {
MODAL_CANCEL,
MODAL_CLOSE,
MODAL_CONFIRMED,
NODE_NAME_PREFIX,
NODE_OUTPUT_DEFAULT_KEY,
ONBOARDING_CALL_SIGNUP_MODAL_KEY,
ONBOARDING_PROMPT_TIMEBOX,
@ -195,7 +193,7 @@ import Sticky from '@/components/Sticky.vue';
import * as CanvasHelpers from './canvasHelpers';
import mixins from 'vue-typed-mixins';
import { v4 as uuidv4} from 'uuid';
import { v4 as uuid } from 'uuid';
import {
IConnection,
IConnections,
@ -228,6 +226,7 @@ import {
ITag,
IWorkflowTemplate,
IExecutionsSummary,
IWorkflowToShare,
} from '../Interface';
import { mapGetters } from 'vuex';
@ -639,6 +638,7 @@ export default mixins(
}
this.resetWorkspace();
data.workflow.nodes = CanvasHelpers.getFixedNodesList(data.workflow.nodes);
await this.addNodes(data.workflow.nodes, data.workflow.connections);
if (data.workflow.pinData) {
@ -1094,7 +1094,14 @@ export default mixins(
copySelectedNodes (isCut: boolean) {
this.getSelectedNodesToSave().then((data) => {
const nodeData = JSON.stringify(data, null, 2);
const workflowToCopy: IWorkflowToShare = {
meta: {
instanceId: this.$store.getters.instanceId,
},
...data,
};
const nodeData = JSON.stringify(workflowToCopy, null, 2);
this.copyToClipboard(nodeData);
if (data.nodes.length > 0) {
if(!isCut){
@ -1290,11 +1297,7 @@ export default mixins(
}
}
this.$telemetry.track('User pasted nodes', {
workflow_id: this.$store.getters.workflowId,
});
return this.importWorkflowData(workflowData!, false);
return this.importWorkflowData(workflowData!, false, 'paste');
},
// Returns the workflow data from a given URL. If no data gets found or
@ -1315,13 +1318,11 @@ export default mixins(
}
this.stopLoading();
this.$telemetry.track('User imported workflow', { source: 'url', workflow_id: this.$store.getters.workflowId });
return workflowData;
},
// Imports the given workflow data into the current workflow
async importWorkflowData (workflowData: IWorkflowDataUpdate, importTags = true): Promise<void> {
async importWorkflowData (workflowData: IWorkflowToShare, importTags = true, source: string): Promise<void> {
// If it is JSON check if it looks on the first look like data we can use
if (
!workflowData.hasOwnProperty('nodes') ||
@ -1331,6 +1332,40 @@ export default mixins(
}
try {
const nodeIdMap: {[prev: string]: string} = {};
if (workflowData.nodes) {
// set all new ids when pasting/importing workflows
workflowData.nodes.forEach((node: INode) => {
if (node.id) {
const newId = uuid();
nodeIdMap[newId] = node.id;
node.id = newId;
}
else {
node.id = uuid();
}
});
}
const currInstanceId = this.$store.getters.instanceId;
const nodeGraph = JSON.stringify(
TelemetryHelpers.generateNodesGraph(workflowData as IWorkflowBase,
this.getNodeTypes(),
{
nodeIdMap,
sourceInstanceId: workflowData.meta && workflowData.meta.instanceId !== currInstanceId? workflowData.meta.instanceId: '',
}).nodeGraph,
);
if (source === 'paste') {
this.$telemetry.track('User pasted nodes', {
workflow_id: this.$store.getters.workflowId,
node_graph_string: nodeGraph,
});
} else {
this.$telemetry.track('User imported workflow', { source, workflow_id: this.$store.getters.workflowId, node_graph_string: nodeGraph });
}
// By default we automatically deselect all the currently
// selected nodes and select the new ones
this.deselectAllNodes();
@ -1500,6 +1535,7 @@ export default mixins(
}
const newNodeData: INodeUi = {
id: uuid(),
name: nodeTypeData.defaults.name as string,
type: nodeTypeData.name,
typeVersion: Array.isArray(nodeTypeData.version)
@ -1564,7 +1600,7 @@ export default mixins(
});
if (nodeTypeData.webhooks && nodeTypeData.webhooks.length) {
newNodeData.webhookId = uuidv4();
newNodeData.webhookId = uuid();
}
await this.addNodes([newNodeData]);
@ -1676,8 +1712,12 @@ export default mixins(
// Get the node and set it as active that new nodes
// which get created get automatically connected
// to it.
const sourceNodeName = this.$store.getters.getNodeNameByIndex(info.sourceId.slice(NODE_NAME_PREFIX.length));
this.$store.commit('setLastSelectedNode', sourceNodeName);
const sourceNode = this.$store.getters.getNodeById(info.sourceId) as INodeUi | null;
if (!sourceNode) {
return;
}
this.$store.commit('setLastSelectedNode', sourceNode.name);
this.$store.commit('setLastSelectedNodeOutputIndex', info.index);
this.newNodeInsertPosition = null;
@ -1696,7 +1736,8 @@ export default mixins(
}
if (this.pullConnActiveNodeName) {
const sourceNodeName = this.$store.getters.getNodeNameByIndex(connection.sourceId.slice(NODE_NAME_PREFIX.length));
const sourceNode = this.$store.getters.getNodeById(connection.sourceId);
const sourceNodeName = sourceNode.name;
const outputIndex = connection.getParameters().index;
this.connectTwoNodes(sourceNodeName, outputIndex, this.pullConnActiveNodeName, 0);
@ -1720,8 +1761,8 @@ export default mixins(
// @ts-ignore
const targetInfo = info.dropEndpoint.getParameters();
const sourceNodeName = this.$store.getters.getNodeNameByIndex(sourceInfo.nodeIndex);
const targetNodeName = this.$store.getters.getNodeNameByIndex(targetInfo.nodeIndex);
const sourceNodeName = this.$store.getters.getNodeById(sourceInfo.nodeId).name;
const targetNodeName = this.$store.getters.getNodeById(targetInfo.nodeId).name;
// check for duplicates
if (this.getConnection(sourceNodeName, sourceInfo.index, targetNodeName, targetInfo.index)) {
@ -1745,8 +1786,8 @@ export default mixins(
const sourceInfo = info.sourceEndpoint.getParameters();
const targetInfo = info.targetEndpoint.getParameters();
const sourceNodeName = this.$store.getters.getNodeNameByIndex(sourceInfo.nodeIndex);
const targetNodeName = this.$store.getters.getNodeNameByIndex(targetInfo.nodeIndex);
const sourceNodeName = this.$store.getters.getNodeById(sourceInfo.nodeId).name;
const targetNodeName = this.$store.getters.getNodeById(targetInfo.nodeId).name;
info.connection.__meta = {
sourceNodeName,
@ -1872,12 +1913,12 @@ export default mixins(
const connectionInfo = [
{
node: this.$store.getters.getNodeNameByIndex(sourceInfo.nodeIndex),
node: this.$store.getters.getNodeById(sourceInfo.nodeId).name,
type: sourceInfo.type,
index: sourceInfo.index,
},
{
node: this.$store.getters.getNodeNameByIndex(targetInfo.nodeIndex),
node: this.$store.getters.getNodeById(targetInfo.nodeId).name,
type: targetInfo.type,
index: targetInfo.index,
},
@ -1896,7 +1937,8 @@ export default mixins(
this.__removeConnectionByConnectionInfo(info, false);
if (this.pullConnActiveNodeName) { // establish new connection when dragging connection from one node to another
const sourceNodeName = this.$store.getters.getNodeNameByIndex(info.connection.sourceId.slice(NODE_NAME_PREFIX.length));
const sourceNode = this.$store.getters.getNodeById(info.connection.sourceId);
const sourceNodeName = sourceNode.name;
const outputIndex = info.connection.getParameters().index;
this.connectTwoNodes(sourceNodeName, outputIndex, this.pullConnActiveNodeName, 0);
@ -1940,11 +1982,14 @@ export default mixins(
const nodeType = this.$store.getters['nodeTypes/getNodeType'](node.type, node.typeVersion) as INodeTypeDescription | null;
if (nodeType && nodeType.inputs && nodeType.inputs.length === 1) {
this.pullConnActiveNodeName = node.name;
const endpoint = this.instance.getEndpoint(this.getInputEndpointUUID(nodeName, 0));
const endpointUUID = this.getInputEndpointUUID(nodeName, 0);
if (endpointUUID) {
const endpoint = this.instance.getEndpoint(endpointUUID);
CanvasHelpers.showDropConnectionState(connection, endpoint);
CanvasHelpers.showDropConnectionState(connection, endpoint);
return true;
return true;
}
}
}
}
@ -1992,7 +2037,10 @@ export default mixins(
this.$store.commit('setStateDirty', false);
await this.addNodes([{...CanvasHelpers.DEFAULT_START_NODE}]);
await this.addNodes([{
id: uuid(),
...CanvasHelpers.DEFAULT_START_NODE,
}]);
this.nodeSelectedByName(CanvasHelpers.DEFAULT_START_NODE.name, false);
@ -2007,6 +2055,7 @@ export default mixins(
this.$nextTick(async () => {
await this.addNodes([
{
id: uuid(),
...CanvasHelpers.WELCOME_STICKY_NODE,
parameters: {
// Use parameters from the template but add translated content
@ -2108,17 +2157,33 @@ export default mixins(
}
});
},
getOutputEndpointUUID(nodeName: string, index: number) {
return CanvasHelpers.getOutputEndpointUUID(this.getNodeIndex(nodeName), index);
getOutputEndpointUUID(nodeName: string, index: number): string | null {
const node = this.$store.getters.getNodeByName(nodeName);
if (!node) {
return null;
}
return CanvasHelpers.getOutputEndpointUUID(node.id, index);
},
getInputEndpointUUID(nodeName: string, index: number) {
return CanvasHelpers.getInputEndpointUUID(this.getNodeIndex(nodeName), index);
const node = this.$store.getters.getNodeByName(nodeName);
if (!node) {
return null;
}
return CanvasHelpers.getInputEndpointUUID(node.id, index);
},
__addConnection (connection: [IConnection, IConnection], addVisualConnection = false) {
if (addVisualConnection === true) {
const outputUuid = this.getOutputEndpointUUID(connection[0].node, connection[0].index);
const inputUuid = this.getInputEndpointUUID(connection[1].node, connection[1].index);
if (!outputUuid || !inputUuid) {
return;
}
const uuid: [string, string] = [
this.getOutputEndpointUUID(connection[0].node, connection[0].index),
this.getInputEndpointUUID(connection[1].node, connection[1].index),
outputUuid,
inputUuid,
];
// Create connections in DOM
@ -2140,10 +2205,12 @@ export default mixins(
},
__removeConnection (connection: [IConnection, IConnection], removeVisualConnection = false) {
if (removeVisualConnection === true) {
const sourceId = this.$store.getters.getNodeByName(connection[0].node);
const targetId = this.$store.getters.getNodeByName(connection[1].node);
// @ts-ignore
const connections = this.instance.getConnections({
source: NODE_NAME_PREFIX + this.getNodeIndex(connection[0].node),
target: NODE_NAME_PREFIX + this.getNodeIndex(connection[1].node),
source: sourceId,
target: targetId,
});
// @ts-ignore
@ -2175,12 +2242,12 @@ export default mixins(
const connectionInfo = [
{
node: this.$store.getters.getNodeNameByIndex(sourceInfo.nodeIndex),
node: this.$store.getters.getNodeById(sourceInfo.nodeId).name,
type: sourceInfo.type,
index: sourceInfo.index,
},
{
node: this.$store.getters.getNodeNameByIndex(targetInfo.nodeIndex),
node: this.$store.getters.getNodeById(targetInfo.nodeId).name,
type: targetInfo.type,
index: targetInfo.index,
},
@ -2208,6 +2275,7 @@ export default mixins(
// Deep copy the data so that data on lower levels of the node-properties do
// not share objects
const newNodeData = JSON.parse(JSON.stringify(this.getNodeDataToSave(node)));
newNodeData.id = uuid();
// Check if node-name is unique else find one that is
newNodeData.name = this.getUniqueNodeName({
@ -2223,7 +2291,7 @@ export default mixins(
if (newNodeData.webhookId) {
// Make sure that the node gets a new unique webhook-ID
newNodeData.webhookId = uuidv4();
newNodeData.webhookId = uuid();
}
await this.addNodes([newNodeData]);
@ -2248,14 +2316,17 @@ export default mixins(
this.$telemetry.track('User duplicated node', { node_type: node.type, workflow_id: this.$store.getters.workflowId });
},
getJSPlumbConnection (sourceNodeName: string, sourceOutputIndex: number, targetNodeName: string, targetInputIndex: number): Connection | undefined {
const sourceIndex = this.getNodeIndex(sourceNodeName);
const sourceId = `${NODE_NAME_PREFIX}${sourceIndex}`;
const sourceNode = this.$store.getters.getNodeByName(sourceNodeName) as INodeUi;
const targetNode = this.$store.getters.getNodeByName(targetNodeName) as INodeUi;
if (!sourceNode || !targetNode) {
return;
}
const targetIndex = this.getNodeIndex(targetNodeName);
const targetId = `${NODE_NAME_PREFIX}${targetIndex}`;
const sourceId = sourceNode.id;
const targetId = targetNode.id;
const sourceEndpoint = CanvasHelpers.getOutputEndpointUUID(sourceIndex, sourceOutputIndex);
const targetEndpoint = CanvasHelpers.getInputEndpointUUID(targetIndex, targetInputIndex);
const sourceEndpoint = CanvasHelpers.getOutputEndpointUUID(sourceId, sourceOutputIndex);
const targetEndpoint = CanvasHelpers.getInputEndpointUUID(targetId, targetInputIndex);
// @ts-ignore
const connections = this.instance.getConnections({
@ -2269,9 +2340,8 @@ export default mixins(
});
},
getJSPlumbEndpoints (nodeName: string): Endpoint[] {
const nodeIndex = this.getNodeIndex(nodeName);
const nodeId = `${NODE_NAME_PREFIX}${nodeIndex}`;
return this.instance.getEndpoints(nodeId);
const node = this.$store.getters.getNodeByName(nodeName);
return this.instance.getEndpoints(node.id);
},
getPlusEndpoint (nodeName: string, outputIndex: number): Endpoint | undefined {
const endpoints = this.getJSPlumbEndpoints(nodeName);
@ -2279,15 +2349,15 @@ export default mixins(
return endpoints.find((endpoint: Endpoint) => endpoint.type === 'N8nPlus' && endpoint.__meta && endpoint.__meta.index === outputIndex);
},
getIncomingOutgoingConnections(nodeName: string): {incoming: Connection[], outgoing: Connection[]} {
const name = `${NODE_NAME_PREFIX}${this.$store.getters.getNodeIndex(nodeName)}`;
const node = this.$store.getters.getNodeByName(nodeName);
// @ts-ignore
const outgoing = this.instance.getConnections({
source: name,
source: node.id,
}) as Connection[];
// @ts-ignore
const incoming = this.instance.getConnections({
target: name,
target: node.id,
}) as Connection[];
return {
@ -2305,8 +2375,8 @@ export default mixins(
},
onNodeRun ({name, data, waiting}: {name: string, data: ITaskData[] | null, waiting: boolean}) {
const sourceNodeName = name;
const sourceIndex = this.$store.getters.getNodeIndex(sourceNodeName);
const sourceId = `${NODE_NAME_PREFIX}${sourceIndex}`;
const sourceNode = this.$store.getters.getNodeByName(sourceNodeName);
const sourceId = sourceNode.id;
if (data === null || data.length === 0 || waiting) {
// @ts-ignore
@ -2438,18 +2508,15 @@ export default mixins(
}
setTimeout(() => {
const nodeIndex = this.$store.getters.getNodeIndex(nodeName);
const nodeIdName = `node-${nodeIndex}`;
// Suspend drawing
this.instance.setSuspendDrawing(true);
// Remove all endpoints and the connections in jsplumb
this.instance.removeAllEndpoints(nodeIdName);
this.instance.removeAllEndpoints(node.id);
// Remove the draggable
// @ts-ignore
this.instance.destroyDraggable(nodeIdName);
this.instance.destroyDraggable(node.id);
// Remove the connections in data
this.$store.commit('removeAllNodeConnection', node);
@ -2465,10 +2532,6 @@ export default mixins(
// Remove node from selected index if found in it
this.$store.commit('removeNodeFromSelection', node);
// Remove from node index
if (nodeIndex !== -1) {
this.$store.commit('setNodeIndex', { index: nodeIndex, name: null });
}
}, 0); // allow other events to finish like drag stop
},
valueChanged (parameterData: IUpdateInformation) {
@ -2557,7 +2620,7 @@ export default mixins(
try {
const nodes = this.$store.getters.allNodes as INodeUi[];
// @ts-ignore
nodes.forEach((node: INodeUi) => this.instance.destroyDraggable(`${NODE_NAME_PREFIX}${this.$store.getters.getNodeIndex(node.name)}`));
nodes.forEach((node: INodeUi) => this.instance.destroyDraggable(node.id));
this.instance.deleteEveryEndpoint();
} catch (e) {}
@ -2621,6 +2684,10 @@ export default mixins(
let nodeType: INodeTypeDescription | null;
let foundNodeIssues: INodeIssues | null;
nodes.forEach((node) => {
if (!node.id) {
node.id = uuid();
}
nodeType = this.$store.getters['nodeTypes/getNodeType'](node.type, node.typeVersion) as INodeTypeDescription | null;
// Make sure that some properties always exist
@ -2919,7 +2986,6 @@ export default mixins(
this.$store.commit('removeActiveAction', 'workflowRunning');
this.$store.commit('setExecutionWaitingForWebhook', false);
this.$store.commit('resetNodeIndex');
this.$store.commit('resetSelectedNodes');
this.$store.commit('setNodeViewOffsetPosition', {newOffset: [0, 0], setStateDirty: false});
@ -2983,19 +3049,24 @@ export default mixins(
}
},
async onImportWorkflowDataEvent(data: IDataObject) {
await this.importWorkflowData(data.data as IWorkflowDataUpdate);
await this.importWorkflowData(data.data as IWorkflowDataUpdate, undefined, 'file');
},
async onImportWorkflowUrlEvent(data: IDataObject) {
const workflowData = await this.getWorkflowDataFromUrl(data.url as string);
if (workflowData !== undefined) {
await this.importWorkflowData(workflowData);
await this.importWorkflowData(workflowData, undefined, 'url');
}
},
addPinDataConnections(pinData: IPinData) {
Object.keys(pinData).forEach((nodeName) => {
const node = this.$store.getters.getNodeByName(nodeName);
if (!node) {
return;
}
// @ts-ignore
const connections = this.instance.getConnections({
source: NODE_NAME_PREFIX + this.getNodeIndex(nodeName),
source: node.id,
}) as Connection[];
connections.forEach((connection) => {
@ -3008,9 +3079,14 @@ export default mixins(
},
removePinDataConnections(pinData: IPinData) {
Object.keys(pinData).forEach((nodeName) => {
const node = this.$store.getters.getNodeByName(nodeName);
if (!node) {
return;
}
// @ts-ignore
const connections = this.instance.getConnections({
source: NODE_NAME_PREFIX + this.getNodeIndex(nodeName),
source: node.id,
}) as Connection[];
connections.forEach(CanvasHelpers.resetConnection);

View file

@ -10,6 +10,7 @@ import {
NodeInputConnections,
INodeTypeDescription,
} from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
export const OVERLAY_DROP_NODE_ID = 'drop-add-node';
export const OVERLAY_MIDPOINT_ARROW_ID = 'midpoint-arrow';
@ -705,12 +706,12 @@ export const addConnectionActionsOverlay = (connection: Connection, onDelete: Fu
]);
};
export const getOutputEndpointUUID = (nodeIndex: string, outputIndex: number) => {
return `${nodeIndex}${OUTPUT_UUID_KEY}${outputIndex}`;
export const getOutputEndpointUUID = (nodeId: string, outputIndex: number) => {
return `${nodeId}${OUTPUT_UUID_KEY}${outputIndex}`;
};
export const getInputEndpointUUID = (nodeIndex: string, inputIndex: number) => {
return `${nodeIndex}${INPUT_UUID_KEY}${inputIndex}`;
export const getInputEndpointUUID = (nodeId: string, inputIndex: number) => {
return `${nodeId}${INPUT_UUID_KEY}${inputIndex}`;
};
export const getFixedNodesList = (workflowNodes: INode[]) => {
@ -728,7 +729,7 @@ export const getFixedNodesList = (workflowNodes: INode[]) => {
});
if (!hasStartNode) {
nodes.push({...DEFAULT_START_NODE});
nodes.push({...DEFAULT_START_NODE, id: uuid() });
}
return nodes;
};

View file

@ -823,6 +823,7 @@ export interface INodeCredentials {
}
export interface INode {
id: string;
name: string;
typeVersion: number;
type: string;
@ -1542,6 +1543,7 @@ export interface INoteGraphItem {
}
export interface INodeGraphItem {
id: string;
type: string;
resource?: string;
operation?: string;
@ -1553,6 +1555,8 @@ export interface INodeGraphItem {
credential_type?: string; // HTTP Request node v2
credential_set?: boolean; // HTTP Request node v2
method?: string; // HTTP Request node v2
src_node_id?: string;
src_instance_id?: string;
}
export interface INodeNameIndex {

View file

@ -22,10 +22,10 @@ export function isNumber(value: unknown): value is number {
}
function getStickyDimensions(note: INode, stickyType: INodeType | undefined) {
const heightProperty = stickyType?.description.properties.find(
const heightProperty = stickyType?.description?.properties.find(
(property) => property.name === 'height',
);
const widthProperty = stickyType?.description.properties.find(
const widthProperty = stickyType?.description?.properties.find(
(property) => property.name === 'width',
);
@ -114,6 +114,10 @@ export function getDomainPath(raw: string, urlParts = URL_PARTS_REGEX): string {
export function generateNodesGraph(
workflow: IWorkflowBase,
nodeTypes: INodeTypes,
options?: {
sourceInstanceId?: string;
nodeIdMap?: { [curr: string]: string };
},
): INodesGraphResult {
const nodesGraph: INodesGraph = {
node_types: [],
@ -149,10 +153,19 @@ export function generateNodesGraph(
otherNodes.forEach((node: INode, index: number) => {
nodesGraph.node_types.push(node.type);
const nodeItem: INodeGraphItem = {
id: node.id,
type: node.type,
position: node.position,
};
if (options?.sourceInstanceId) {
nodeItem.src_instance_id = options.sourceInstanceId;
}
if (node.id && options?.nodeIdMap && options.nodeIdMap[node.id]) {
nodeItem.src_node_id = options.nodeIdMap[node.id];
}
if (node.type === 'n8n-nodes-base.httpRequest' && node.typeVersion === 1) {
try {
nodeItem.domain = new URL(node.parameters.url as string).hostname;
@ -182,7 +195,7 @@ export function generateNodesGraph(
} else {
const nodeType = nodeTypes.getByNameAndVersion(node.type);
nodeType?.description.properties.forEach((property) => {
nodeType?.description?.properties?.forEach((property) => {
if (
property.name === 'operation' ||
property.name === 'resource' ||
@ -212,7 +225,7 @@ export function generateNodesGraph(
});
});
});
} catch (_) {
} catch (e) {
return { nodeGraph: nodesGraph, nameIndices: nodeNameAndIndex, webhookNodeNames };
}

View file

@ -21,6 +21,7 @@ describe('Expression', () => {
name: 'node',
typeVersion: 1,
type: 'test.set',
id: 'uuid-1234',
position: [0, 0],
parameters: {}
}

View file

@ -613,6 +613,7 @@ describe('RoutingNode', () => {
name: 'test',
type: 'test.set',
typeVersion: 1,
id: 'uuid-1234',
position: [0, 0],
};
@ -1659,6 +1660,7 @@ describe('RoutingNode', () => {
name: 'test',
type: 'test.set',
typeVersion: 1,
id: 'uuid-1234',
position: [0, 0],
};
@ -1831,6 +1833,7 @@ describe('RoutingNode', () => {
name: 'test',
type: 'test.set',
typeVersion: 1,
id: 'uuid-1234',
position: [0, 0],
};

View file

@ -548,6 +548,7 @@ describe('Workflow', () => {
parameters: stubData.parameters,
type: 'test.set',
typeVersion: 1,
id: 'uuid-1234',
position: [100, 100],
};
}
@ -1008,6 +1009,7 @@ describe('Workflow', () => {
parameters: testData.input.Node1.parameters,
type: 'test.set',
typeVersion: 1,
id: 'uuid-1',
position: [100, 100],
},
{
@ -1015,6 +1017,7 @@ describe('Workflow', () => {
parameters: testData.input.Node2.parameters,
type: 'test.set',
typeVersion: 1,
id: 'uuid-2',
position: [100, 200],
},
{
@ -1026,6 +1029,7 @@ describe('Workflow', () => {
: {},
type: 'test.set',
typeVersion: 1,
id: 'uuid-3',
position: [100, 300],
},
{
@ -1037,6 +1041,7 @@ describe('Workflow', () => {
: {},
type: 'test.set',
typeVersion: 1,
id: 'uuid-4',
position: [100, 400],
},
];
@ -1219,6 +1224,7 @@ describe('Workflow', () => {
},
type: 'test.setMulti',
typeVersion: 1,
id: 'uuid-1234',
position: [100, 100],
},
];
@ -1296,6 +1302,7 @@ describe('Workflow', () => {
name: 'Start',
type: 'test.set',
typeVersion: 1,
id: 'uuid-1',
position: [240, 300],
},
{
@ -1305,6 +1312,7 @@ describe('Workflow', () => {
name: 'Set',
type: 'test.set',
typeVersion: 1,
id: 'uuid-2',
position: [460, 300],
},
{
@ -1314,6 +1322,7 @@ describe('Workflow', () => {
name: 'Set1',
type: 'test.set',
typeVersion: 1,
id: 'uuid-3',
position: [680, 300],
},
],
@ -1353,6 +1362,7 @@ describe('Workflow', () => {
name: 'Switch',
type: 'test.switch',
typeVersion: 1,
id: 'uuid-1',
position: [460, 300],
},
{
@ -1362,6 +1372,7 @@ describe('Workflow', () => {
name: 'Set',
type: 'test.set',
typeVersion: 1,
id: 'uuid-2',
position: [740, 300],
},
{
@ -1371,6 +1382,7 @@ describe('Workflow', () => {
name: 'Set1',
type: 'test.set',
typeVersion: 1,
id: 'uuid-3',
position: [780, 100],
},
{
@ -1380,6 +1392,7 @@ describe('Workflow', () => {
name: 'Set2',
type: 'test.set',
typeVersion: 1,
id: 'uuid-4',
position: [1040, 260],
},
],
@ -1443,6 +1456,7 @@ describe('Workflow', () => {
name: 'Switch',
type: 'test.switch',
typeVersion: 1,
id: 'uuid-1',
position: [920, 340],
},
{
@ -1450,6 +1464,7 @@ describe('Workflow', () => {
name: 'Start',
type: 'test.set',
typeVersion: 1,
id: 'uuid-2',
position: [240, 300],
},
{
@ -1459,6 +1474,7 @@ describe('Workflow', () => {
name: 'Set1',
type: 'test.set',
typeVersion: 1,
id: 'uuid-3',
position: [700, 340],
},
{
@ -1468,6 +1484,7 @@ describe('Workflow', () => {
name: 'Set',
type: 'test.set',
typeVersion: 1,
id: 'uuid-4',
position: [1220, 300],
},
{
@ -1475,6 +1492,7 @@ describe('Workflow', () => {
name: 'Switch',
type: 'test.switch',
typeVersion: 1,
id: 'uuid-5',
position: [920, 340],
},
],

View file

@ -10,6 +10,7 @@ describe('WorkflowDataProxy', () => {
name: 'Start',
type: 'test.set',
typeVersion: 1,
id: 'uuid-1',
position: [100, 200],
},
{
@ -20,6 +21,7 @@ describe('WorkflowDataProxy', () => {
name: 'Function',
type: 'test.set',
typeVersion: 1,
id: 'uuid-2',
position: [280, 200],
},
{
@ -36,6 +38,7 @@ describe('WorkflowDataProxy', () => {
name: 'Rename',
type: 'test.set',
typeVersion: 1,
id: 'uuid-3',
position: [460, 200],
},
];