Add tagging of workflows (#1647)

* clean up dropdown

* clean up focusoncreate

*  Ignore mistaken ID in POST /workflows

*  Fix undefined tag ID in PATCH /workflows

*  Shorten response for POST /tags

* remove scss mixins

* clean up imports

*  Implement validation with class-validator

* address ivan's comments

* implement modals

* Fix lint issues

* fix disabling shortcuts

* fix focus issues

* fix focus issues

* fix focus issues with modal

* fix linting issues

* use dispatch

* use constants for modal keys

* fix focus

* fix lint issues

* remove unused prop

* add modal root

* fix lint issues

* remove unused methods

* fix shortcut

* remove max width

*  Fix duplicate entry error for pg and MySQL

* update rename messaging

* update order of buttons

* fix firefox overflow on windows

* fix dropdown height

* 🔨 refactor tag crud controllers

* 🧹 remove unused imports

* use variable for number of items

* fix dropdown spacing

*  Restore type to fix build

*  Fix post-refactor PATCH /workflows/:id

*  Fix PATCH /workflows/:id for zero tags

*  Fix usage count becoming stringified

* address max's comments

* fix filter spacing

* fix blur bug

* address most of ivan's comments

* address tags type concern

* remove defaults

*  return tag id as string

* 🔨 add hooks to tag CUD operations

* 🏎 simplify timestamp pruning

* remove blur event

* fix onblur bug

*  Fix fs import to fix build

* address max's comments

* implement responsive tag container

* fix lint issues

* Set default dates in entities

* 👕 Fix lint in migrations

* update tag limits

* address ivan's comments

* remove rename, refactor header, implement new designs for save, remove responsive tag container

* update styling

* update styling

* implement responsive tag container

* implement header tags edit

* implement header tags edit

* fix lint issues

* implement expandable input

* minor fixes

* minor fixes

* use variable

* rename save as

* duplicate fixes

*  Implement unique workflow names

*  Create /workflows/new endpoint

* minor edit fixes

* lint fixes

* style fixes

* hook up saving name

* hook up tags

* clean up impl

* fix dirty state bug

* update limit

* update notification messages

* on click outside

* fix minor bug with count

* lint fixes

*  Add query string params to /workflows/new

* handle minor edge cases

* handle minor edge cases

* handle minor bugs; fix firefox dropdown issue

* Fix min width

* apply tags only after api success

* remove count fix

* 🚧 Adjust to new qs requirements

* clean up workflow tags impl, fix tags delete bug

* fix minor issue

* fix minor spacing issue

* disable wrap for ops

* fix viewport root; save on click in dropdown

* save button loading when saving name/tags

* implement max width on tags container

* implement cleaner create experience

* disable edit while updating

* codacy hex color

* refactor tags container

* fix clickability

* fix workflow open and count

* clean up structure

* fix up lint issues

*  Create migrations for unique workflow names

* fix button size

* increase workflow name limit for larger screen

* tslint fixes

* disable responsiveness for workflow modal

* rename event

* change min width for tags

* clean up pr

*  Adjust quotes in MySQL migration

*  Adjust quotes in Postgres migration

* address max's comments on styles

* remove success toasts

* add hover mode to name

* minor fixes

* refactor name preview

* fix name input not to jiggle

* finish up name input

* Fix up add tags

* clean up param

* clean up scss

* fix resizing name

* fix resizing name

* fix resize bug

* clean up edit spacing

* ignore on esc

* fix input bug

* focus input on clear

* build

* fix up add tags clickablity

* remove scrollbars

* move into folders

* clean up multiple patch req

* remove padding top from edit

* update tags on enter

* build

* rollout blur on enter behavior

* rollout esc behavior

* fix tags bug when duplicating tags

* move key to reload tags

* update header spacing

* build

* update hex case

* refactor workflow title

* remove unusued prop

* keep focus on error, fix bug on error

* Fix bug with name / tags toggle on error

* impl creating new workflow name

*  Refactor endpoint per new guidelines

* support naming endpoint

*  Refactor to support numeric suffixes

* 👕 Lint migrations for unique workflow names

*  Add migrations set default dates to indexes

* fix connection push bug

*  Lowercase default workflow name

*  Add prefixes to set default dates migration

*  Fix indentation on default dates migrations

*  Add temp ts-ignore for unrelated change

*  Adjust default dates migration for MySQL

Remove change to data column in credentials_entity, already covered by Omar's migration. Also, fix quotes from table prefix addition.

*  Adjust quotes in dates migration for PG

* fix safari color bug

* fix count bug

* fix scroll bugs in dropdown

* expand filter size

* apply box-sizing to main header

* update workflow names in executions to be wrapped by quotes

* fix bug where key is same in dropdown

* fix firefox bug

* move up push connection session

* 🔨 Remove mistakenly added nullable property

* 🔥 Remove unneeded index drop-create (PG)

* 🔥 Remove unneeded table copying

*  Merge dates migration with tags migration

* 🔨 Refactor endpoint and make wf name env

* dropdown colors in firefox

* update colors to use variables

* update thumb color

* change error message

* remove 100 char maximum

* fix bug with saving tags dropdowns multiple times

* update error message when no name

*  Update name missing toast message

*  Update workflow already exists message

* disable saving for executions

* fix bug causing modal to close

* make tags in workflow open clickable

* increase workflow limit to 3

* remove success notifications

* update header spacing

* escape tag names

* update tag and table colors

* remove tags from export

* build

* clean up push connection dependencies

* address ben's comments

* revert tags optional interface

* address comments

* update duplicate message

* build

* fix eol

* add one more eol

*  Update comment

* add hover style for workflow open, fix up font weight

Co-authored-by: Mutasem <mutdmour@gmail.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
This commit is contained in:
Ben Hesseldieck 2021-05-29 20:31:21 +02:00 committed by GitHub
parent 335673d329
commit 05eec87d1d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
92 changed files with 4602 additions and 1236 deletions

View file

@ -3,6 +3,7 @@ root = true
[*]
charset = utf-8
indent_style = tab
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
@ -12,4 +13,4 @@ indent_style = space
indent_size = 2
[*.ts]
quote_type = single
quote_type = single

View file

@ -149,6 +149,15 @@ const config = convict({
},
},
workflows: {
defaultName: {
doc: 'Default name for workflow',
format: String,
default: 'My workflow',
env: 'WORKFLOWS_DEFAULT_NAME',
},
},
executions: {
// By default workflows get always executed in their own process.

View file

@ -1,12 +1,14 @@
import { SQLite, MySQLDb, PostgresDb} from '../src/databases/index';
import * as path from 'path';
import { UserSettings } from 'n8n-core';
import { entities } from '../src/databases/entities';
module.exports = [
{
"name": "sqlite",
"type": "sqlite",
"logging": true,
"entities": Object.values(SQLite),
"database": "./packages/cli/database.sqlite",
"entities": Object.values(entities),
"database": path.join(UserSettings.getUserN8nFolderPath(), 'database.sqlite'),
"migrations": [
"./src/databases/sqlite/migrations/*.ts"
],
@ -14,7 +16,7 @@ module.exports = [
"./src/databases/sqlite/subscribers/*.ts"
],
"cli": {
"entitiesDir": "./src/databases/sqlite",
"entitiesDir": "./src/databases/entities",
"migrationsDir": "./src/databases/sqlite/migrations",
"subscribersDir": "./src/databases/sqlite/subscribers"
}
@ -29,7 +31,7 @@ module.exports = [
"port": 5432,
"database": "n8n",
"schema": "public",
"entities": Object.values(PostgresDb),
"entities": Object.values(entities),
"migrations": [
"./src/databases/postgresdb/migrations/*.ts"
],
@ -37,7 +39,7 @@ module.exports = [
"src/subscriber/**/*.ts"
],
"cli": {
"entitiesDir": "./src/databases/postgresdb",
"entitiesDir": "./src/databases/entities",
"migrationsDir": "./src/databases/postgresdb/migrations",
"subscribersDir": "./src/databases/postgresdb/subscribers"
}
@ -51,7 +53,7 @@ module.exports = [
"host": "localhost",
"port": "3306",
"logging": false,
"entities": Object.values(MySQLDb),
"entities": Object.values(entities),
"migrations": [
"./src/databases/mysqldb/migrations/*.ts"
],
@ -59,7 +61,7 @@ module.exports = [
"src/subscriber/**/*.ts"
],
"cli": {
"entitiesDir": "./src/databases/mysqldb",
"entitiesDir": "./src/databases/entities",
"migrationsDir": "./src/databases/mysqldb/migrations",
"subscribersDir": "./src/databases/mysqldb/Subscribers"
}
@ -73,7 +75,7 @@ module.exports = [
"host": "localhost",
"port": "3306",
"logging": false,
"entities": Object.values(MySQLDb),
"entities": Object.values(entities),
"migrations": [
"./src/databases/mysqldb/migrations/*.ts"
],
@ -81,7 +83,7 @@ module.exports = [
"src/subscriber/**/*.ts"
],
"cli": {
"entitiesDir": "./src/databases/mysqldb",
"entitiesDir": "./src/databases/entities",
"migrationsDir": "./src/databases/mysqldb/migrations",
"subscribersDir": "./src/databases/mysqldb/Subscribers"
}

View file

@ -89,6 +89,7 @@
"body-parser-xml": "^1.1.0",
"bull": "^3.19.0",
"callsites": "^3.1.0",
"class-validator": "^0.13.1",
"client-oauth2": "^4.2.5",
"compression": "^1.7.4",
"connect-history-api-fallback": "^1.6.0",

View file

@ -19,7 +19,6 @@ import {
import { ChildProcess } from 'child_process';
import * as PCancelable from 'p-cancelable';
import { ObjectID } from 'typeorm';
export class ActiveExecutions {
@ -53,13 +52,13 @@ export class ActiveExecutions {
if (executionData.workflowData.id !== undefined && WorkflowHelpers.isWorkflowIdValid(executionData.workflowData.id.toString()) === true) {
fullExecutionData.workflowId = executionData.workflowData.id.toString();
}
const execution = ResponseHelper.flattenExecutionData(fullExecutionData);
// Save the Execution in DB
const executionResult = await Db.collections.Execution!.save(execution as IExecutionFlattedDb);
const executionId = typeof executionResult.id === "object" ? executionResult.id.toString() : executionResult.id + "";
const executionId = typeof executionResult.id === "object" ? executionResult.id!.toString() : executionResult.id + "";
this.activeExecutions[executionId] = {
executionData,

View file

@ -253,7 +253,7 @@ export class ActiveWorkflowRunner {
* @memberof ActiveWorkflowRunner
*/
async isActive(id: string): Promise<boolean> {
const workflow = await Db.collections.Workflow?.findOne({ id }) as IWorkflowDb;
const workflow = await Db.collections.Workflow?.findOne({ id: Number(id) }) as IWorkflowDb;
return workflow?.active as boolean;
}

View file

@ -18,17 +18,14 @@ import { TlsOptions } from 'tls';
import * as config from '../config';
import {
MySQLDb,
PostgresDb,
SQLite,
} from './databases';
import { entities } from './databases/entities';
export let collections: IDatabaseCollections = {
Credentials: null,
Execution: null,
Workflow: null,
Webhook: null,
Tag: null,
};
import { postgresMigrations } from './databases/postgresdb/migrations';
@ -41,15 +38,12 @@ export async function init(): Promise<IDatabaseCollections> {
const dbType = await GenericHelpers.getConfigValue('database.type') as DatabaseType;
const n8nFolder = UserSettings.getUserN8nFolderPath();
let entities;
let connectionOptions: ConnectionOptions;
const entityPrefix = config.get('database.tablePrefix');
switch (dbType) {
case 'postgresdb':
entities = PostgresDb;
const sslCa = await GenericHelpers.getConfigValue('database.postgresdb.ssl.ca') as string;
const sslCert = await GenericHelpers.getConfigValue('database.postgresdb.ssl.cert') as string;
const sslKey = await GenericHelpers.getConfigValue('database.postgresdb.ssl.key') as string;
@ -84,7 +78,6 @@ export async function init(): Promise<IDatabaseCollections> {
case 'mariadb':
case 'mysqldb':
entities = MySQLDb;
connectionOptions = {
type: dbType === 'mysqldb' ? 'mysql' : 'mariadb',
database: await GenericHelpers.getConfigValue('database.mysqldb.database') as string,
@ -100,10 +93,9 @@ export async function init(): Promise<IDatabaseCollections> {
break;
case 'sqlite':
entities = SQLite;
connectionOptions = {
type: 'sqlite',
database: path.join(n8nFolder, 'database.sqlite'),
database: path.join(n8nFolder, 'database.sqlite'),
entityPrefix,
migrations: sqliteMigrations,
migrationsRun: false, // migrations for sqlite will be ran manually for now; see below
@ -113,7 +105,7 @@ export async function init(): Promise<IDatabaseCollections> {
default:
throw new Error(`The database "${dbType}" is currently not supported!`);
}
}
Object.assign(connectionOptions, {
entities: Object.values(entities),
@ -150,6 +142,7 @@ export async function init(): Promise<IDatabaseCollections> {
collections.Execution = getRepository(entities.ExecutionEntity);
collections.Workflow = getRepository(entities.WorkflowEntity);
collections.Webhook = getRepository(entities.WebhookEntity);
collections.Tag = getRepository(entities.TagEntity);
return collections;
}

View file

@ -1,16 +1,14 @@
import * as config from '../config';
import * as express from 'express';
import { join as pathJoin } from 'path';
import {
readFile as fsReadFile,
} from 'fs/promises';
import { readFile as fsReadFile } from 'fs/promises';
import { readFileSync as fsReadFileSync } from 'fs';
import { IDataObject } from 'n8n-workflow';
import { IPackageVersions } from './';
let versionCache: IPackageVersions | undefined;
/**
* Returns the base URL n8n is reachable from
*
@ -63,6 +61,27 @@ export async function getVersions(): Promise<IPackageVersions> {
return versionCache;
}
/**
* Extracts configuration schema for key
*
* @param {string} configKey
* @param {IDataObject} configSchema
* @returns {IDataObject} schema of the configKey
*/
function extractSchemaForKey(configKey: string, configSchema: IDataObject): IDataObject {
const configKeyParts = configKey.split('.');
for (const key of configKeyParts) {
if (configSchema[key] === undefined) {
throw new Error(`Key "${key}" of ConfigKey "${configKey}" does not exist`);
} else if ((configSchema[key]! as IDataObject)._cvtProperties === undefined) {
configSchema = configSchema[key] as IDataObject;
} else {
configSchema = (configSchema[key] as IDataObject)._cvtProperties as IDataObject;
}
}
return configSchema;
}
/**
* Gets value from config with support for "_FILE" environment variables
@ -72,22 +91,10 @@ export async function getVersions(): Promise<IPackageVersions> {
* @returns {(Promise<string | boolean | number | undefined>)}
*/
export async function getConfigValue(configKey: string): Promise<string | boolean | number | undefined> {
const configKeyParts = configKey.split('.');
// Get the environment variable
const configSchema = config.getSchema();
// @ts-ignore
let currentSchema = configSchema._cvtProperties as IDataObject;
for (const key of configKeyParts) {
if (currentSchema[key] === undefined) {
throw new Error(`Key "${key}" of ConfigKey "${configKey}" does not exist`);
} else if ((currentSchema[key]! as IDataObject)._cvtProperties === undefined) {
currentSchema = currentSchema[key] as IDataObject;
} else {
currentSchema = (currentSchema[key] as IDataObject)._cvtProperties as IDataObject;
}
}
const currentSchema = extractSchemaForKey(configKey, configSchema._cvtProperties as IDataObject);
// Check if environment variable is defined for config key
if (currentSchema.env === undefined) {
// No environment variable defined, so return value from config
@ -114,3 +121,42 @@ export async function getConfigValue(configKey: string): Promise<string | boolea
return data;
}
/**
* Gets value from config with support for "_FILE" environment variables synchronously
*
* @export
* @param {string} configKey The key of the config data to get
* @returns {(string | boolean | number | undefined)}
*/
export function getConfigValueSync(configKey: string): string | boolean | number | undefined {
// Get the environment variable
const configSchema = config.getSchema();
// @ts-ignore
const currentSchema = extractSchemaForKey(configKey, configSchema._cvtProperties as IDataObject);
// Check if environment variable is defined for config key
if (currentSchema.env === undefined) {
// No environment variable defined, so return value from config
return config.get(configKey);
}
// Check if special file enviroment variable exists
const fileEnvironmentVariable = process.env[currentSchema.env + '_FILE'];
if (fileEnvironmentVariable === undefined) {
// Does not exist, so return value from config
return config.get(configKey);
}
let data;
try {
data = fsReadFileSync(fileEnvironmentVariable, 'utf8') as string;
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`The file "${fileEnvironmentVariable}" could not be found.`);
}
throw error;
}
return data;
}

View file

@ -20,11 +20,13 @@ import {
} from 'n8n-core';
import * as PCancelable from 'p-cancelable';
import { ObjectID, Repository } from 'typeorm';
import { Repository } from 'typeorm';
import { ChildProcess } from 'child_process';
import { Url } from 'url';
import { Request } from 'express';
import { WorkflowEntity } from './databases/entities/WorkflowEntity';
import { TagEntity } from './databases/entities/TagEntity';
export interface IActivationError {
time: number;
@ -57,12 +59,13 @@ export interface ICredentialsOverwrite {
export interface IDatabaseCollections {
Credentials: Repository<ICredentialsDb> | null;
Execution: Repository<IExecutionFlattedDb> | null;
Workflow: Repository<IWorkflowDb> | null;
Workflow: Repository<WorkflowEntity> | null;
Webhook: Repository<IWebhookDb> | null;
Tag: Repository<TagEntity> | null;
}
export interface IWebhookDb {
workflowId: number | string | ObjectID;
workflowId: number | string;
webhookPath: string;
method: string;
node: string;
@ -70,28 +73,44 @@ export interface IWebhookDb {
pathLength?: number;
}
export interface IWorkflowBase extends IWorkflowBaseWorkflow {
id?: number | string | ObjectID;
// ----------------------------------
// tags
// ----------------------------------
export interface ITagDb {
id: number;
name: string;
createdAt: Date;
updatedAt: Date;
}
export type UsageCount = {
usageCount: number
};
export type ITagWithCountDb = ITagDb & UsageCount;
// ----------------------------------
// workflows
// ----------------------------------
export interface IWorkflowBase extends IWorkflowBaseWorkflow {
id?: number | string;
}
// Almost identical to editor-ui.Interfaces.ts
export interface IWorkflowDb extends IWorkflowBase {
id: number | string | ObjectID;
id: number | string;
tags: ITagDb[];
}
export interface IWorkflowResponse extends IWorkflowBase {
id: string;
}
export interface IWorkflowShortResponse {
id: string;
name: string;
active: boolean;
createdAt: Date;
updatedAt: Date;
}
// ----------------------------------
// credentials
// ----------------------------------
export interface ICredentialsBase {
createdAt: Date;
@ -99,7 +118,7 @@ export interface ICredentialsBase {
}
export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted {
id: number | string | ObjectID;
id: number | string;
}
export interface ICredentialsResponse extends ICredentialsDb {
@ -107,7 +126,7 @@ export interface ICredentialsResponse extends ICredentialsDb {
}
export interface ICredentialsDecryptedDb extends ICredentialsBase, ICredentialsDecrypted {
id: number | string | ObjectID;
id: number | string;
}
export interface ICredentialsDecryptedResponse extends ICredentialsDecryptedDb {
@ -118,14 +137,14 @@ export type DatabaseType = 'mariadb' | 'postgresdb' | 'mysqldb' | 'sqlite';
export type SaveExecutionDataType = 'all' | 'none';
export interface IExecutionBase {
id?: number | string | ObjectID;
id?: number | string;
mode: WorkflowExecuteMode;
startedAt: Date;
stoppedAt?: Date; // empty value means execution is still running
workflowId?: string; // To be able to filter executions easily //
finished: boolean;
retryOf?: number | string | ObjectID; // If it is a retry, the id of the execution it is a retry of.
retrySuccessId?: number | string | ObjectID; // If it failed and a retry did succeed. The id of the successful retry.
retryOf?: number | string; // If it is a retry, the id of the execution it is a retry of.
retrySuccessId?: number | string; // If it failed and a retry did succeed. The id of the successful retry.
}
// Data in regular format with references
@ -155,7 +174,7 @@ export interface IExecutionFlatted extends IExecutionBase {
}
export interface IExecutionFlattedDb extends IExecutionBase {
id: number | string | ObjectID;
id: number | string;
data: string;
workflowData: IWorkflowBase;
}
@ -398,7 +417,7 @@ export interface IWorkflowExecutionDataProcess {
executionMode: WorkflowExecuteMode;
executionData?: IRunExecutionData;
runData?: IRunData;
retryOf?: number | string | ObjectID;
retryOf?: number | string;
sessionId?: string;
startNodes?: string[];
workflowData: IWorkflowBase;

View file

@ -10,6 +10,7 @@ import {
import {
getConnectionManager,
In,
Like,
} from 'typeorm';
import * as bodyParser from 'body-parser';
require('body-parser-xml')(bodyParser);
@ -54,10 +55,9 @@ import {
IExternalHooksClass,
IN8nUISettings,
IPackageVersions,
IWorkflowBase,
ITagWithCountDb,
IWorkflowExecutionDataProcess,
IWorkflowResponse,
IWorkflowShortResponse,
LoadNodesAndCredentials,
NodeTypes,
Push,
@ -67,6 +67,7 @@ import {
WebhookServer,
WorkflowCredentials,
WorkflowExecuteAdditionalData,
WorkflowHelpers,
WorkflowRunner,
} from './';
@ -85,6 +86,7 @@ import {
INodePropertyOptions,
INodeTypeDescription,
IRunData,
IWorkflowBase,
IWorkflowCredentials,
Workflow,
WorkflowExecuteMode,
@ -110,6 +112,11 @@ import * as Queue from '../src/Queue';
import { OptionsWithUrl } from 'request-promise-native';
import { Registry } from 'prom-client';
import * as TagHelpers from './TagHelpers';
import { TagEntity } from './databases/entities/TagEntity';
import { WorkflowEntity } from './databases/entities/WorkflowEntity';
import { WorkflowNameRequest } from './WorkflowHelpers';
class App {
app: express.Application;
@ -119,6 +126,7 @@ class App {
endpointWebhookTest: string;
endpointPresetCredentials: string;
externalHooks: IExternalHooksClass;
defaultWorkflowName: string;
saveDataErrorExecution: string;
saveDataSuccessExecution: string;
saveManualExecutions: boolean;
@ -142,6 +150,9 @@ class App {
this.endpointWebhook = config.get('endpoints.webhook') as string;
this.endpointWebhookTest = config.get('endpoints.webhookTest') as string;
this.defaultWorkflowName = config.get('workflows.defaultName') as string;
this.saveDataErrorExecution = config.get('executions.saveDataOnError') as string;
this.saveDataSuccessExecution = config.get('executions.saveDataOnSuccess') as string;
this.saveManualExecutions = config.get('executions.saveDataManualExecutions') as boolean;
@ -484,25 +495,30 @@ class App {
// Creates a new workflow
this.app.post(`/${this.restEndpoint}/workflows`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IWorkflowResponse> => {
this.app.post(`/${this.restEndpoint}/workflows`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<WorkflowEntity> => {
delete req.body.id; // ignore if sent by mistake
const incomingData = req.body;
const newWorkflowData = req.body as IWorkflowBase;
const newWorkflow = new WorkflowEntity();
newWorkflowData.name = newWorkflowData.name.trim();
newWorkflowData.createdAt = this.getCurrentDate();
newWorkflowData.updatedAt = this.getCurrentDate();
Object.assign(newWorkflow, incomingData);
newWorkflow.name = incomingData.name.trim();
newWorkflowData.id = undefined;
const incomingTagOrder = incomingData.tags.slice();
await this.externalHooks.run('workflow.create', [newWorkflowData]);
if (incomingData.tags.length) {
newWorkflow.tags = await Db.collections.Tag!.findByIds(incomingData.tags, { select: ['id', 'name'] });
}
// Save the workflow in DB
const result = await Db.collections.Workflow!.save(newWorkflowData);
await this.externalHooks.run('workflow.create', [newWorkflow]);
// Convert to response format in which the id is a string
(result as IWorkflowBase as IWorkflowResponse).id = result.id.toString();
return result as IWorkflowBase as IWorkflowResponse;
await WorkflowHelpers.validateWorkflow(newWorkflow);
const savedWorkflow = await Db.collections.Workflow!.save(newWorkflow).catch(WorkflowHelpers.throwDuplicateEntryError) as WorkflowEntity;
savedWorkflow.tags = TagHelpers.sortByRequestOrder(savedWorkflow.tags, incomingTagOrder);
// @ts-ignore
savedWorkflow.id = savedWorkflow.id.toString();
return savedWorkflow;
}));
@ -535,47 +551,90 @@ class App {
// Returns workflows
this.app.get(`/${this.restEndpoint}/workflows`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IWorkflowShortResponse[]> => {
const findQuery = {} as FindManyOptions;
this.app.get(`/${this.restEndpoint}/workflows`, ResponseHelper.send(async (req: express.Request, res: express.Response) => {
const findQuery: FindManyOptions<WorkflowEntity> = {
select: ['id', 'name', 'active', 'createdAt', 'updatedAt'],
relations: ['tags'],
};
if (req.query.filter) {
findQuery.where = JSON.parse(req.query.filter as string);
}
// Return only the fields we need
findQuery.select = ['id', 'name', 'active', 'createdAt', 'updatedAt'];
const workflows = await Db.collections.Workflow!.find(findQuery);
const results = await Db.collections.Workflow!.find(findQuery);
workflows.forEach(workflow => {
// @ts-ignore
workflow.id = workflow.id.toString();
// @ts-ignore
workflow.tags = workflow.tags.map(({ id, name }) => ({ id: id.toString(), name }));
});
return workflows;
}));
for (const entry of results) {
(entry as unknown as IWorkflowShortResponse).id = entry.id.toString();
this.app.get(`/${this.restEndpoint}/workflows/new`, ResponseHelper.send(async (req: WorkflowNameRequest, res: express.Response): Promise<{ name: string }> => {
const nameToReturn = req.query.name && req.query.name !== ''
? req.query.name
: this.defaultWorkflowName;
const workflows = await Db.collections.Workflow!.find({
select: ['name'],
where: { name: Like(`${nameToReturn}%`) },
});
// name is unique
if (workflows.length === 0) {
return { name: nameToReturn };
}
return results as unknown as IWorkflowShortResponse[];
const maxSuffix = workflows.reduce((acc: number, { name }) => {
const parts = name.split(`${nameToReturn} `);
if (parts.length > 2) return acc;
const suffix = Number(parts[1]);
if (!isNaN(suffix) && Math.ceil(suffix) > acc) {
acc = Math.ceil(suffix);
}
return acc;
}, 0);
// name is duplicate but no numeric suffixes exist yet
if (maxSuffix === 0) {
return { name: `${nameToReturn} 2` };
}
return { name: `${nameToReturn} ${maxSuffix + 1}` };
}));
// Returns a specific workflow
this.app.get(`/${this.restEndpoint}/workflows/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IWorkflowResponse | undefined> => {
const result = await Db.collections.Workflow!.findOne(req.params.id);
this.app.get(`/${this.restEndpoint}/workflows/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<WorkflowEntity | undefined> => {
const workflow = await Db.collections.Workflow!.findOne(req.params.id, { relations: ['tags'] });
if (result === undefined) {
if (workflow === undefined) {
return undefined;
}
// Convert to response format in which the id is a string
(result as IWorkflowBase as IWorkflowResponse).id = result.id.toString();
return result as IWorkflowBase as IWorkflowResponse;
// @ts-ignore
workflow.id = workflow.id.toString();
// @ts-ignore
workflow.tags.forEach(tag => tag.id = tag.id.toString());
return workflow;
}));
// Updates an existing workflow
this.app.patch(`/${this.restEndpoint}/workflows/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IWorkflowResponse> => {
this.app.patch(`/${this.restEndpoint}/workflows/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<WorkflowEntity> => {
const { tags, ...updateData } = req.body;
const newWorkflowData = req.body as IWorkflowBase;
const id = req.params.id;
newWorkflowData.id = id;
updateData.id = id;
await this.externalHooks.run('workflow.update', [newWorkflowData]);
await this.externalHooks.run('workflow.update', [updateData]);
const isActive = await this.activeWorkflowRunner.isActive(id);
@ -585,64 +644,78 @@ class App {
await this.activeWorkflowRunner.remove(id);
}
if (newWorkflowData.settings) {
if (newWorkflowData.settings.timezone === 'DEFAULT') {
if (updateData.settings) {
if (updateData.settings.timezone === 'DEFAULT') {
// Do not save the default timezone
delete newWorkflowData.settings.timezone;
delete updateData.settings.timezone;
}
if (newWorkflowData.settings.saveDataErrorExecution === 'DEFAULT') {
if (updateData.settings.saveDataErrorExecution === 'DEFAULT') {
// Do not save when default got set
delete newWorkflowData.settings.saveDataErrorExecution;
delete updateData.settings.saveDataErrorExecution;
}
if (newWorkflowData.settings.saveDataSuccessExecution === 'DEFAULT') {
if (updateData.settings.saveDataSuccessExecution === 'DEFAULT') {
// Do not save when default got set
delete newWorkflowData.settings.saveDataSuccessExecution;
delete updateData.settings.saveDataSuccessExecution;
}
if (newWorkflowData.settings.saveManualExecutions === 'DEFAULT') {
if (updateData.settings.saveManualExecutions === 'DEFAULT') {
// Do not save when default got set
delete newWorkflowData.settings.saveManualExecutions;
delete updateData.settings.saveManualExecutions;
}
if (parseInt(newWorkflowData.settings.executionTimeout as string, 10) === this.executionTimeout) {
if (parseInt(updateData.settings.executionTimeout as string, 10) === this.executionTimeout) {
// Do not save when default got set
delete newWorkflowData.settings.executionTimeout;
delete updateData.settings.executionTimeout;
}
}
newWorkflowData.updatedAt = this.getCurrentDate();
// required due to atomic update
updateData.updatedAt = this.getCurrentDate();
await Db.collections.Workflow!.update(id, newWorkflowData);
await this.externalHooks.run('workflow.afterUpdate', [newWorkflowData]);
await WorkflowHelpers.validateWorkflow(updateData);
await Db.collections.Workflow!.update(id, updateData).catch(WorkflowHelpers.throwDuplicateEntryError);
const tablePrefix = config.get('database.tablePrefix');
await TagHelpers.removeRelations(req.params.id, tablePrefix);
if (tags?.length) {
await TagHelpers.createRelations(req.params.id, tags, tablePrefix);
}
// We sadly get nothing back from "update". Neither if it updated a record
// nor the new value. So query now the hopefully updated entry.
const responseData = await Db.collections.Workflow!.findOne(id);
const workflow = await Db.collections.Workflow!.findOne(id, { relations: ['tags'] });
if (responseData === undefined) {
if (workflow === undefined) {
throw new ResponseHelper.ResponseError(`Workflow with id "${id}" could not be found to be updated.`, undefined, 400);
}
if (responseData.active === true) {
if (tags?.length) {
workflow.tags = TagHelpers.sortByRequestOrder(workflow.tags, tags);
}
await this.externalHooks.run('workflow.afterUpdate', [workflow]);
if (workflow.active === true) {
// When the workflow is supposed to be active add it again
try {
await this.externalHooks.run('workflow.activate', [responseData]);
await this.externalHooks.run('workflow.activate', [workflow]);
await this.activeWorkflowRunner.add(id, isActive ? 'update' : 'activate');
} catch (error) {
// If workflow could not be activated set it again to inactive
newWorkflowData.active = false;
await Db.collections.Workflow!.update(id, newWorkflowData);
updateData.active = false;
// @ts-ignore
await Db.collections.Workflow!.update(id, updateData);
// Also set it in the returned data
responseData.active = false;
workflow.active = false;
// Now return the original error for UI to display
throw error;
}
}
// Convert to response format in which the id is a string
(responseData as IWorkflowBase as IWorkflowResponse).id = responseData.id.toString();
return responseData as IWorkflowBase as IWorkflowResponse;
// @ts-ignore
workflow.id = workflow.id.toString();
return workflow;
}));
@ -665,7 +738,6 @@ class App {
return true;
}));
this.app.post(`/${this.restEndpoint}/workflows/run`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IExecutionPushResponse> => {
const workflowData = req.body.workflowData;
const runData: IRunData | undefined = req.body.runData;
@ -713,6 +785,69 @@ class App {
};
}));
// Retrieves all tags, with or without usage count
this.app.get(`/${this.restEndpoint}/tags`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<TagEntity[] | ITagWithCountDb[]> => {
if (req.query.withUsageCount === 'true') {
const tablePrefix = config.get('database.tablePrefix');
return TagHelpers.getTagsWithCountDb(tablePrefix);
}
const tags = await Db.collections.Tag!.find({ select: ['id', 'name'] });
// @ts-ignore
tags.forEach(tag => tag.id = tag.id.toString());
return tags;
}));
// Creates a tag
this.app.post(`/${this.restEndpoint}/tags`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<TagEntity | void> => {
const newTag = new TagEntity();
newTag.name = req.body.name.trim();
await this.externalHooks.run('tag.beforeCreate', [newTag]);
await TagHelpers.validateTag(newTag);
const tag = await Db.collections.Tag!.save(newTag).catch(TagHelpers.throwDuplicateEntryError);
await this.externalHooks.run('tag.afterCreate', [tag]);
// @ts-ignore
tag.id = tag.id.toString();
return tag;
}));
// Updates a tag
this.app.patch(`/${this.restEndpoint}/tags/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<TagEntity | void> => {
const { name } = req.body;
const { id } = req.params;
const newTag = new TagEntity();
newTag.id = Number(id);
newTag.name = name.trim();
await this.externalHooks.run('tag.beforeUpdate', [newTag]);
await TagHelpers.validateTag(newTag);
const tag = await Db.collections.Tag!.save(newTag).catch(TagHelpers.throwDuplicateEntryError);
await this.externalHooks.run('tag.afterUpdate', [tag]);
// @ts-ignore
tag.id = tag.id.toString();
return tag;
}));
// Deletes a tag
this.app.delete(`/${this.restEndpoint}/tags/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<boolean> => {
const id = Number(req.params.id);
await this.externalHooks.run('tag.beforeDelete', [id]);
await Db.collections.Tag!.delete({ id });
await this.externalHooks.run('tag.afterDelete', [id]);
return true;
}));
// Returns parameter values which normally get loaded from an external API or
// get generated dynamically
@ -728,6 +863,7 @@ class App {
const nodeTypes = NodeTypes();
// @ts-ignore
const loadDataInstance = new LoadNodeParameterOptions(nodeType, nodeTypes, path, JSON.parse('' + req.query.currentNodeParameters), credentials!);
const workflowData = loadDataInstance.getWorkflowData() as IWorkflowBase;
@ -892,8 +1028,6 @@ class App {
await this.externalHooks.run('credentials.create', [newCredentialsData]);
// Add special database related data
newCredentialsData.createdAt = this.getCurrentDate();
newCredentialsData.updatedAt = this.getCurrentDate();
// TODO: also add user automatically depending on who is logged in, if anybody is logged in

View file

@ -0,0 +1,112 @@
import { getConnection } from "typeorm";
import { validate } from 'class-validator';
import {
ResponseHelper,
} from ".";
import {
TagEntity,
} from "./databases/entities/TagEntity";
import {
ITagWithCountDb,
} from "./Interfaces";
// ----------------------------------
// utils
// ----------------------------------
/**
* Sort a `TagEntity[]` by the order of the tag IDs in the incoming request.
*/
export function sortByRequestOrder(tagsDb: TagEntity[], tagIds: string[]) {
const tagMap = tagsDb.reduce((acc, tag) => {
// @ts-ignore
tag.id = tag.id.toString();
acc[tag.id] = tag;
return acc;
}, {} as { [key: string]: TagEntity });
return tagIds.map(tagId => tagMap[tagId]);
}
// ----------------------------------
// validators
// ----------------------------------
/**
* Validate a new tag based on `class-validator` constraints.
*/
export async function validateTag(newTag: TagEntity) {
const errors = await validate(newTag);
if (errors.length) {
const validationErrorMessage = Object.values(errors[0].constraints!)[0];
throw new ResponseHelper.ResponseError(validationErrorMessage, undefined, 400);
}
}
export function throwDuplicateEntryError(error: Error) {
const errorMessage = error.message.toLowerCase();
if (errorMessage.includes('unique') || errorMessage.includes('duplicate')) {
throw new ResponseHelper.ResponseError('Tag name already exists', undefined, 400);
}
throw new ResponseHelper.ResponseError(errorMessage, undefined, 400);
}
// ----------------------------------
// queries
// ----------------------------------
/**
* Retrieve all tags and the number of workflows each tag is related to.
*/
export function getTagsWithCountDb(tablePrefix: string): Promise<ITagWithCountDb[]> {
return getConnection()
.createQueryBuilder()
.select(`${tablePrefix}tag_entity.id`, 'id')
.addSelect(`${tablePrefix}tag_entity.name`, 'name')
.addSelect(`COUNT(${tablePrefix}workflows_tags.workflowId)`, 'usageCount')
.from(`${tablePrefix}tag_entity`, 'tag_entity')
.leftJoin(`${tablePrefix}workflows_tags`, 'workflows_tags', `${tablePrefix}workflows_tags.tagId = tag_entity.id`)
.groupBy(`${tablePrefix}tag_entity.id`)
.getRawMany()
.then(tagsWithCount => {
tagsWithCount.forEach(tag => {
tag.id = tag.id.toString();
tag.usageCount = Number(tag.usageCount);
});
return tagsWithCount;
});
}
// ----------------------------------
// mutations
// ----------------------------------
/**
* Relate a workflow to one or more tags.
*/
export function createRelations(workflowId: string, tagIds: string[], tablePrefix: string) {
return getConnection()
.createQueryBuilder()
.insert()
.into(`${tablePrefix}workflows_tags`)
.values(tagIds.map(tagId => ({ workflowId, tagId })))
.execute();
}
/**
* Remove all tags for a workflow during a tag update operation.
*/
export function removeRelations(workflowId: string, tablePrefix: string) {
return getConnection()
.createQueryBuilder()
.delete()
.from(`${tablePrefix}workflows_tags`)
.where('workflowId = :id', { id: workflowId })
.execute();
}

View file

@ -6,6 +6,7 @@ import {
IWorkflowErrorData,
IWorkflowExecutionDataProcess,
NodeTypes,
ResponseHelper,
WorkflowCredentials,
WorkflowRunner,
} from './';
@ -22,6 +23,8 @@ import {
Workflow,} from 'n8n-workflow';
import * as config from '../config';
import { WorkflowEntity } from './databases/entities/WorkflowEntity';
import { validate } from 'class-validator';
const ERROR_TRIGGER_TYPE = config.get('nodes.errorTriggerType') as string;
@ -82,7 +85,7 @@ export function isWorkflowIdValid (id: string | null | undefined | number): bool
export async function executeErrorWorkflow(workflowId: string, workflowErrorData: IWorkflowErrorData): Promise<void> {
// Wrap everything in try/catch to make sure that no errors bubble up and all get caught here
try {
const workflowData = await Db.collections.Workflow!.findOne({ id: workflowId });
const workflowData = await Db.collections.Workflow!.findOne({ id: Number(workflowId) });
if (workflowData === undefined) {
// The error workflow could not be found
@ -357,3 +360,32 @@ export async function getStaticDataById(workflowId: string | number) {
return workflowData.staticData || {};
}
// TODO: Deduplicate `validateWorkflow` and `throwDuplicateEntryError` with TagHelpers?
export async function validateWorkflow(newWorkflow: WorkflowEntity) {
const errors = await validate(newWorkflow);
if (errors.length) {
const validationErrorMessage = Object.values(errors[0].constraints!)[0];
throw new ResponseHelper.ResponseError(validationErrorMessage, undefined, 400);
}
}
export function throwDuplicateEntryError(error: Error) {
const errorMessage = error.message.toLowerCase();
if (errorMessage.includes('unique') || errorMessage.includes('duplicate')) {
throw new ResponseHelper.ResponseError('There is already a workflow with this name', undefined, 400);
}
throw new ResponseHelper.ResponseError(errorMessage, undefined, 400);
}
export type WorkflowNameRequest = Express.Request & {
query: {
name?: string;
offset?: string;
}
};

View file

@ -3,14 +3,22 @@ import {
} from 'n8n-workflow';
import {
ICredentialsDb,
} from '../../';
getTimestampSyntax,
resolveDataType
} from '../utils';
import {
ICredentialsDb,
} from '../..';
import {
BeforeUpdate,
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity()
@ -33,13 +41,17 @@ export class CredentialsEntity implements ICredentialsDb {
})
type: string;
@Column('json')
@Column(resolveDataType('json'))
nodesAccess: ICredentialNodeAccess[];
@Column('timestamp')
@CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() })
createdAt: Date;
@Column('timestamp')
@UpdateDateColumn({ precision: 3, default: () => getTimestampSyntax(), onUpdate: getTimestampSyntax() })
updatedAt: Date;
@BeforeUpdate()
setUpdateDate() {
this.updatedAt = new Date();
}
}

View file

@ -7,14 +7,18 @@ import {
IWorkflowDb,
} from '../../';
import {
resolveDataType
} from '../utils';
import {
Column,
ColumnOptions,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
export class ExecutionEntity implements IExecutionFlattedDb {
@ -36,14 +40,14 @@ export class ExecutionEntity implements IExecutionFlattedDb {
@Column({ nullable: true })
retrySuccessId: string;
@Column('timestamp')
@Column(resolveDataType('datetime'))
startedAt: Date;
@Index()
@Column('timestamp', { nullable: true })
@Column({ type: resolveDataType('datetime') as ColumnOptions['type'], nullable: true })
stoppedAt: Date;
@Column('json')
@Column(resolveDataType('json'))
workflowData: IWorkflowDb;
@Index()

View file

@ -0,0 +1,37 @@
import { BeforeUpdate, Column, CreateDateColumn, Entity, Index, ManyToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { IsDate, IsOptional, IsString, Length } from 'class-validator';
import { ITagDb } from '../../Interfaces';
import { WorkflowEntity } from './WorkflowEntity';
import { getTimestampSyntax } from '../utils';
@Entity()
export class TagEntity implements ITagDb {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 24 })
@Index({ unique: true })
@IsString({ message: 'Tag name must be of type string.' })
@Length(1, 24, { message: 'Tag name must be 1 to 24 characters long.' })
name: string;
@CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() })
@IsOptional() // ignored by validation because set at DB level
@IsDate()
createdAt: Date;
@UpdateDateColumn({ precision: 3, default: () => getTimestampSyntax(), onUpdate: getTimestampSyntax() })
@IsOptional() // ignored by validation because set at DB level
@IsDate()
updatedAt: Date;
@ManyToMany(() => WorkflowEntity, workflow => workflow.tags)
workflows: WorkflowEntity[];
@BeforeUpdate()
setUpdateDate() {
this.updatedAt = new Date();
}
}

View file

@ -0,0 +1,94 @@
import {
Length,
} from 'class-validator';
import {
IConnections,
IDataObject,
INode,
IWorkflowSettings,
} from 'n8n-workflow';
import {
BeforeUpdate,
Column,
ColumnOptions,
CreateDateColumn,
Entity,
Index,
JoinTable,
ManyToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import {
IWorkflowDb,
} from '../../';
import {
getTimestampSyntax,
resolveDataType
} from '../utils';
import {
TagEntity,
} from './TagEntity';
@Entity()
export class WorkflowEntity implements IWorkflowDb {
@PrimaryGeneratedColumn()
id: number;
@Index({ unique: true })
@Length(1, 128, { message: 'Workflow name must be 1 to 128 characters long.' })
@Column({ length: 128 })
name: string;
@Column()
active: boolean;
@Column(resolveDataType('json'))
nodes: INode[];
@Column(resolveDataType('json'))
connections: IConnections;
@CreateDateColumn({ precision: 3, default: () => getTimestampSyntax() })
createdAt: Date;
@UpdateDateColumn({ precision: 3, default: () => getTimestampSyntax(), onUpdate: getTimestampSyntax() })
updatedAt: Date;
@Column({
type: resolveDataType('json') as ColumnOptions['type'],
nullable: true,
})
settings?: IWorkflowSettings;
@Column({
type: resolveDataType('json') as ColumnOptions['type'],
nullable: true,
})
staticData?: IDataObject;
@ManyToMany(() => TagEntity, tag => tag.workflows)
@JoinTable({
name: "workflows_tags", // table name for the junction table of this relation
joinColumn: {
name: "workflowId",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "tagId",
referencedColumnName: "id",
},
})
tags: TagEntity[];
@BeforeUpdate()
setUpdateDate() {
this.updatedAt = new Date();
}
}

View file

@ -0,0 +1,13 @@
import { CredentialsEntity } from './CredentialsEntity';
import { ExecutionEntity } from './ExecutionEntity';
import { WorkflowEntity } from './WorkflowEntity';
import { WebhookEntity } from './WebhookEntity';
import { TagEntity } from './TagEntity';
export const entities = {
CredentialsEntity,
ExecutionEntity,
WorkflowEntity,
WebhookEntity,
TagEntity,
};

View file

@ -1,9 +0,0 @@
import * as PostgresDb from './postgresdb';
import * as SQLite from './sqlite';
import * as MySQLDb from './mysqldb';
export {
PostgresDb,
SQLite,
MySQLDb,
};

View file

@ -1,44 +0,0 @@
import {
ICredentialNodeAccess,
} from 'n8n-workflow';
import {
ICredentialsDb,
} from '../../';
import {
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
export class CredentialsEntity implements ICredentialsDb {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 128,
})
name: string;
@Column('text')
data: string;
@Index()
@Column({
length: 32,
})
type: string;
@Column('json')
nodesAccess: ICredentialNodeAccess[];
@Column('datetime')
createdAt: Date;
@Column('datetime')
updatedAt: Date;
}

View file

@ -1,52 +0,0 @@
import {
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
IExecutionFlattedDb,
IWorkflowDb,
} from '../../';
import {
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
export class ExecutionEntity implements IExecutionFlattedDb {
@PrimaryGeneratedColumn()
id: number;
@Column('text')
data: string;
@Column()
finished: boolean;
@Column('varchar')
mode: WorkflowExecuteMode;
@Column({ nullable: true })
retryOf: string;
@Column({ nullable: true })
retrySuccessId: string;
@Column('datetime')
startedAt: Date;
@Index()
@Column('datetime', { nullable: true })
stoppedAt: Date;
@Column('json')
workflowData: IWorkflowDb;
@Index()
@Column({ nullable: true })
workflowId: string;
}

View file

@ -1,55 +0,0 @@
import {
IConnections,
IDataObject,
INode,
IWorkflowSettings,
} from 'n8n-workflow';
import {
IWorkflowDb,
} from '../../';
import {
Column,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
export class WorkflowEntity implements IWorkflowDb {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 128,
})
name: string;
@Column()
active: boolean;
@Column('json')
nodes: INode[];
@Column('json')
connections: IConnections;
@Column('datetime')
createdAt: Date;
@Column('datetime')
updatedAt: Date;
@Column({
type: 'json',
nullable: true,
})
settings?: IWorkflowSettings;
@Column({
type: 'json',
nullable: true,
})
staticData?: IDataObject;
}

View file

@ -1,4 +0,0 @@
export * from './CredentialsEntity';
export * from './ExecutionEntity';
export * from './WorkflowEntity';
export * from './WebhookEntity';

View file

@ -0,0 +1,50 @@
import {MigrationInterface, QueryRunner} from "typeorm";
import * as config from '../../../../config';
export class CreateTagEntity1617268711084 implements MigrationInterface {
name = 'CreateTagEntity1617268711084';
async up(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
// create tags table + relationship with workflow entity
await queryRunner.query('CREATE TABLE `' + tablePrefix + 'tag_entity` (`id` int NOT NULL AUTO_INCREMENT, `name` varchar(24) NOT NULL, `createdAt` datetime NOT NULL, `updatedAt` datetime NOT NULL, UNIQUE INDEX `IDX_' + tablePrefix + '8f949d7a3a984759044054e89b` (`name`), PRIMARY KEY (`id`)) ENGINE=InnoDB');
await queryRunner.query('CREATE TABLE `' + tablePrefix + 'workflows_tags` (`workflowId` int NOT NULL, `tagId` int NOT NULL, INDEX `IDX_' + tablePrefix + '54b2f0343d6a2078fa13744386` (`workflowId`), INDEX `IDX_' + tablePrefix + '77505b341625b0b4768082e217` (`tagId`), PRIMARY KEY (`workflowId`, `tagId`)) ENGINE=InnoDB');
await queryRunner.query('ALTER TABLE `' + tablePrefix + 'workflows_tags` ADD CONSTRAINT `FK_' + tablePrefix + '54b2f0343d6a2078fa137443869` FOREIGN KEY (`workflowId`) REFERENCES `' + tablePrefix + 'workflow_entity`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION');
await queryRunner.query('ALTER TABLE `' + tablePrefix + 'workflows_tags` ADD CONSTRAINT `FK_' + tablePrefix + '77505b341625b0b4768082e2171` FOREIGN KEY (`tagId`) REFERENCES `' + tablePrefix + 'tag_entity`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION');
// set default dates for `createdAt` and `updatedAt`
await queryRunner.query("ALTER TABLE `" + tablePrefix + "credentials_entity` CHANGE `createdAt` `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)");
await queryRunner.query("ALTER TABLE `" + tablePrefix + "credentials_entity` CHANGE `updatedAt` `updatedAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)");
await queryRunner.query("ALTER TABLE `" + tablePrefix + "tag_entity` CHANGE `createdAt` `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)");
await queryRunner.query("ALTER TABLE `" + tablePrefix + "tag_entity` CHANGE `updatedAt` `updatedAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)");
await queryRunner.query("ALTER TABLE `" + tablePrefix + "workflow_entity` CHANGE `createdAt` `createdAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)");
await queryRunner.query("ALTER TABLE `" + tablePrefix + "workflow_entity` CHANGE `updatedAt` `updatedAt` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)");
}
async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
// tags
await queryRunner.query('ALTER TABLE `' + tablePrefix + 'workflows_tags` DROP FOREIGN KEY `FK_' + tablePrefix + '77505b341625b0b4768082e2171`');
await queryRunner.query('ALTER TABLE `' + tablePrefix + 'workflows_tags` DROP FOREIGN KEY `FK_' + tablePrefix + '54b2f0343d6a2078fa137443869`');
await queryRunner.query('DROP INDEX `IDX_' + tablePrefix + '77505b341625b0b4768082e217` ON `' + tablePrefix + 'workflows_tags`');
await queryRunner.query('DROP INDEX `IDX_' + tablePrefix + '54b2f0343d6a2078fa13744386` ON `' + tablePrefix + 'workflows_tags`');
await queryRunner.query('DROP TABLE `' + tablePrefix + 'workflows_tags`');
await queryRunner.query('DROP INDEX `IDX_' + tablePrefix + '8f949d7a3a984759044054e89b` ON `' + tablePrefix + 'tag_entity`');
await queryRunner.query('DROP TABLE `' + tablePrefix + 'tag_entity`');
// `createdAt` and `updatedAt`
await queryRunner.query("ALTER TABLE `" + tablePrefix + "workflow_entity` CHANGE `updatedAt` `updatedAt` datetime(0) NOT NULL");
await queryRunner.query("ALTER TABLE `" + tablePrefix + "workflow_entity` CHANGE `createdAt` `createdAt` datetime(0) NOT NULL");
await queryRunner.query("ALTER TABLE `" + tablePrefix + "tag_entity` CHANGE `updatedAt` `updatedAt` datetime(0) NOT NULL");
await queryRunner.query("ALTER TABLE `" + tablePrefix + "tag_entity` CHANGE `createdAt` `createdAt` datetime(0) NOT NULL");
await queryRunner.query("ALTER TABLE `" + tablePrefix + "credentials_entity` CHANGE `updatedAt` `updatedAt` datetime(0) NOT NULL");
await queryRunner.query("ALTER TABLE `" + tablePrefix + "credentials_entity` CHANGE `createdAt` `createdAt` datetime(0) NOT NULL");
}
}

View file

@ -0,0 +1,48 @@
import {MigrationInterface, QueryRunner} from "typeorm";
import config = require("../../../../config");
export class UniqueWorkflowNames1620826335440 implements MigrationInterface {
name = 'UniqueWorkflowNames1620826335440';
async up(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
const workflowNames = await queryRunner.query(`
SELECT name
FROM ${tablePrefix}workflow_entity
`);
for (const { name } of workflowNames) {
const duplicates = await queryRunner.query(`
SELECT id, name
FROM ${tablePrefix}workflow_entity
WHERE name = '${name}'
ORDER BY createdAt ASC
`);
if (duplicates.length > 1) {
await Promise.all(duplicates.map(({ id, name }: { id: number; name: string; }, index: number) => {
if (index === 0) return Promise.resolve();
return queryRunner.query(`
UPDATE ${tablePrefix}workflow_entity
SET name = '${name} ${index + 1}'
WHERE id = '${id}'
`);
}));
}
}
await queryRunner.query('ALTER TABLE `' + tablePrefix + 'workflow_entity` ADD UNIQUE INDEX `IDX_' + tablePrefix + '943d8f922be094eb507cb9a7f9` (`name`)');
}
async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
await queryRunner.query('ALTER TABLE `' + tablePrefix + 'workflow_entity` DROP INDEX `IDX_' + tablePrefix + '943d8f922be094eb507cb9a7f9`');
}
}

View file

@ -5,6 +5,8 @@ import { AddWebhookId1611149998770 } from './1611149998770-AddWebhookId';
import { MakeStoppedAtNullable1607431743767 } from './1607431743767-MakeStoppedAtNullable';
import { ChangeDataSize1615306975123 } from './1615306975123-ChangeDataSize';
import { ChangeCredentialDataSize1620729500000 } from './1620729500000-ChangeCredentialDataSize';
import { CreateTagEntity1617268711084 } from './1617268711084-CreateTagEntity';
import { UniqueWorkflowNames1620826335440 } from './1620826335440-UniqueWorkflowNames';
export const mysqlMigrations = [
InitialMigration1588157391238,
@ -14,4 +16,6 @@ export const mysqlMigrations = [
MakeStoppedAtNullable1607431743767,
ChangeDataSize1615306975123,
ChangeCredentialDataSize1620729500000,
CreateTagEntity1617268711084,
UniqueWorkflowNames1620826335440,
];

View file

@ -1,33 +0,0 @@
import {
Column,
Entity,
Index,
PrimaryColumn,
} from 'typeorm';
import {
IWebhookDb,
} from '../../';
@Entity()
@Index(['webhookId', 'method', 'pathLength'])
export class WebhookEntity implements IWebhookDb {
@Column()
workflowId: number;
@PrimaryColumn()
webhookPath: string;
@PrimaryColumn()
method: string;
@Column()
node: string;
@Column({ nullable: true })
webhookId: string;
@Column({ nullable: true })
pathLength: number;
}

View file

@ -1,55 +0,0 @@
import {
IConnections,
IDataObject,
INode,
IWorkflowSettings,
} from 'n8n-workflow';
import {
IWorkflowDb,
} from '../../';
import {
Column,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
export class WorkflowEntity implements IWorkflowDb {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 128,
})
name: string;
@Column()
active: boolean;
@Column('json')
nodes: INode[];
@Column('json')
connections: IConnections;
@Column('timestamp')
createdAt: Date;
@Column('timestamp')
updatedAt: Date;
@Column({
type: 'json',
nullable: true,
})
settings?: IWorkflowSettings;
@Column({
type: 'json',
nullable: true,
})
staticData?: IDataObject;
}

View file

@ -1,5 +0,0 @@
export * from './CredentialsEntity';
export * from './ExecutionEntity';
export * from './WorkflowEntity';
export * from './WebhookEntity';

View file

@ -0,0 +1,76 @@
import {MigrationInterface, QueryRunner} from "typeorm";
import * as config from '../../../../config';
export class CreateTagEntity1617270242566 implements MigrationInterface {
name = 'CreateTagEntity1617270242566';
async up(queryRunner: QueryRunner): Promise<void> {
let tablePrefix = config.get('database.tablePrefix');
const tablePrefixPure = tablePrefix;
const schema = config.get('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
// create tags table + relationship with workflow entity
await queryRunner.query(`CREATE TABLE ${tablePrefix}tag_entity ("id" SERIAL NOT NULL, "name" character varying(24) NOT NULL, "createdAt" TIMESTAMP NOT NULL, "updatedAt" TIMESTAMP NOT NULL, CONSTRAINT "PK_${tablePrefixPure}7a50a9b74ae6855c0dcaee25052" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE UNIQUE INDEX IDX_${tablePrefixPure}812eb05f7451ca757fb98444ce ON ${tablePrefix}tag_entity ("name") `);
await queryRunner.query(`CREATE TABLE ${tablePrefix}workflows_tags ("workflowId" integer NOT NULL, "tagId" integer NOT NULL, CONSTRAINT "PK_${tablePrefixPure}a60448a90e51a114e95e2a125b3" PRIMARY KEY ("workflowId", "tagId"))`);
await queryRunner.query(`CREATE INDEX IDX_${tablePrefixPure}31140eb41f019805b40d008744 ON ${tablePrefix}workflows_tags ("workflowId") `);
await queryRunner.query(`CREATE INDEX IDX_${tablePrefixPure}5e29bfe9e22c5d6567f509d4a4 ON ${tablePrefix}workflows_tags ("tagId") `);
await queryRunner.query(`ALTER TABLE ${tablePrefix}workflows_tags ADD CONSTRAINT "FK_${tablePrefixPure}31140eb41f019805b40d0087449" FOREIGN KEY ("workflowId") REFERENCES ${tablePrefix}workflow_entity ("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}workflows_tags ADD CONSTRAINT "FK_${tablePrefixPure}5e29bfe9e22c5d6567f509d4a46" FOREIGN KEY ("tagId") REFERENCES ${tablePrefix}tag_entity ("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
// set default dates for `createdAt` and `updatedAt`
await queryRunner.query(`ALTER TABLE ${tablePrefix}credentials_entity ALTER COLUMN "createdAt" TYPE TIMESTAMP(3)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}credentials_entity ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP(3)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}credentials_entity ALTER COLUMN "updatedAt" TYPE TIMESTAMP(3)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}credentials_entity ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP(3)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}tag_entity ALTER COLUMN "createdAt" TYPE TIMESTAMP(3)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}tag_entity ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP(3)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}tag_entity ALTER COLUMN "updatedAt" TYPE TIMESTAMP(3)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}tag_entity ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP(3)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}workflow_entity ALTER COLUMN "createdAt" TYPE TIMESTAMP(3)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}workflow_entity ALTER COLUMN "createdAt" SET DEFAULT CURRENT_TIMESTAMP(3)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}workflow_entity ALTER COLUMN "updatedAt" TYPE TIMESTAMP(3)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}workflow_entity ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP(3)`);
}
async down(queryRunner: QueryRunner): Promise<void> {
let tablePrefix = config.get('database.tablePrefix');
const tablePrefixPure = tablePrefix;
const schema = config.get('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
// tags
await queryRunner.query(`ALTER TABLE ${tablePrefix}workflows_tags DROP CONSTRAINT "FK_${tablePrefixPure}5e29bfe9e22c5d6567f509d4a46"`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}workflows_tags DROP CONSTRAINT "FK_${tablePrefixPure}31140eb41f019805b40d0087449"`);
await queryRunner.query(`DROP INDEX IDX_${tablePrefixPure}5e29bfe9e22c5d6567f509d4a4`);
await queryRunner.query(`DROP INDEX IDX_${tablePrefixPure}31140eb41f019805b40d008744`);
await queryRunner.query(`DROP TABLE ${tablePrefix}workflows_tags`);
await queryRunner.query(`DROP INDEX IDX_${tablePrefixPure}812eb05f7451ca757fb98444ce`);
await queryRunner.query(`DROP TABLE ${tablePrefix}tag_entity`);
// `createdAt` and `updatedAt`
await queryRunner.query(`ALTER TABLE ${tablePrefix}workflow_entity ALTER COLUMN "updatedAt" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}workflow_entity ALTER COLUMN "updatedAt" TYPE TIMESTAMP(6)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}workflow_entity ALTER COLUMN "createdAt" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}workflow_entity ALTER COLUMN "createdAt" TYPE TIMESTAMP(6)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}tag_entity ALTER COLUMN "updatedAt" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}tag_entity ALTER COLUMN "updatedAt" TYPE TIMESTAMP(6)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}tag_entity ALTER COLUMN "createdAt" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}tag_entity ALTER COLUMN "createdAt" TYPE TIMESTAMP(6)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}credentials_entity ALTER COLUMN "updatedAt" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}credentials_entity ALTER COLUMN "updatedAt" TYPE TIMESTAMP(6)`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}credentials_entity ALTER COLUMN "createdAt" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}credentials_entity ALTER COLUMN "createdAt" TYPE TIMESTAMP(6)`);
}
}

View file

@ -0,0 +1,60 @@
import {MigrationInterface, QueryRunner} from "typeorm";
import config = require("../../../../config");
export class UniqueWorkflowNames1620824779533 implements MigrationInterface {
name = 'UniqueWorkflowNames1620824779533';
async up(queryRunner: QueryRunner): Promise<void> {
let tablePrefix = config.get('database.tablePrefix');
const tablePrefixPure = tablePrefix;
const schema = config.get('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
const workflowNames = await queryRunner.query(`
SELECT name
FROM ${tablePrefix}workflow_entity
`);
for (const { name } of workflowNames) {
const duplicates = await queryRunner.query(`
SELECT id, name
FROM ${tablePrefix}workflow_entity
WHERE name = '${name}'
ORDER BY "createdAt" ASC
`);
if (duplicates.length > 1) {
await Promise.all(duplicates.map(({ id, name }: { id: number; name: string; }, index: number) => {
if (index === 0) return Promise.resolve();
return queryRunner.query(`
UPDATE ${tablePrefix}workflow_entity
SET name = '${name} ${index + 1}'
WHERE id = '${id}'
`);
}));
}
}
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab" ON ${tablePrefix}workflow_entity ("name") `);
}
async down(queryRunner: QueryRunner): Promise<void> {
let tablePrefix = config.get('database.tablePrefix');
const tablePrefixPure = tablePrefix;
const schema = config.get('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
await queryRunner.query(`DROP INDEX "public"."IDX_${tablePrefixPure}a252c527c4c89237221fe2c0ab"`);
}
}

View file

@ -3,6 +3,8 @@ import { WebhookModel1589476000887 } from './1589476000887-WebhookModel';
import { CreateIndexStoppedAt1594828256133 } from './1594828256133-CreateIndexStoppedAt';
import { AddWebhookId1611144599516 } from './1611144599516-AddWebhookId';
import { MakeStoppedAtNullable1607431743768 } from './1607431743768-MakeStoppedAtNullable';
import { CreateTagEntity1617270242566 } from './1617270242566-CreateTagEntity';
import { UniqueWorkflowNames1620824779533 } from './1620824779533-UniqueWorkflowNames';
export const postgresMigrations = [
InitialMigration1587669153312,
@ -10,4 +12,6 @@ export const postgresMigrations = [
CreateIndexStoppedAt1594828256133,
AddWebhookId1611144599516,
MakeStoppedAtNullable1607431743768,
CreateTagEntity1617270242566,
UniqueWorkflowNames1620824779533,
];

View file

@ -1,44 +0,0 @@
import {
ICredentialNodeAccess,
} from 'n8n-workflow';
import {
ICredentialsDb,
} from '../../';
import {
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
export class CredentialsEntity implements ICredentialsDb {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 128,
})
name: string;
@Column('text')
data: string;
@Index()
@Column({
length: 32,
})
type: string;
@Column('simple-json')
nodesAccess: ICredentialNodeAccess[];
@Column()
createdAt: Date;
@Column()
updatedAt: Date;
}

View file

@ -1,52 +0,0 @@
import {
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
IExecutionFlattedDb,
IWorkflowDb,
} from '../../';
import {
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
export class ExecutionEntity implements IExecutionFlattedDb {
@PrimaryGeneratedColumn()
id: number;
@Column('text')
data: string;
@Column()
finished: boolean;
@Column('varchar')
mode: WorkflowExecuteMode;
@Column({ nullable: true })
retryOf: string;
@Column({ nullable: true })
retrySuccessId: string;
@Column()
startedAt: Date;
@Index()
@Column({ nullable: true })
stoppedAt: Date;
@Column('simple-json')
workflowData: IWorkflowDb;
@Index()
@Column({ nullable: true })
workflowId: string;
}

View file

@ -1,33 +0,0 @@
import {
Column,
Entity,
Index,
PrimaryColumn,
} from 'typeorm';
import {
IWebhookDb,
} from '../../Interfaces';
@Entity()
@Index(['webhookId', 'method', 'pathLength'])
export class WebhookEntity implements IWebhookDb {
@Column()
workflowId: number;
@PrimaryColumn()
webhookPath: string;
@PrimaryColumn()
method: string;
@Column()
node: string;
@Column({ nullable: true })
webhookId: string;
@Column({ nullable: true })
pathLength: number;
}

View file

@ -1,55 +0,0 @@
import {
IConnections,
IDataObject,
INode,
IWorkflowSettings,
} from 'n8n-workflow';
import {
IWorkflowDb,
} from '../../';
import {
Column,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity()
export class WorkflowEntity implements IWorkflowDb {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 128,
})
name: string;
@Column()
active: boolean;
@Column('simple-json')
nodes: INode[];
@Column('simple-json')
connections: IConnections;
@Column()
createdAt: Date;
@Column()
updatedAt: Date;
@Column({
type: 'simple-json',
nullable: true,
})
settings?: IWorkflowSettings;
@Column({
type: 'simple-json',
nullable: true,
})
staticData?: IDataObject;
}

View file

@ -1,4 +0,0 @@
export * from './CredentialsEntity';
export * from './ExecutionEntity';
export * from './WorkflowEntity';
export * from './WebhookEntity';

View file

@ -0,0 +1,69 @@
import {MigrationInterface, QueryRunner} from "typeorm";
import * as config from '../../../../config';
export class CreateTagEntity1617213344594 implements MigrationInterface {
name = 'CreateTagEntity1617213344594';
async up(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
// create tags table + relationship with workflow entity
await queryRunner.query(`CREATE TABLE "${tablePrefix}tag_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b" ON "${tablePrefix}tag_entity" ("name") `);
await queryRunner.query(`CREATE TABLE "${tablePrefix}workflows_tags" ("workflowId" integer NOT NULL, "tagId" integer NOT NULL, CONSTRAINT "FK_54b2f0343d6a2078fa137443869" FOREIGN KEY ("workflowId") REFERENCES "${tablePrefix}workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_77505b341625b0b4768082e2171" FOREIGN KEY ("tagId") REFERENCES "${tablePrefix}tag_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("workflowId", "tagId"))`);
await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}54b2f0343d6a2078fa13744386" ON "${tablePrefix}workflows_tags" ("workflowId") `);
await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}77505b341625b0b4768082e217" ON "${tablePrefix}workflows_tags" ("tagId") `);
// set default dates for `createdAt` and `updatedAt`
await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8"`);
await queryRunner.query(`CREATE TABLE "${tablePrefix}temporary_credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(32) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')))`);
await queryRunner.query(`INSERT INTO "${tablePrefix}temporary_credentials_entity"("id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt") SELECT "id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt" FROM "${tablePrefix}credentials_entity"`);
await queryRunner.query(`DROP TABLE "${tablePrefix}credentials_entity"`);
await queryRunner.query(`ALTER TABLE "${tablePrefix}temporary_credentials_entity" RENAME TO "${tablePrefix}credentials_entity"`);
await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "${tablePrefix}credentials_entity" ("type") `);
await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b"`);
await queryRunner.query(`CREATE TABLE "${tablePrefix}temporary_tag_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')))`);
await queryRunner.query(`INSERT INTO "${tablePrefix}temporary_tag_entity"("id", "name", "createdAt", "updatedAt") SELECT "id", "name", "createdAt", "updatedAt" FROM "${tablePrefix}tag_entity"`);
await queryRunner.query(`DROP TABLE "${tablePrefix}tag_entity"`);
await queryRunner.query(`ALTER TABLE "${tablePrefix}temporary_tag_entity" RENAME TO "${tablePrefix}tag_entity"`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b" ON "${tablePrefix}tag_entity" ("name") `);
await queryRunner.query(`CREATE TABLE "${tablePrefix}temporary_workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text, "connections" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "settings" text, "staticData" text)`);
await queryRunner.query(`INSERT INTO "${tablePrefix}temporary_workflow_entity"("id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData") SELECT "id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData" FROM "${tablePrefix}workflow_entity"`);
await queryRunner.query(`DROP TABLE "${tablePrefix}workflow_entity"`);
await queryRunner.query(`ALTER TABLE "${tablePrefix}temporary_workflow_entity" RENAME TO "${tablePrefix}workflow_entity"`);
}
async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
// tags
await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}77505b341625b0b4768082e217"`);
await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}54b2f0343d6a2078fa13744386"`);
await queryRunner.query(`DROP TABLE "${tablePrefix}workflows_tags"`);
await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b"`);
await queryRunner.query(`DROP TABLE "${tablePrefix}tag_entity"`);
// `createdAt` and `updatedAt`
await queryRunner.query(`ALTER TABLE "${tablePrefix}workflow_entity" RENAME TO "${tablePrefix}temporary_workflow_entity"`);
await queryRunner.query(`CREATE TABLE "${tablePrefix}workflow_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text NOT NULL, "connections" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL, "settings" text, "staticData" text)`);
await queryRunner.query(`INSERT INTO "${tablePrefix}workflow_entity"("id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData") SELECT "id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData" FROM "${tablePrefix}temporary_workflow_entity"`);
await queryRunner.query(`DROP TABLE "${tablePrefix}temporary_workflow_entity"`);
await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b"`);
await queryRunner.query(`ALTER TABLE "${tablePrefix}tag_entity" RENAME TO "${tablePrefix}temporary_tag_entity"`);
await queryRunner.query(`CREATE TABLE "${tablePrefix}tag_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(24) NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`);
await queryRunner.query(`INSERT INTO "${tablePrefix}tag_entity"("id", "name", "createdAt", "updatedAt") SELECT "id", "name", "createdAt", "updatedAt" FROM "${tablePrefix}temporary_tag_entity"`);
await queryRunner.query(`DROP TABLE "${tablePrefix}temporary_tag_entity"`);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_${tablePrefix}8f949d7a3a984759044054e89b" ON "${tablePrefix}tag_entity" ("name") `);
await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8"`);
await queryRunner.query(`ALTER TABLE "${tablePrefix}credentials_entity" RENAME TO "temporary_credentials_entity"`);
await queryRunner.query(`CREATE TABLE "${tablePrefix}credentials_entity" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "data" text NOT NULL, "type" varchar(32) NOT NULL, "nodesAccess" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL)`);
await queryRunner.query(`INSERT INTO "${tablePrefix}credentials_entity"("id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt") SELECT "id", "name", "data", "type", "nodesAccess", "createdAt", "updatedAt" FROM "${tablePrefix}temporary_credentials_entity"`);
await queryRunner.query(`DROP TABLE "${tablePrefix}temporary_credentials_entity"`);
await queryRunner.query(`CREATE INDEX "IDX_${tablePrefix}07fde106c0b471d8cc80a64fc8" ON "credentials_entity" ("type") `);
}
}

View file

@ -0,0 +1,47 @@
import {MigrationInterface, QueryRunner} from "typeorm";
import config = require("../../../../config");
export class UniqueWorkflowNames1620821879465 implements MigrationInterface {
name = 'UniqueWorkflowNames1620821879465';
async up(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
const workflowNames = await queryRunner.query(`
SELECT name
FROM "${tablePrefix}workflow_entity"
`);
for (const { name } of workflowNames) {
const duplicates = await queryRunner.query(`
SELECT id, name
FROM "${tablePrefix}workflow_entity"
WHERE name = "${name}"
ORDER BY createdAt ASC
`);
if (duplicates.length > 1) {
await Promise.all(duplicates.map(({ id, name }: { id: number; name: string; }, index: number) => {
if (index === 0) return Promise.resolve();
return queryRunner.query(`
UPDATE "${tablePrefix}workflow_entity"
SET name = "${name} ${index + 1}"
WHERE id = '${id}'
`);
}));
}
}
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_${tablePrefix}943d8f922be094eb507cb9a7f9" ON "${tablePrefix}workflow_entity" ("name") `);
}
async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}943d8f922be094eb507cb9a7f9"`);
}
}

View file

@ -3,6 +3,8 @@ import { WebhookModel1592445003908 } from './1592445003908-WebhookModel';
import { CreateIndexStoppedAt1594825041918 } from './1594825041918-CreateIndexStoppedAt';
import { AddWebhookId1611071044839 } from './1611071044839-AddWebhookId';
import { MakeStoppedAtNullable1607431743769 } from './1607431743769-MakeStoppedAtNullable';
import { CreateTagEntity1617213344594 } from './1617213344594-CreateTagEntity';
import { UniqueWorkflowNames1620821879465 } from './1620821879465-UniqueWorkflowNames';
export const sqliteMigrations = [
InitialMigration1588102412422,
@ -10,4 +12,6 @@ export const sqliteMigrations = [
CreateIndexStoppedAt1594825041918,
AddWebhookId1611071044839,
MakeStoppedAtNullable1607431743769,
CreateTagEntity1617213344594,
UniqueWorkflowNames1620821879465,
];

View file

@ -0,0 +1,42 @@
import {
DatabaseType,
} from '../index';
import { getConfigValueSync } from '../../src/GenericHelpers';
/**
* Resolves the data type for the used database type
*
* @export
* @param {string} dataType
* @returns {string}
*/
export function resolveDataType(dataType: string) {
const dbType = getConfigValueSync('database.type') as DatabaseType;
const typeMap: { [key in DatabaseType]: { [key: string]: string } } = {
sqlite: {
json: 'simple-json',
},
postgresdb: {
datetime: 'timestamp',
},
mysqldb: {},
mariadb: {},
};
return typeMap[dbType][dataType] ?? dataType;
}
export function getTimestampSyntax() {
const dbType = getConfigValueSync('database.type') as DatabaseType;
const map: { [key in DatabaseType]: string } = {
sqlite: "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')",
postgresdb: "CURRENT_TIMESTAMP(3)",
mysqldb: "CURRENT_TIMESTAMP(3)",
mariadb: "CURRENT_TIMESTAMP(3)",
};
return map[dbType];
}

View file

@ -24,7 +24,9 @@
"test:e2e": "vue-cli-service test:e2e",
"test:unit": "vue-cli-service test:unit"
},
"dependencies": {},
"dependencies": {
"v-click-outside": "^3.1.2"
},
"devDependencies": {
"@beyonk/google-fonts-webpack-plugin": "^1.5.0",
"@fortawesome/fontawesome-svg-core": "^1.2.19",

View file

@ -134,7 +134,7 @@ export interface IRestApi {
getNodeParameterOptions(nodeType: string, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise<INodePropertyOptions[]>;
removeTestWebhook(workflowId: string): Promise<boolean>;
runWorkflow(runData: IStartRunData): Promise<IExecutionPushResponse>;
createNewWorkflow(sendData: IWorkflowData): Promise<IWorkflowDb>;
createNewWorkflow(sendData: IWorkflowDataUpdate): Promise<IWorkflowDb>;
updateWorkflow(id: string, data: IWorkflowDataUpdate): Promise<IWorkflowDb>;
deleteWorkflow(name: string): Promise<void>;
getWorkflow(id: string): Promise<IWorkflowDb>;
@ -208,14 +208,17 @@ export interface IWorkflowData {
nodes: INode[];
connections: IConnections;
settings?: IWorkflowSettings;
tags?: string[];
}
export interface IWorkflowDataUpdate {
id?: string;
name?: string;
nodes?: INode[];
connections?: IConnections;
settings?: IWorkflowSettings;
active?: boolean;
tags?: ITag[] | string[]; // string[] when store or requested, ITag[] from API response
}
// Almost identical to cli.Interfaces.ts
@ -228,6 +231,7 @@ export interface IWorkflowDb {
nodes: INodeUi[];
connections: IConnections;
settings?: IWorkflowSettings;
tags?: ITag[] | string[]; // string[] when store or requested, ITag[] from API response
}
// Identical to cli.Interfaces.ts
@ -237,6 +241,7 @@ export interface IWorkflowShortResponse {
active: boolean;
createdAt: number | string;
updatedAt: number | string;
tags: ITag[];
}
@ -445,3 +450,84 @@ export interface ILinkMenuItemProperties {
href: string;
newWindow?: boolean;
}
export interface ITag {
id: string;
name: string;
usageCount?: number;
}
export interface ITagRow {
tag?: ITag;
usage?: string;
create?: boolean;
disable?: boolean;
update?: boolean;
delete?: boolean;
}
export interface IRootState {
activeExecutions: IExecutionsCurrentSummaryExtended[];
activeWorkflows: string[];
activeActions: string[];
activeNode: string | null;
baseUrl: string;
credentials: ICredentialsResponse[] | null;
credentialTypes: ICredentialType[] | null;
endpointWebhook: string;
endpointWebhookTest: string;
executionId: string | null;
executingNode: string | null;
executionWaitingForWebhook: boolean;
pushConnectionActive: boolean;
saveDataErrorExecution: string;
saveDataSuccessExecution: string;
saveManualExecutions: boolean;
timezone: string;
stateIsDirty: boolean;
executionTimeout: number;
maxExecutionTimeout: number;
versionCli: string;
oauthCallbackUrls: object;
n8nMetadata: object;
workflowExecutionData: IExecutionResponse | null;
lastSelectedNode: string | null;
lastSelectedNodeOutputIndex: number | null;
nodeIndex: Array<string | null>;
nodeTypes: INodeTypeDescription[];
nodeViewOffsetPosition: XYPositon;
nodeViewMoveInProgress: boolean;
selectedNodes: INodeUi[];
sessionId: string;
urlBaseWebhook: string;
workflow: IWorkflowDb;
sidebarMenuItems: IMenuItem[];
}
export interface ITagsState {
tags: { [id: string]: ITag };
isLoading: boolean;
fetchedAll: boolean;
fetchedUsageCount: boolean;
}
export interface IModalState {
open: boolean;
}
export interface IUiState {
sidebarMenuCollapsed: boolean;
modalStack: string[];
modals: {
[key: string]: IModalState;
};
isPageLoading: boolean;
}
export interface IWorkflowsState {
}
export interface IRestApiContext {
baseUrl: string;
sessionId: string;
}

View file

@ -0,0 +1,76 @@
import axios, { AxiosRequestConfig, Method } from 'axios';
import {
IDataObject,
} from 'n8n-workflow';
import {
IRestApiContext,
} from '../Interface';
class ResponseError extends Error {
// The HTTP status code of response
httpStatusCode?: number;
// The error code in the response
errorCode?: number;
// The stack trace of the server
serverStackTrace?: string;
/**
* Creates an instance of ResponseError.
* @param {string} message The error message
* @param {number} [errorCode] The error code which can be used by frontend to identify the actual error
* @param {number} [httpStatusCode] The HTTP status code the response should have
* @param {string} [stack] The stack trace
* @memberof ResponseError
*/
constructor (message: string, options: {errorCode?: number, httpStatusCode?: number, stack?: string} = {}) {
super(message);
this.name = 'ResponseError';
const { errorCode, httpStatusCode, stack } = options;
if (errorCode) {
this.errorCode = errorCode;
}
if (httpStatusCode) {
this.httpStatusCode = httpStatusCode;
}
if (stack) {
this.serverStackTrace = stack;
}
}
}
export async function makeRestApiRequest(context: IRestApiContext, method: Method, endpoint: string, data?: IDataObject) {
const { baseUrl, sessionId } = context;
const options: AxiosRequestConfig = {
method,
url: endpoint,
baseURL: baseUrl,
headers: {
sessionid: sessionId,
},
};
if (['PATCH', 'POST', 'PUT'].includes(method)) {
options.data = data;
} else {
options.params = data;
}
try {
const response = await axios.request(options);
return response.data.data;
} catch (error) {
if (error.message === 'Network Error') {
throw new ResponseError('API-Server can not be reached. It is probably down.');
}
const errorResponseData = error.response.data;
if (errorResponseData !== undefined && errorResponseData.message !== undefined) {
throw new ResponseError(errorResponseData.message, {errorCode: errorResponseData.code, httpStatusCode: error.response.status, stack: errorResponseData.stack});
}
throw error;
}
}

View file

@ -0,0 +1,18 @@
import { IRestApiContext, ITag } from '@/Interface';
import { makeRestApiRequest } from './helpers';
export async function getTags(context: IRestApiContext, withUsageCount = false): Promise<ITag[]> {
return await makeRestApiRequest(context, 'GET', '/tags', { withUsageCount });
}
export async function createTag(context: IRestApiContext, params: { name: string }): Promise<ITag> {
return await makeRestApiRequest(context, 'POST', '/tags', params);
}
export async function updateTag(context: IRestApiContext, id: string, params: { name: string }): Promise<ITag> {
return await makeRestApiRequest(context, 'PATCH', `/tags/${id}`, params);
}
export async function deleteTag(context: IRestApiContext, id: string): Promise<boolean> {
return await makeRestApiRequest(context, 'DELETE', `/tags/${id}`);
}

View file

@ -0,0 +1,6 @@
import { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from './helpers';
export async function getNewWorkflow(context: IRestApiContext, name?: string) {
return await makeRestApiRequest(context, 'GET', `/workflows/new`, name ? { name } : {});
}

View file

@ -0,0 +1,101 @@
<template>
<span>
<slot v-bind:bp="bp" v-bind:value="value" />
</span>
</template>
<script lang="ts">
import {
BREAKPOINT_SM,
BREAKPOINT_MD,
BREAKPOINT_LG,
BREAKPOINT_XL,
} from "@/constants";
/**
* matching element.io https://element.eleme.io/#/en-US/component/layout#col-attributes
* xs < 768
* sm >= 768
* md >= 992
* lg >= 1200
* xl >= 1920
*/
import mixins from "vue-typed-mixins";
import { genericHelpers } from "@/components/mixins/genericHelpers";
export default mixins(genericHelpers).extend({
name: "BreakpointsObserver",
props: [
"valueXS",
"valueXL",
"valueLG",
"valueMD",
"valueSM",
"valueDefault",
],
data() {
return {
width: window.innerWidth,
};
},
created() {
window.addEventListener("resize", this.onResize);
},
beforeDestroy() {
window.removeEventListener("resize", this.onResize);
},
methods: {
onResize() {
this.callDebounced("onResizeEnd", 50);
},
onResizeEnd() {
this.$data.width = window.innerWidth;
},
},
computed: {
bp(): string {
if (this.$data.width < BREAKPOINT_SM) {
return "XS";
}
if (this.$data.width >= BREAKPOINT_XL) {
return "XL";
}
if (this.$data.width >= BREAKPOINT_LG) {
return "LG";
}
if (this.$data.width >= BREAKPOINT_MD) {
return "MD";
}
return "SM";
},
value(): any | undefined { // tslint:disable-line:no-any
if (this.$props.valueXS !== undefined && this.$data.width < BREAKPOINT_SM) {
return this.$props.valueXS;
}
if (this.$props.valueXL !== undefined && this.$data.width >= BREAKPOINT_XL) {
return this.$props.valueXL;
}
if (this.$props.valueLG !== undefined && this.$data.width >= BREAKPOINT_LG) {
return this.$props.valueLG;
}
if (this.$props.valueMD !== undefined && this.$data.width >= BREAKPOINT_MD) {
return this.$props.valueMD;
}
if (this.$props.valueSM !== undefined) {
return this.$props.valueSM;
}
return this.$props.valueDefault;
},
},
});
</script>

View file

@ -0,0 +1,125 @@
<template>
<Modal
:name="modalName"
:eventBus="modalBus"
@enter="save"
size="sm"
title="Duplicate Workflow"
>
<template v-slot:content>
<el-row>
<el-input
v-model="name"
ref="nameInput"
placeholder="Enter workflow name"
:maxlength="MAX_WORKFLOW_NAME_LENGTH"
/>
</el-row>
<el-row>
<TagsDropdown
:createEnabled="true"
:currentTagIds="currentTagIds"
:eventBus="dropdownBus"
@blur="onTagsBlur"
@esc="onTagsEsc"
@update="onTagsUpdate"
placeholder="Choose or create a tag"
ref="dropdown"
/>
</el-row>
</template>
<template v-slot:footer="{ close }">
<el-button size="small" @click="save" :loading="isSaving">Save</el-button>
<el-button size="small" @click="close" :disabled="isSaving">Cancel</el-button>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from "vue";
import mixins from "vue-typed-mixins";
import { MAX_WORKFLOW_NAME_LENGTH } from "@/constants";
import { workflowHelpers } from "@/components/mixins/workflowHelpers";
import { showMessage } from "@/components/mixins/showMessage";
import TagsDropdown from "@/components/TagsDropdown.vue";
import Modal from "./Modal.vue";
export default mixins(showMessage, workflowHelpers).extend({
components: { TagsDropdown, Modal },
name: "DuplicateWorkflow",
props: ["dialogVisible", "modalName", "isActive"],
data() {
const currentTagIds = this.$store.getters[
"workflowTags"
] as string[];
return {
name: '',
currentTagIds,
isSaving: false,
modalBus: new Vue(),
dropdownBus: new Vue(),
MAX_WORKFLOW_NAME_LENGTH,
prevTagIds: currentTagIds,
};
},
async mounted() {
this.$data.name = await this.$store.dispatch('workflows/getDuplicateCurrentWorkflowName');
this.$nextTick(() => this.focusOnNameInput());
},
watch: {
isActive(active) {
if (active) {
this.focusOnSelect();
}
},
},
methods: {
focusOnSelect() {
this.dropdownBus.$emit('focus');
},
focusOnNameInput() {
const input = this.$refs.nameInput as HTMLElement;
if (input && input.focus) {
input.focus();
}
},
onTagsBlur() {
this.prevTagIds = this.currentTagIds;
},
onTagsEsc() {
// revert last changes
this.currentTagIds = this.prevTagIds;
},
onTagsUpdate(tagIds: string[]) {
this.currentTagIds = tagIds;
},
async save(): Promise<void> {
const name = this.name.trim();
if (!name) {
this.$showMessage({
title: "Name missing",
message: `Please enter a name.`,
type: "error",
});
return;
}
this.$data.isSaving = true;
const saved = await this.saveAsNewWorkflow({name, tags: this.currentTagIds});
if (saved) {
this.closeDialog();
}
this.$data.isSaving = false;
},
closeDialog(): void {
this.modalBus.$emit("close");
},
},
});
</script>

View file

@ -0,0 +1,70 @@
<template>
<!-- mock el-input element to apply styles -->
<div :class="{'el-input': true, 'static-size': staticSize}" :data-value="hiddenValue">
<slot></slot>
</div>
</template>
<script lang="ts">
import Vue from "vue";
export default Vue.extend({
name: "ExpandableInputBase",
props: ['value', 'placeholder', 'staticSize'],
computed: {
hiddenValue() {
let value = (this.value as string).replace(/\s/g, '.'); // force input to expand on space chars
if (!value) {
// @ts-ignore
value = this.$props.placeholder;
}
return `${value}`; // adjust for padding
},
},
});
</script>
<style lang="scss" scoped>
$--horiz-padding: 15px;
*,
*::after {
box-sizing: border-box;
}
input {
border: 1px solid transparent;
padding: 0 $--horiz-padding - 2px; // -2px for borders
}
div.el-input {
display: inline-grid;
font: inherit;
padding: 10px 0;
&::after,
input {
grid-area: 1 / 2;
font: inherit;
}
&::after {
content: attr(data-value) ' ';
visibility: hidden;
white-space: nowrap;
padding: 0 $--horiz-padding;
}
&:not(.static-size)::after {
overflow: hidden;
}
&:hover {
input {
border: $--custom-input-border-shadow
}
}
}
</style>

View file

@ -0,0 +1,64 @@
<template>
<ExpandableInputBase :value="value" :placeholder="placeholder">
<input
class="el-input__inner"
:value="value"
:placeholder="placeholder"
:maxlength="maxlength"
@input="onInput"
@keydown.enter="onEnter"
@keydown.esc="onEscape"
ref="input"
size="4"
v-click-outside="onBlur"
/>
</ExpandableInputBase>
</template>
<script lang="ts">
import Vue from "vue";
import ExpandableInputBase from "./ExpandableInputBase.vue";
export default Vue.extend({
components: { ExpandableInputBase },
name: "ExpandableInputEdit",
props: ['value', 'placeholder', 'maxlength', 'autofocus', 'eventBus'],
mounted() {
// autofocus on input element is not reliable
if (this.$props.autofocus && this.$refs.input) {
this.focus();
}
if (this.$props.eventBus) {
this.$props.eventBus.$on('focus', () => {
this.focus();
});
}
},
methods: {
focus() {
if (this.$refs.input) {
(this.$refs.input as HTMLInputElement).focus();
}
},
onInput() {
this.$emit('input', (this.$refs.input as HTMLInputElement).value);
},
onEnter() {
this.$emit('enter', (this.$refs.input as HTMLInputElement).value);
},
onBlur() {
this.$emit('blur', (this.$refs.input as HTMLInputElement).value);
},
onEscape() {
this.$emit('esc');
},
},
});
</script>
<style lang="scss" scoped>
.el-input input.el-input__inner {
border: 1px solid $--color-primary !important;
}
</style>

View file

@ -0,0 +1,40 @@
<template>
<ExpandableInputBase :value="value" :staticSize="true">
<template>
<input
:class="{ 'el-input__inner': true, clickable: true }"
:value="value"
:disabled="true"
size="4"
/>
</template>
</ExpandableInputBase>
</template>
<script lang="ts">
import Vue from "vue";
import ExpandableInputBase from "./ExpandableInputBase.vue";
export default Vue.extend({
components: { ExpandableInputBase },
name: "ExpandableInputPreview",
props: ["value"],
});
</script>
<style lang="scss" scoped>
input,
input:hover {
background-color: unset;
transition: unset;
pointer-events: none; // fix firefox bug
}
input[disabled] {
color: $--custom-font-black;
// override safari colors
-webkit-text-fill-color: $--custom-font-black;
-webkit-opacity: 1;
}
</style>

View file

@ -0,0 +1,100 @@
<template>
<span @keydown.stop class="inline-edit" >
<span v-if="isEditEnabled">
<ExpandableInputEdit
:placeholder="placeholder"
:value="newValue"
:maxlength="maxLength"
:autofocus="true"
:eventBus="inputBus"
@input="onInput"
@esc="onEscape"
@blur="onBlur"
@enter="submit"
/>
</span>
<span @click="onClick" class="preview" v-else>
<ExpandableInputPreview
:value="previewValue || value"
/>
</span>
</span>
</template>
<script lang="ts">
import Vue from "vue";
import ExpandableInputEdit from "@/components/ExpandableInput/ExpandableInputEdit.vue";
import ExpandableInputPreview from "@/components/ExpandableInput/ExpandableInputPreview.vue";
export default Vue.extend({
name: "InlineTextEdit",
components: { ExpandableInputEdit, ExpandableInputPreview },
props: ['isEditEnabled', 'value', 'placeholder', 'maxLength', 'previewValue'],
data() {
return {
newValue: '',
escPressed: false,
disabled: false,
inputBus: new Vue(),
};
},
methods: {
onInput(newValue: string) {
if (this.disabled) {
return;
}
this.newValue = newValue;
},
onClick() {
if (this.disabled) {
return;
}
this.$data.newValue = this.$props.value;
this.$emit('toggle');
},
onBlur() {
if (this.disabled) {
return;
}
if (!this.$data.escPressed) {
this.submit();
}
this.$data.escPressed = false;
},
submit() {
if (this.disabled) {
return;
}
const onSubmit = (updated: boolean) => {
this.$data.disabled = false;
if (!updated) {
this.$data.inputBus.$emit('focus');
}
};
this.$data.disabled = true;
this.$emit('submit', this.newValue, onSubmit);
},
onEscape() {
if (this.disabled) {
return;
}
this.$data.escPressed = true;
this.$emit('toggle');
},
},
});
</script>
<style lang="scss" scoped>
.preview {
cursor: pointer;
}
</style>

View file

@ -0,0 +1,30 @@
<template>
<span ref="observed">
<slot></slot>
</span>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import emitter from '@/components/mixins/emitter';
export default mixins(emitter).extend({
name: 'IntersectionObserved',
props: ['enabled'],
mounted() {
if (!this.$props.enabled) {
return;
}
this.$nextTick(() => {
this.$dispatch('IntersectionObserver', 'observe', this.$refs.observed);
});
},
beforeDestroy() {
if (this.$props.enabled) {
this.$dispatch('IntersectionObserver', 'unobserve', this.$refs.observed);
}
},
});
</script>

View file

@ -0,0 +1,56 @@
<template>
<div ref="root">
<slot></slot>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'IntersectionObserver',
props: ['threshold', 'enabled'],
data() {
return {
observer: null,
};
},
mounted() {
if (!this.$props.enabled) {
return;
}
const options = {
root: this.$refs.root as Element,
rootMargin: '0px',
threshold: this.$props.threshold,
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(({target, isIntersecting}) => {
this.$emit('observed', {
el: target,
isIntersecting,
});
});
}, options);
this.$data.observer = observer;
this.$on('observe', (observed: Element) => {
observer.observe(observed);
});
this.$on('unobserve', (observed: Element) => {
observer.unobserve(observed);
});
},
beforeDestroy() {
if (this.$props.enabled) {
this.$data.observer.disconnect();
}
},
});
</script>

View file

@ -1,288 +0,0 @@
<template>
<div>
<div class="main-header">
<input type="file" ref="importFile" style="display: none" v-on:change="handleFileImport()">
<div class="top-menu">
<div class="center-item">
<span v-if="isExecutionPage">
Execution Id:
<span v-if="isExecutionPage" class="execution-name">
<strong>{{executionId}}</strong>&nbsp;
<font-awesome-icon icon="check" class="execution-icon success" v-if="executionFinished" title="Execution was successful" />
<font-awesome-icon icon="times" class="execution-icon error" v-else title="Execution did fail" />
</span>
of
<span class="workflow-name clickable" title="Open Workflow">
<span @click="openWorkflow(workflowExecution.workflowId)">"{{workflowName}}"</span>
</span>
workflow
</span>
<span index="workflow-name" class="current-workflow" v-if="!isReadOnly">
<span v-if="currentWorkflow">Workflow: <span class="workflow-name">{{workflowName}}<span v-if="isDirty">*</span></span></span>
<span v-else class="workflow-not-saved">Workflow was not saved!</span>
</span>
<span class="saving-workflow" v-if="isWorkflowSaving">
<font-awesome-icon icon="spinner" spin />
Saving...
</span>
</div>
<div class="push-connection-lost" v-if="!isPushConnectionActive">
<el-tooltip placement="bottom-end" effect="light">
<div slot="content">
Cannot connect to server.<br />
It is either down or you have a connection issue. <br />
It should reconnect automatically once the issue is resolved.
</div>
<span>
<font-awesome-icon icon="exclamation-triangle" />&nbsp;
Connection lost
</span>
</el-tooltip>
</div>
<div class="workflow-active" v-else-if="!isReadOnly">
Active:
<workflow-activator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflow" :disabled="!currentWorkflow"/>
</div>
<div class="read-only" v-if="isReadOnly">
<el-tooltip placement="bottom-end" effect="light">
<div slot="content">
You're viewing the log of a previous execution. You cannot<br />
make changes since this execution already occured. Make changes<br /> to this workflow by clicking on it`s name on the left.
</div>
<span>
<font-awesome-icon icon="exclamation-triangle" />
Read only
</span>
</el-tooltip>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IExecutionResponse,
IExecutionsStopData,
IWorkflowDataUpdate,
} from '../Interface';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { pushConnection } from '@/components/mixins/pushConnection';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import { titleChange } from '@/components/mixins/titleChange';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { saveAs } from 'file-saver';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
pushConnection,
restApi,
showMessage,
titleChange,
workflowHelpers,
)
.extend({
name: 'MainHeader',
components: {
WorkflowActivator,
},
computed: {
executionId (): string | undefined {
return this.$route.params.id;
},
executionFinished (): boolean {
if (!this.isExecutionPage) {
// We are not on an execution page so return false
return false;
}
const fullExecution = this.$store.getters.getWorkflowExecution;
if (fullExecution === null) {
// No execution loaded so return also false
return false;
}
if (fullExecution.finished === true) {
return true;
}
return false;
},
isExecutionPage (): boolean {
if (['ExecutionById'].includes(this.$route.name as string)) {
return true;
}
return false;
},
isPushConnectionActive (): boolean {
return this.$store.getters.pushConnectionActive;
},
isWorkflowActive (): boolean {
return this.$store.getters.isActive;
},
isWorkflowSaving (): boolean {
return this.$store.getters.isActionActive('workflowSaving');
},
currentWorkflow (): string {
return this.$route.params.name;
},
workflowExecution (): IExecutionResponse | null {
return this.$store.getters.getWorkflowExecution;
},
workflowName (): string {
return this.$store.getters.workflowName;
},
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
isDirty () : boolean {
return this.$store.getters.getStateIsDirty;
},
},
methods: {
async openWorkflow (workflowId: string) {
this.$titleSet(this.workflowName, 'IDLE');
// Change to other workflow
this.$router.push({
name: 'NodeViewExisting',
params: { name: workflowId },
});
},
},
async mounted () {
// Initialize the push connection
this.pushConnect();
},
beforeDestroy () {
this.pushDisconnect();
},
});
</script>
<style lang="scss">
.el-menu--horizontal>.el-menu-item,
.el-menu--horizontal>.el-submenu .el-submenu__title,
.el-menu-item {
height: 65px;
line-height: 65px;
}
.el-submenu .el-submenu__title,
.el-menu--horizontal>.el-menu-item,
.el-menu.el-menu--horizontal {
border: none !important;
}
.el-menu--popup-bottom-start {
margin-top: 0px;
border-top: 1px solid #464646;
border-radius: 0 0 2px 2px;
}
.main-header {
position: fixed;
top: 0;
background-color: #fff;
height: 65px;
width: 100%;
}
.top-menu {
position: relative;
font-size: 0.9em;
width: 100%;
font-weight: 400;
.center-item {
margin: 0 auto;
text-align: center;
line-height: 65px;
.saving-workflow {
display: inline-block;
margin-left: 2em;
padding: 0 15px;
color: $--color-primary;
background-color: $--color-primary-light;
line-height: 30px;
height: 30px;
border-radius: 15px;
}
}
.read-only {
position: absolute;
top: 0;
line-height: 65px;
margin-right: 5em;
right: 0;
color: $--color-primary;
}
.push-connection-lost {
position: absolute;
top: 0;
line-height: 65px;
margin-right: 5em;
right: 0;
color: $--color-primary;
}
.workflow-active {
position: absolute;
top: 0;
line-height: 65px;
margin-right: 5em;
right: 0;
}
.workflow-name {
color: $--color-primary;
}
}
</style>
<style scoped lang="scss">
.current-execution,
.current-workflow {
vertical-align: top;
}
.execution-icon.error,
.workflow-not-saved {
color: #FF2244;
}
.execution-icon.success {
color: #22FF44;
}
.menu-separator-bottom {
border-bottom: 1px solid #707070;
}
.menu-separator-top {
border-top: 1px solid #707070;
}
</style>

View file

@ -0,0 +1,104 @@
<template>
<div class="container">
<span class="title">
Execution Id:
<span>
<strong>{{ executionId }}</strong
>&nbsp;
<font-awesome-icon
icon="check"
class="execution-icon success"
v-if="executionFinished"
title="Execution was successful"
/>
<font-awesome-icon
icon="times"
class="execution-icon error"
v-else
title="Execution failed"
/>
</span>
of
<span class="primary-color clickable" title="Open Workflow">
<WorkflowNameShort :name="workflowName">
<template v-slot="{ shortenedName }">
<span @click="openWorkflow(workflowExecution.workflowId)">
"{{ shortenedName }}"
</span>
</template>
</WorkflowNameShort>
</span>
workflow
</span>
<ReadOnly class="read-only" />
</div>
</template>
<script lang="ts">
import mixins from "vue-typed-mixins";
import { IExecutionResponse } from "../../../Interface";
import { titleChange } from "@/components/mixins/titleChange";
import WorkflowNameShort from "@/components/WorkflowNameShort.vue";
import ReadOnly from "@/components/MainHeader/ExecutionDetails/ReadOnly.vue";
export default mixins(titleChange).extend({
name: "ExecutionDetails",
components: {
WorkflowNameShort,
ReadOnly,
},
computed: {
executionId(): string | undefined {
return this.$route.params.id;
},
executionFinished(): boolean {
const fullExecution = this.$store.getters.getWorkflowExecution;
return !!fullExecution && fullExecution.finished;
},
workflowExecution(): IExecutionResponse | null {
return this.$store.getters.getWorkflowExecution;
},
workflowName(): string {
return this.$store.getters.workflowName;
},
},
methods: {
async openWorkflow(workflowId: string) {
this.$titleSet(this.workflowName, "IDLE");
// Change to other workflow
this.$router.push({
name: "NodeViewExisting",
params: { name: workflowId },
});
},
},
});
</script>
<style scoped lang="scss">
* {
box-sizing: border-box;
}
.execution-icon.success {
color: $--custom-success-text-light;
}
.container {
width: 100%;
display: flex;
}
.title {
flex: 1;
text-align: center;
}
.read-only {
align-self: flex-end;
}
</style>

View file

@ -0,0 +1,13 @@
<template>
<el-tooltip class="primary-color" placement="bottom-end" effect="light">
<div slot="content">
You're viewing the log of a previous execution. You cannot<br />
make changes since this execution already occured. Make changes<br />
to this workflow by clicking on its name on the left.
</div>
<span>
<font-awesome-icon icon="exclamation-triangle" />
Read only
</span>
</el-tooltip>
</template>

View file

@ -0,0 +1,94 @@
<template>
<div>
<div :class="{'main-header': true, expanded: !sidebarMenuCollapsed}">
<div class="top-menu">
<ExecutionDetails v-if="isExecutionPage" />
<WorkflowDetails v-else />
</div>
</div>
</div>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import { pushConnection } from '@/components/mixins/pushConnection';
import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue';
import ExecutionDetails from '@/components/MainHeader/ExecutionDetails/ExecutionDetails.vue';
export default mixins(
pushConnection,
)
.extend({
name: 'MainHeader',
components: {
WorkflowDetails,
ExecutionDetails,
},
computed: {
...mapGetters('ui', [
'sidebarMenuCollapsed',
]),
isExecutionPage (): boolean {
return ['ExecutionById'].includes(this.$route.name as string);
},
},
async mounted() {
// Initialize the push connection
this.pushConnect();
},
beforeDestroy() {
this.pushDisconnect();
},
});
</script>
<style lang="scss">
.el-menu--horizontal>.el-menu-item,
.el-menu--horizontal>.el-submenu .el-submenu__title,
.el-menu-item {
height: 65px;
line-height: 65px;
}
.el-submenu .el-submenu__title,
.el-menu--horizontal>.el-menu-item,
.el-menu.el-menu--horizontal {
border: none !important;
}
.el-menu--popup-bottom-start {
margin-top: 0px;
border-top: 1px solid #464646;
border-radius: 0 0 2px 2px;
}
.main-header {
position: fixed;
top: 0;
background-color: #fff;
height: 65px;
width: 100%;
box-sizing: border-box;
padding-left: $--sidebar-width;
&.expanded {
padding-left: $--sidebar-expanded-width;
}
* {
box-sizing: border-box;
}
}
.top-menu {
display: flex;
align-items: center;
font-size: 0.9em;
height: $--header-height;
font-weight: 400;
padding: 0 20px;
}
</style>

View file

@ -0,0 +1,279 @@
<template>
<div class="container" v-if="workflowName">
<BreakpointsObserver :valueXS="15" :valueSM="25" :valueMD="50" class="name-container">
<template v-slot="{ value }">
<WorkflowNameShort
:name="workflowName"
:limit="value"
:custom="true"
>
<template v-slot="{ shortenedName }">
<InlineTextEdit
:value="workflowName"
:previewValue="shortenedName"
:isEditEnabled="isNameEditEnabled"
:maxLength="MAX_WORKFLOW_NAME_LENGTH"
@toggle="onNameToggle"
@submit="onNameSubmit"
placeholder="Enter workflow name"
class="name"
/>
</template>
</WorkflowNameShort>
</template>
</BreakpointsObserver>
<div
v-if="isTagsEditEnabled"
class="tags">
<TagsDropdown
:createEnabled="true"
:currentTagIds="appliedTagIds"
:eventBus="tagsEditBus"
@blur="onTagsBlur"
@update="onTagsUpdate"
@esc="onTagsEditEsc"
placeholder="Choose or create a tag"
ref="dropdown"
class="tags-edit"
/>
</div>
<div
class="tags"
v-else-if="currentWorkflowTagIds.length === 0"
>
<span
class="add-tag clickable"
@click="onTagsEditEnable"
>
+ Add tag
</span>
</div>
<TagsContainer
v-else
:tagIds="currentWorkflowTagIds"
:clickable="true"
:responsive="true"
:key="currentWorkflowId"
@click="onTagsEditEnable"
class="tags"
/>
<PushConnectionTracker class="actions">
<template>
<span class="activator">
<span>Active:</span>
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" :disabled="!currentWorkflowId"/>
</span>
<SaveWorkflowButton />
</template>
</PushConnectionTracker>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import mixins from "vue-typed-mixins";
import { mapGetters } from "vuex";
import { MAX_WORKFLOW_NAME_LENGTH } from "@/constants";
import WorkflowNameShort from "@/components/WorkflowNameShort.vue";
import TagsContainer from "@/components/TagsContainer.vue";
import PushConnectionTracker from "@/components/PushConnectionTracker.vue";
import WorkflowActivator from "@/components/WorkflowActivator.vue";
import { workflowHelpers } from "@/components/mixins/workflowHelpers";
import SaveWorkflowButton from "@/components/SaveWorkflowButton.vue";
import TagsDropdown from "@/components/TagsDropdown.vue";
import InlineTextEdit from "@/components/InlineTextEdit.vue";
import BreakpointsObserver from "@/components/BreakpointsObserver.vue";
const hasChanged = (prev: string[], curr: string[]) => {
if (prev.length !== curr.length) {
return true;
}
const set = new Set(prev);
return curr.reduce((accu, val) => accu || !set.has(val), false);
};
export default mixins(workflowHelpers).extend({
name: "WorkflowDetails",
components: {
TagsContainer,
PushConnectionTracker,
WorkflowNameShort,
WorkflowActivator,
SaveWorkflowButton,
TagsDropdown,
InlineTextEdit,
BreakpointsObserver,
},
data() {
return {
isTagsEditEnabled: false,
isNameEditEnabled: false,
appliedTagIds: [],
tagsEditBus: new Vue(),
MAX_WORKFLOW_NAME_LENGTH,
tagsSaving: false,
};
},
computed: {
...mapGetters({
isWorkflowActive: "isActive",
workflowName: "workflowName",
isDirty: "getStateIsDirty",
currentWorkflowTagIds: "workflowTags",
}),
isWorkflowSaving(): boolean {
return this.$store.getters.isActionActive("workflowSaving");
},
currentWorkflowId() {
return this.$route.params.name;
},
},
methods: {
onTagsEditEnable() {
this.$data.appliedTagIds = this.currentWorkflowTagIds;
this.$data.isTagsEditEnabled = true;
setTimeout(() => {
// allow name update to occur before disabling name edit
this.$data.isNameEditEnabled = false;
this.$data.tagsEditBus.$emit('focus');
}, 0);
},
async onTagsUpdate(tags: string[]) {
this.$data.appliedTagIds = tags;
},
async onTagsBlur() {
const current = this.currentWorkflowTagIds;
const tags = this.$data.appliedTagIds;
if (!hasChanged(current, tags)) {
this.$data.isTagsEditEnabled = false;
return;
}
if (this.$data.tagsSaving) {
return;
}
this.$data.tagsSaving = true;
const saved = await this.saveCurrentWorkflow({ tags });
this.$data.tagsSaving = false;
if (saved) {
this.$data.isTagsEditEnabled = false;
}
},
onTagsEditEsc() {
this.$data.isTagsEditEnabled = false;
},
onNameToggle() {
this.$data.isNameEditEnabled = !this.$data.isNameEditEnabled;
if (this.$data.isNameEditEnabled) {
if (this.$data.isTagsEditEnabled) {
// @ts-ignore
this.onTagsBlur();
}
this.$data.isTagsEditEnabled = false;
}
},
async onNameSubmit(name: string, cb: (saved: boolean) => void) {
const newName = name.trim();
if (!newName) {
this.$showMessage({
title: "Name missing",
message: `Please enter a name, or press 'esc' to go back to the old one.`,
type: "error",
});
cb(false);
return;
}
if (newName === this.workflowName) {
this.$data.isNameEditEnabled = false;
cb(true);
return;
}
const saved = await this.saveCurrentWorkflow({ name });
if (saved) {
this.$data.isNameEditEnabled = false;
}
cb(saved);
},
},
watch: {
currentWorkflowId() {
this.$data.isTagsEditEnabled = false;
this.$data.isNameEditEnabled = false;
},
},
});
</script>
<style scoped lang="scss">
$--text-line-height: 24px;
$--header-spacing: 20px;
.container {
width: 100%;
display: flex;
align-items: center;
}
.name-container {
margin-right: $--header-spacing;
}
.name {
color: $--custom-font-dark;
font-size: 15px;
}
.activator {
color: $--custom-font-dark;
font-weight: 400;
font-size: 13px;
line-height: $--text-line-height;
display: flex;
align-items: center;
margin-right: 30px;
> span {
margin-right: 5px;
}
}
.add-tag {
font-size: 12px;
padding: 20px 0; // to be more clickable
color: $--custom-font-very-light;
font-weight: 600;
white-space: nowrap;
&:hover {
color: $--color-primary;
}
}
.tags {
flex: 1;
padding-right: 20px;
margin-right: $--header-spacing;
}
.tags-edit {
min-width: 100px;
max-width: 460px;
}
.actions {
display: flex;
align-items: center;
}
</style>

View file

@ -4,12 +4,11 @@
<executions-list :dialogVisible="executionsListDialogVisible" @closeDialog="closeExecutionsListOpenDialog"></executions-list>
<credentials-list :dialogVisible="credentialOpenDialogVisible" @closeDialog="closeCredentialOpenDialog"></credentials-list>
<credentials-edit :dialogVisible="credentialNewDialogVisible" @closeDialog="closeCredentialNewDialog"></credentials-edit>
<workflow-open @openWorkflow="openWorkflow" :dialogVisible="workflowOpenDialogVisible" @closeDialog="closeWorkflowOpenDialog"></workflow-open>
<workflow-settings :dialogVisible="workflowSettingsDialogVisible" @closeDialog="closeWorkflowSettingsDialog"></workflow-settings>
<input type="file" ref="importFile" style="display: none" v-on:change="handleFileImport()">
<div class="side-menu-wrapper" :class="{expanded: !isCollapsed}">
<div id="collapse-change-button" class="clickable" @click="isCollapsed=!isCollapsed">
<div id="collapse-change-button" class="clickable" @click="toggleCollapse">
<font-awesome-icon icon="angle-right" class="icon" />
</div>
<el-menu default-active="workflow" @select="handleSelect" :collapse="isCollapsed">
@ -41,22 +40,16 @@
<span slot="title" class="item-title">Open</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-save" :disabled="!currentWorkflow">
<el-menu-item index="workflow-save">
<template slot="title">
<font-awesome-icon icon="save"/>
<span slot="title" class="item-title">Save</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-save-as">
<el-menu-item index="workflow-duplicate" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="copy"/>
<span slot="title" class="item-title">Save As</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-rename" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="edit"/>
<span slot="title" class="item-title">Rename</span>
<span slot="title" class="item-title">Duplicate</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-delete" :disabled="!currentWorkflow">
@ -143,7 +136,6 @@
<script lang="ts">
import Vue from 'vue';
import { MessageBoxInputData } from 'element-ui/types/message-box';
import {
@ -157,7 +149,6 @@ import About from '@/components/About.vue';
import CredentialsEdit from '@/components/CredentialsEdit.vue';
import CredentialsList from '@/components/CredentialsList.vue';
import ExecutionsList from '@/components/ExecutionsList.vue';
import WorkflowOpen from '@/components/WorkflowOpen.vue';
import WorkflowSettings from '@/components/WorkflowSettings.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
@ -170,6 +161,7 @@ import { workflowRun } from '@/components/mixins/workflowRun';
import { saveAs } from 'file-saver';
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import MenuItemsIterator from './MainSidebarMenuItemsIterator.vue';
const helpMenuItems: IMenuItem[] = [
@ -220,7 +212,6 @@ export default mixins(
CredentialsEdit,
CredentialsList,
ExecutionsList,
WorkflowOpen,
WorkflowSettings,
MenuItemsIterator,
},
@ -229,17 +220,18 @@ export default mixins(
aboutDialogVisible: false,
// @ts-ignore
basePath: this.$store.getters.getBaseUrl,
isCollapsed: true,
credentialNewDialogVisible: false,
credentialOpenDialogVisible: false,
executionsListDialogVisible: false,
stopExecutionInProgress: false,
workflowOpenDialogVisible: false,
workflowSettingsDialogVisible: false,
helpMenuItems,
};
},
computed: {
...mapGetters('ui', {
isCollapsed: 'sidebarMenuCollapsed',
}),
exeuctionId (): string | undefined {
return this.$route.params.id;
},
@ -294,6 +286,9 @@ export default mixins(
},
},
methods: {
toggleCollapse () {
this.$store.commit('ui/toggleSidebarMenuCollapse');
},
clearExecutionData () {
this.$store.commit('setWorkflowExecutionData', null);
this.updateNodesExecutionIssues();
@ -301,9 +296,6 @@ export default mixins(
closeAboutDialog () {
this.aboutDialogVisible = false;
},
closeWorkflowOpenDialog () {
this.workflowOpenDialogVisible = false;
},
closeWorkflowSettingsDialog () {
this.workflowSettingsDialogVisible = false;
},
@ -316,6 +308,9 @@ export default mixins(
closeCredentialNewDialog () {
this.credentialNewDialogVisible = false;
},
openTagManager() {
this.$store.dispatch('ui/openTagsManagerModal');
},
async stopExecution () {
const executionId = this.$store.getters.activeExecutionId;
if (executionId === null) {
@ -342,7 +337,7 @@ export default mixins(
params: { name: workflowId },
});
this.workflowOpenDialogVisible = false;
this.$store.commit('ui/closeTopModal');
},
async handleFileImport () {
const reader = new FileReader();
@ -372,7 +367,7 @@ export default mixins(
},
async handleSelect (key: string, keyPath: string) {
if (key === 'workflow-open') {
this.workflowOpenDialogVisible = true;
this.$store.dispatch('ui/openWorklfowOpenModal');
} else if (key === 'workflow-import-file') {
(this.$refs.importFile as HTMLInputElement).click();
} else if (key === 'workflow-import-url') {
@ -386,49 +381,6 @@ export default mixins(
this.$root.$emit('importWorkflowUrl', { url: promptResponse.value });
} catch (e) {}
} else if (key === 'workflow-rename') {
const workflowName = await this.$prompt(
'Enter new workflow name',
'Rename',
{
inputValue: this.workflowName,
confirmButtonText: 'Rename',
cancelButtonText: 'Cancel',
},
)
.then((data) => {
// @ts-ignore
return data.value;
})
.catch(() => {
// User did cancel
return undefined;
});
if (workflowName === undefined || workflowName === this.workflowName) {
return;
}
const workflowId = this.$store.getters.workflowId;
const updateData = {
name: workflowName,
};
try {
await this.restApi().updateWorkflow(workflowId, updateData);
} catch (error) {
this.$showError(error, 'Problem renaming the workflow', 'There was a problem renaming the workflow:');
return;
}
this.$store.commit('setWorkflowName', {newName: workflowName, setStateDirty: false});
this.$showMessage({
title: 'Workflow renamed',
message: `The workflow got renamed to "${workflowName}"!`,
type: 'success',
});
} else if (key === 'workflow-delete') {
const deleteConfirmed = await this.confirmMessage(`Are you sure that you want to delete the workflow "${this.workflowName}"?`, 'Delete Workflow?', 'warning', 'Yes, delete!');
@ -454,7 +406,9 @@ export default mixins(
this.$router.push({ name: 'NodeViewNew' });
} else if (key === 'workflow-download') {
const workflowData = await this.getWorkflowDataToSave();
const blob = new Blob([JSON.stringify(workflowData, null, 2)], {
const {tags, ...data} = workflowData;
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json;charset=utf-8',
});
@ -465,8 +419,8 @@ export default mixins(
saveAs(blob, workflowName + '.json');
} else if (key === 'workflow-save') {
this.saveCurrentWorkflow();
} else if (key === 'workflow-save-as') {
this.saveCurrentWorkflow(true);
} else if (key === 'workflow-duplicate') {
this.$store.dispatch('ui/openDuplicateModal');
} else if (key === 'help-about') {
this.aboutDialogVisible = true;
} else if (key === 'workflow-settings') {
@ -508,11 +462,6 @@ export default mixins(
}
},
},
async mounted () {
this.$root.$on('openWorkflowDialog', async () => {
this.workflowOpenDialogVisible = true;
});
},
});
</script>
@ -568,7 +517,7 @@ export default mixins(
&.logo-item {
background-color: $--color-primary !important;
height: 65px;
height: $--header-height;
.icon {
position: relative;
@ -610,10 +559,10 @@ a.logo {
.side-menu-wrapper {
height: 100%;
width: 65px;
width: $--sidebar-width;
&.expanded {
width: 200px;
width: $--sidebar-expanded-width;
}
}

View file

@ -0,0 +1,115 @@
<template>
<div v-if="dialogVisible">
<el-dialog
:visible="dialogVisible"
:before-close="closeDialog"
:title="title"
:class="{ 'dialog-wrapper': true, [size]: true }"
:width="width"
append-to-body
>
<template v-slot:title>
<slot name="header" />
</template>
<div class="modal-content" @keydown.stop @keydown.enter="handleEnter" @keydown.esc="closeDialog">
<slot name="content"/>
</div>
<el-row class="modal-footer">
<slot name="footer" :close="closeDialog" />
</el-row>
</el-dialog>
</div>
</template>
<script lang="ts">
import Vue from "vue";
const sizeMap: {[size: string]: string} = {
xl: '80%',
m: '50%',
default: '50%',
};
export default Vue.extend({
name: "Modal",
props: ['name', 'title', 'eventBus', 'size'],
mounted() {
window.addEventListener('keydown', this.onWindowKeydown);
if (this.$props.eventBus) {
this.$props.eventBus.$on('close', () => {
this.closeDialog();
});
}
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
},
beforeDestroy() {
window.removeEventListener('keydown', this.onWindowKeydown);
},
methods: {
onWindowKeydown(event: KeyboardEvent) {
if (!this.isActive) {
return;
}
if (event && event.keyCode === 13) {
this.handleEnter();
}
},
handleEnter() {
if (this.isActive) {
this.$emit('enter');
}
},
closeDialog() {
this.$store.commit('ui/closeTopModal');
},
},
computed: {
width(): string {
return this.$props.size ? sizeMap[this.$props.size] : sizeMap.default;
},
isActive(): boolean {
return this.$store.getters['ui/isModalActive'](this.$props.name);
},
dialogVisible(): boolean {
return this.$store.getters['ui/isModalOpen'](this.$props.name);
},
},
});
</script>
<style lang="scss">
.dialog-wrapper {
* {
box-sizing: border-box;
}
&.xl > div, &.md > div {
min-width: 620px;
}
&.sm {
display: flex;
align-items: center;
justify-content: center;
> div {
max-width: 420px;
}
}
}
.modal-content > .el-row {
margin-bottom: 15px;
}
.modal-footer > .el-button {
float: right;
margin-left: 5px;
}
</style>

View file

@ -0,0 +1,24 @@
<template>
<div
v-if="isOpen(name)"
>
<slot :modalName="name" :active="isActive(name)"></slot>
</div>
</template>
<script lang="ts">
import Vue from "vue";
export default Vue.extend({
name: "ModalRoot",
props: ["name"],
methods: {
isActive(name: string) {
return this.$store.getters['ui/isModalActive'](name);
},
isOpen(name: string) {
return this.$store.getters['ui/isModalOpen'](name);
},
},
});
</script>

View file

@ -0,0 +1,53 @@
<template>
<div>
<ModalRoot :name="DUPLICATE_MODAL_KEY">
<template v-slot:default="{ modalName, active }">
<DuplicateWorkflowDialog
:isActive="active"
:modalName="modalName"
/>
</template>
</ModalRoot>
<ModalRoot :name="TAGS_MANAGER_MODAL_KEY">
<template v-slot="{ modalName }">
<TagsManager
:modalName="modalName"
/>
</template>
</ModalRoot>
<ModalRoot :name="WORKLOW_OPEN_MODAL_KEY">
<template v-slot="{ modalName }">
<WorkflowOpen
:modalName="modalName"
/>
</template>
</ModalRoot>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY } from '@/constants';
import TagsManager from "@/components/TagsManager/TagsManager.vue";
import DuplicateWorkflowDialog from "@/components/DuplicateWorkflowDialog.vue";
import WorkflowOpen from "@/components/WorkflowOpen.vue";
import ModalRoot from "./ModalRoot.vue";
export default Vue.extend({
name: "Modals",
components: {
TagsManager,
DuplicateWorkflowDialog,
WorkflowOpen,
ModalRoot,
},
data: () => ({
DUPLICATE_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
WORKLOW_OPEN_MODAL_KEY,
}),
});
</script>

View file

@ -0,0 +1,29 @@
<template>
<span>
<div class="push-connection-lost primary-color" v-if="!pushConnectionActive">
<el-tooltip placement="bottom-end" effect="light">
<div slot="content">
Cannot connect to server.<br />
It is either down or you have a connection issue. <br />
It should reconnect automatically once the issue is resolved.
</div>
<span>
<font-awesome-icon icon="exclamation-triangle" />&nbsp; Connection lost
</span>
</el-tooltip>
</div>
<slot v-else />
</span>
</template>
<script lang="ts">
import Vue from "vue";
import { mapGetters } from "vuex";
export default Vue.extend({
name: "PushConnectionTracker",
computed: {
...mapGetters(["pushConnectionActive"]),
},
});
</script>

View file

@ -762,7 +762,7 @@ export default mixins(
background: #fff;;
}
tr:nth-child(odd) {
background: $--custom-table-background-alternative;
background: $--custom-table-background-stripe-color;
}
}
}

View file

@ -0,0 +1,65 @@
<template>
<el-button :disabled="isWorkflowSaving" :class="{saved: isSaved}" size="small" @click="save">
<font-awesome-icon v-if="isWorkflowSaving" icon="spinner" spin />
<span v-else-if="isDirty || isNewWorkflow">
Save
</span>
<span v-else>Saved</span>
</el-button>
</template>
<script lang="ts">
import mixins from "vue-typed-mixins";
import { mapGetters } from "vuex";
import { workflowHelpers } from "@/components/mixins/workflowHelpers";
export default mixins(workflowHelpers).extend({
name: "SaveWorkflowButton",
computed: {
...mapGetters({
isDirty: "getStateIsDirty",
}),
isWorkflowSaving(): boolean {
return this.$store.getters.isActionActive("workflowSaving");
},
isNewWorkflow(): boolean {
return !this.$route.params.name;
},
isSaved(): boolean {
return !this.isWorkflowSaving && !this.isDirty && !this.isNewWorkflow;
},
},
methods: {
save() {
this.saveCurrentWorkflow();
},
},
});
</script>
<style lang="scss" scoped>
.el-button {
width: 65px;
// override disabled colors
color: white;
background-color: $--color-primary;
&:hover:not(.saved) {
color: white;
background-color: $--color-primary;
}
&.saved {
color: $--custom-font-very-light;
font-size: 12px;
font-weight: 600;
line-height: 12px;
text-align: center;
background-color: unset;
pointer-events: none;
}
}
</style>

View file

@ -0,0 +1,151 @@
<template>
<IntersectionObserver :threshold="1.0" @observed="onObserved" class="tags-container" :enabled="responsive">
<template>
<span class="tags">
<span
v-for="tag in tags"
:key="tag.id"
:class="{clickable: !tag.hidden}"
@click="(e) => onClick(e, tag)"
>
<el-tag
:title="tag.title"
type="info"
size="small"
v-if="tag.isCount"
class="count-container"
>
{{ tag.name }}
</el-tag>
<IntersectionObserved
:class="{hidden: tag.hidden}"
:data-id="tag.id"
:enabled="responsive"
v-else
>
<el-tag
:title="tag.name"
type="info"
size="small"
:class="{hoverable}"
>
{{ tag.name }}
</el-tag>
</IntersectionObserved>
</span>
</span>
</template>
</IntersectionObserver>
</template>
<script lang="ts">
import Vue from 'vue';
import { ITag } from '@/Interface';
import IntersectionObserver from './IntersectionObserver.vue';
import IntersectionObserved from './IntersectionObserved.vue';
// random upper limit if none is set to minimize performance impact of observers
const DEFAULT_MAX_TAGS_LIMIT = 20;
interface TagEl extends ITag {
hidden?: boolean;
title?: string;
isCount?: boolean;
}
export default Vue.extend({
components: { IntersectionObserver, IntersectionObserved },
name: 'TagsContainer',
props: [
"tagIds",
"limit",
"clickable",
"responsive",
"hoverable",
],
data() {
return {
visibility: {} as {[id: string]: boolean},
};
},
computed: {
tags() {
const tags = this.$props.tagIds.map((tagId: string) => this.$store.getters['tags/getTagById'](tagId))
.filter(Boolean); // if tag has been deleted from store
const limit = this.$props.limit || DEFAULT_MAX_TAGS_LIMIT;
let toDisplay: TagEl[] = limit ? tags.slice(0, limit) : tags;
toDisplay = toDisplay.map((tag: ITag) => ({...tag, hidden: this.$props.responsive && !this.$data.visibility[tag.id]}));
let visibleCount = toDisplay.length;
if (this.$props.responsive) {
visibleCount = Object.values(this.visibility).reduce((accu, val) => val ? accu + 1 : accu, 0);
}
if (visibleCount < tags.length) {
const hidden = tags.slice(visibleCount);
const hiddenTitle = hidden.reduce((accu: string, tag: ITag) => {
return accu ? `${accu}, ${tag.name}` : tag.name;
}, '');
const countTag: TagEl = {
id: 'count',
name: `+${hidden.length}`,
title: hiddenTitle,
isCount: true,
};
toDisplay.splice(visibleCount, 0, countTag);
}
return toDisplay;
},
},
methods: {
onObserved({el, isIntersecting}: {el: HTMLElement, isIntersecting: boolean}) {
if (el.dataset.id) {
Vue.set(this.$data.visibility, el.dataset.id, isIntersecting);
}
},
onClick(e: MouseEvent, tag: TagEl) {
e.stopPropagation();
// if tag is hidden or not displayed
if (!tag.hidden) {
this.$emit('click', tag.id);
}
},
},
});
</script>
<style lang="scss" scoped>
.tags-container {
display: inline-flex;
overflow: hidden;
}
.tags {
display: flex;
> span {
padding-right: 4px; // why not margin? for space between tags to be clickable
}
}
.hidden {
visibility: hidden;
}
.el-tag.hoverable:hover {
border-color: $--color-primary;
}
.count-container {
position: absolute;
max-width: 40px;
text-overflow: ellipsis;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,369 @@
<template>
<div :class="{'tags-container': true, focused}" @keydown.stop v-click-outside="onBlur">
<el-select
:popperAppendToBody="false"
:value="appliedTags"
:loading="isLoading"
:placeholder="placeholder"
:filter-method="filterOptions"
@change="onTagsUpdated"
@visible-change="onVisibleChange"
@remove-tag="onRemoveTag"
filterable
multiple
ref="select"
loading-text="..."
popper-class="tags-dropdown"
>
<el-option
v-if="options.length === 0 && filter && createEnabled"
:key="CREATE_KEY"
:value="CREATE_KEY"
class="ops"
ref="create"
>
<font-awesome-icon icon="plus-circle" />
<span>Create tag "{{ filter }}"</span>
</el-option>
<el-option v-else-if="options.length === 0" value="message" disabled>
<span v-if="createEnabled">Type to create a tag</span>
<span v-else-if="allTags.length > 0">No matching tags exist</span>
<span v-else>No tags exist</span>
</el-option>
<!-- key is id+index for keyboard navigation to work well with filter -->
<el-option
v-for="(tag, i) in options"
:value="tag.id"
:key="tag.id + '_' + i"
:label="tag.name"
class="tag"
ref="tag"
/>
<el-option :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags">
<font-awesome-icon icon="cog" />
<span>Manage tags</span>
</el-option>
</el-select>
</div>
</template>
<script lang="ts">
import mixins from "vue-typed-mixins";
import { mapGetters } from "vuex";
import { ITag } from "@/Interface";
import { MAX_TAG_NAME_LENGTH } from "@/constants";
import { showMessage } from "@/components/mixins/showMessage";
const MANAGE_KEY = "__manage";
const CREATE_KEY = "__create";
export default mixins(showMessage).extend({
name: "TagsDropdown",
props: ["placeholder", "currentTagIds", "createEnabled", "eventBus"],
data() {
return {
filter: "",
MANAGE_KEY,
CREATE_KEY,
focused: false,
preventUpdate: false,
};
},
mounted() {
const select = this.$refs.select as (Vue | undefined);
if (select) {
const input = select.$refs.input as (Element | undefined);
if (input) {
input.setAttribute('maxlength', `${MAX_TAG_NAME_LENGTH}`);
input.addEventListener('keydown', (e: Event) => {
const keyboardEvent = e as KeyboardEvent;
// events don't bubble outside of select, so need to hook onto input
if (keyboardEvent.key === 'Escape') {
this.$emit('esc');
}
else if (keyboardEvent.key === 'Enter' && this.filter.length === 0) {
this.$data.preventUpdate = true;
this.$emit('blur');
// @ts-ignore
if (this.$refs.select && typeof this.$refs.select.blur === 'function') {
// @ts-ignore
this.$refs.select.blur();
}
}
});
}
}
if (this.$props.eventBus) {
this.$props.eventBus.$on('focus', () => {
this.focusOnInput();
this.focusOnTopOption();
});
}
this.$store.dispatch("tags/fetchAll");
},
computed: {
...mapGetters("tags", ["allTags", "isLoading", "hasTags"]),
options(): ITag[] {
return this.allTags
.filter((tag: ITag) =>
tag && tag.name.toLowerCase().includes(this.$data.filter.toLowerCase()),
);
},
appliedTags(): string[] {
return this.$props.currentTagIds.filter((id: string) =>
this.$store.getters['tags/getTagById'](id),
);
},
},
methods: {
filterOptions(filter = "") {
this.$data.filter = filter.trim();
this.$nextTick(() => this.focusOnTopOption());
},
async onCreate() {
const name = this.$data.filter;
try {
const newTag = await this.$store.dispatch("tags/create", name);
this.$emit("update", [...this.$props.currentTagIds, newTag.id]);
this.$nextTick(() => this.focusOnTag(newTag.id));
this.$data.filter = "";
} catch (error) {
this.$showError(
error,
"New tag was not created",
`A problem occurred when trying to create the "${name}" tag`,
);
}
},
onTagsUpdated(selected: string[]) {
const ops = selected.find(
(value) => value === MANAGE_KEY || value === CREATE_KEY,
);
if (ops === MANAGE_KEY) {
this.$data.filter = "";
this.$store.dispatch("ui/openTagsManagerModal");
} else if (ops === CREATE_KEY) {
this.onCreate();
} else {
setTimeout(() => {
if (!this.$data.preventUpdate) {
this.$emit("update", selected);
}
this.$data.preventUpdate = false;
}, 0);
}
},
focusOnTopOption() {
const tags = this.$refs.tag as Vue[] | undefined;
const create = this.$refs.create as Vue | undefined;
//@ts-ignore // focus on create option
if (create && create.hoverItem) {
// @ts-ignore
create.hoverItem();
}
//@ts-ignore // focus on top option after filter
else if (tags && tags[0] && tags[0].hoverItem) {
// @ts-ignore
tags[0].hoverItem();
// @ts-ignore
if (tags[0] && tags[0].$el && tags[0].$el.scrollIntoView) {
// @ts-ignore
tags[0].$el.scrollIntoView();
}
}
},
focusOnTag(tagId: string) {
const tagOptions = (this.$refs.tag as Vue[]) || [];
if (tagOptions && tagOptions.length) {
const added = tagOptions.find((ref: any) => ref.value === tagId); // tslint:disable-line:no-any
// @ts-ignore // focus on newly created item
if (added && added.$el && added.$el.scrollIntoView && added.hoverItem) {
// @ts-ignore
added.hoverItem();
added.$el.scrollIntoView();
}
}
},
focusOnInput() {
const select = this.$refs.select as Vue;
const input = select && select.$refs.input as HTMLElement;
if (input && input.focus) {
input.focus();
this.focused = true;
}
},
onVisibleChange(visible: boolean) {
if (!visible) {
this.$data.filter = '';
this.focused = false;
}
else {
this.focused = true;
}
},
onRemoveTag() {
this.$nextTick(() => {
this.focusOnInput();
});
},
onBlur() {
this.$emit('blur');
},
},
watch: {
allTags() {
// keep applied tags in sync with store
// for example in case tag is deleted from store
if (this.currentTagIds.length !== this.appliedTags.length) {
this.$emit("update", this.appliedTags);
}
},
},
});
</script>
<style lang="scss" scoped>
$--max-input-height: 60px;
$--border-radius: 20px;
.tags-container {
overflow: hidden;
border: 1px solid transparent;
border-radius: $--border-radius;
&.focused {
border: 1px solid $--color-primary;
}
}
/deep/ .el-select {
.el-select__tags {
max-height: $--max-input-height;
border-radius: $--border-radius;
overflow-y: scroll;
overflow-x: hidden;
// firefox fix for scrollbars
scrollbar-color: $--scrollbar-thumb-color transparent;
}
.el-input.is-focus {
border-radius: $--border-radius;
}
input {
max-height: $--max-input-height;
}
}
</style>
<style lang="scss">
.tags-dropdown {
$--item-font-size: 14px;
$--item-line-height: 18px;
$--item-vertical-padding: 10px;
$--item-horizontal-padding: 20px;
$--item-height: $--item-line-height + $--item-vertical-padding * 2;
$--items-to-show: 7;
$--item-padding: $--item-vertical-padding $--item-horizontal-padding;
$--dropdown-height: $--item-height * $--items-to-show;
$--dropdown-width: 224px;
min-width: $--dropdown-width !important;
max-width: $--dropdown-width;
*,*:after {
box-sizing: border-box;
}
.el-tag {
white-space: normal;
}
.el-scrollbar {
position: relative;
max-height: $--dropdown-height;
> div {
overflow: auto;
margin-bottom: 0 !important;
}
ul {
padding: 0;
max-height: $--dropdown-height - $--item-height;
}
&:after {
content: " ";
display: block;
min-height: $--item-height;
width: $--dropdown-width;
padding: $--item-padding;
}
// override theme scrollbars in safari when overscrolling
::-webkit-scrollbar-thumb {
display: none;
}
}
li {
height: $--item-height;
background-color: white;
padding: $--item-padding;
margin: 0;
line-height: $--item-line-height;
font-weight: 400;
font-size: $--item-font-size;
&.is-disabled {
color: $--custom-font-light;
cursor: default;
}
&.selected {
font-weight: bold;
> span {
display: inline-block;
width: calc(100% - #{$--item-font-size});
overflow: hidden;
text-overflow: ellipsis;
}
&:after { // selected check
font-size: $--item-font-size !important;
}
}
&.ops {
color: $--color-primary;
cursor: pointer;
:first-child {
margin-right: 5px;
}
}
&.tag {
border-top: none;
}
&.manage-tags {
position: absolute;
bottom: 0;
min-width: $--dropdown-width;
}
}
}
</style>

View file

@ -0,0 +1,61 @@
<template>
<div class="container">
<el-col class="notags" :span="16">
<div class="icon">🗄</div>
<div>
<div class="headline">Ready to organize your workflows?</div>
<div class="description">
With workflow tags, you're free to create the perfect tagging system for
your flows
</div>
</div>
<el-button ref="create" @click="$emit('enableCreate')"> Create a tag </el-button>
</el-col>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'NoTagsView',
});
</script>
<style lang="scss" scoped>
$--footer-spacing: 45px;
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: $--tags-manager-min-height - $--footer-spacing;
margin-top: $--footer-spacing;
}
.notags {
word-break: normal;
text-align: center;
> * {
margin-bottom: 32px;
}
}
.icon {
font-size: 36px;
line-height: 14px;
}
.headline {
font-size: 17.6px;
color: black;
margin-bottom: 12px;
}
.description {
font-size: 14px;
line-height: 21px;
}
</style>

View file

@ -0,0 +1,190 @@
<template>
<Modal
title="Manage tags"
:name="modalName"
:eventBus="modalBus"
@enter="onEnter"
size="md"
>
<template v-slot:content>
<el-row>
<TagsView
v-if="hasTags || isCreating"
:isLoading="isLoading"
:tags="tags"
@create="onCreate"
@update="onUpdate"
@delete="onDelete"
@disableCreate="onDisableCreate"
/>
<NoTagsView
@enableCreate="onEnableCreate"
v-else />
</el-row>
</template>
<template v-slot:footer="{ close }">
<el-button size="small" @click="close">Done</el-button>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from "vue";
import mixins from "vue-typed-mixins";
import { mapGetters } from "vuex";
import { ITag } from "@/Interface";
import { showMessage } from "@/components/mixins/showMessage";
import TagsView from "@/components/TagsManager/TagsView/TagsView.vue";
import NoTagsView from "@/components/TagsManager/NoTagsView.vue";
import Modal from "@/components/Modal.vue";
export default mixins(showMessage).extend({
name: "TagsManager",
created() {
this.$store.dispatch("tags/fetchAll", {force: true, withUsageCount: true});
},
props: ['modalName'],
data() {
const tagIds = (this.$store.getters['tags/allTags'] as ITag[])
.map((tag) => tag.id);
return {
tagIds,
isCreating: false,
modalBus: new Vue(),
};
},
components: {
TagsView,
NoTagsView,
Modal,
},
computed: {
...mapGetters("tags", ["isLoading"]),
tags(): ITag[] {
return this.$data.tagIds.map((tagId: string) => this.$store.getters['tags/getTagById'](tagId))
.filter(Boolean); // if tag is deleted from store
},
hasTags(): boolean {
return this.tags.length > 0;
},
},
methods: {
onEnableCreate() {
this.$data.isCreating = true;
},
onDisableCreate() {
this.$data.isCreating = false;
},
async onCreate(name: string, cb: (tag: ITag | null, error?: Error) => void) {
try {
if (!name) {
throw new Error("Tag name cannot be empty");
}
const newTag = await this.$store.dispatch("tags/create", name);
this.$data.tagIds = [newTag.id].concat(this.$data.tagIds);
cb(newTag);
} catch (error) {
const escapedName = escape(name);
this.$showError(
error,
"New tag was not created",
`A problem occurred when trying to create the "${escapedName}" tag`,
);
cb(null, error);
}
},
async onUpdate(id: string, name: string, cb: (tag: boolean, error?: Error) => void) {
const tag = this.$store.getters['tags/getTagById'](id);
const oldName = tag.name;
try {
if (!name) {
throw new Error("Tag name cannot be empty");
}
if (name === oldName) {
cb(true);
return;
}
const updatedTag = await this.$store.dispatch("tags/rename", { id, name });
cb(!!updatedTag);
const escapedName = escape(name);
const escapedOldName = escape(oldName);
this.$showMessage({
title: "Tag was updated",
message: `The "${escapedOldName}" tag was successfully updated to "${escapedName}"`,
type: "success",
});
} catch (error) {
const escapedName = escape(oldName);
this.$showError(
error,
"Tag was not updated",
`A problem occurred when trying to update the "${escapedName}" tag`,
);
cb(false, error);
}
},
async onDelete(id: string, cb: (deleted: boolean, error?: Error) => void) {
const tag = this.$store.getters['tags/getTagById'](id);
const name = tag.name;
try {
const deleted = await this.$store.dispatch("tags/delete", id);
if (!deleted) {
throw new Error('Could not delete tag');
}
this.$data.tagIds = this.$data.tagIds.filter((tagId: string) => tagId !== id);
cb(deleted);
const escapedName = escape(name);
this.$showMessage({
title: "Tag was deleted",
message: `The "${escapedName}" tag was successfully deleted from your tag collection`,
type: "success",
});
} catch (error) {
const escapedName = escape(name);
this.$showError(
error,
"Tag was not deleted",
`A problem occurred when trying to delete the "${escapedName}" tag`,
);
cb(false, error);
}
},
onEnter() {
if (this.isLoading) {
return;
}
else if (!this.hasTags) {
this.onEnableCreate();
}
else {
this.modalBus.$emit('close');
}
},
},
});
</script>
<style lang="scss" scoped>
.el-row {
min-height: $--tags-manager-min-height;
}
</style>

View file

@ -0,0 +1,228 @@
<template>
<el-table
stripe
max-height="450"
ref="table"
empty-text="No matching tags exist"
:data="rows"
:span-method="getSpan"
:row-class-name="getRowClasses"
v-loading="isLoading"
>
<el-table-column label="Name">
<template slot-scope="scope">
<div class="name" :key="scope.row.id" @keydown.stop>
<transition name="fade" mode="out-in">
<el-input
v-if="scope.row.create || scope.row.update"
:value="newName"
:maxlength="maxLength"
@input="onNewNameChange"
ref="nameInput"
></el-input>
<span v-else-if="scope.row.delete">
<span>Are you sure you want to delete this tag?</span>
<input ref="deleteHiddenInput" class="hidden" />
</span>
<span v-else :class="{ disabled: scope.row.disable }">
{{ scope.row.tag.name }}
</span>
</transition>
</div>
</template>
</el-table-column>
<el-table-column label="Usage" width="150">
<template slot-scope="scope">
<transition name="fade" mode="out-in">
<div v-if="!scope.row.create && !scope.row.delete" :class="{ disabled: scope.row.disable }">
{{ scope.row.usage }}
</div>
</transition>
</template>
</el-table-column>
<el-table-column>
<template slot-scope="scope">
<transition name="fade" mode="out-in">
<div class="ops" v-if="scope.row.create">
<el-button title="Cancel" @click.stop="cancel" size="small" plain :disabled="isSaving">Cancel</el-button>
<el-button title="Create Tag" @click.stop="apply" size="small" :loading="isSaving">
Create tag
</el-button>
</div>
<div class="ops" v-else-if="scope.row.update">
<el-button title="Cancel" @click.stop="cancel" size="small" plain :disabled="isSaving">Cancel</el-button>
<el-button title="Save Tag" @click.stop="apply" size="small" :loading="isSaving">Save changes</el-button>
</div>
<div class="ops" v-else-if="scope.row.delete">
<el-button title="Cancel" @click.stop="cancel" size="small" plain :disabled="isSaving">Cancel</el-button>
<el-button title="Delete Tag" @click.stop="apply" size="small" :loading="isSaving">Delete tag</el-button>
</div>
<div class="ops main" v-else-if="!scope.row.disable">
<el-button title="Edit Tag" @click.stop="enableUpdate(scope.row)" icon="el-icon-edit" circle></el-button>
<el-button title="Delete Tag" @click.stop="enableDelete(scope.row)" icon="el-icon-delete" circle></el-button>
</div>
</transition>
</template>
</el-table-column>
</el-table>
</template>
<script lang="ts">
import { MAX_TAG_NAME_LENGTH } from "@/constants";
import { ITagRow } from "@/Interface";
import Vue from "vue";
const INPUT_TRANSITION_TIMEOUT = 350;
const DELETE_TRANSITION_TIMEOUT = 100;
export default Vue.extend({
name: "TagsTable",
props: ["rows", "isLoading", "newName", "isSaving"],
data() {
return {
maxLength: MAX_TAG_NAME_LENGTH,
};
},
mounted() {
if (this.$props.rows.length === 1 && this.$props.rows[0].create) {
this.focusOnInput();
}
},
methods: {
getRowClasses: ({ row }: { row: ITagRow }): string => {
return row.disable ? "disabled" : "";
},
getSpan({ row, columnIndex }: { row: ITagRow, columnIndex: number }): number | number[] {
// expand text column with delete message
if (columnIndex === 0 && row.tag && row.delete) {
return [1, 2];
}
// hide usage column on delete
if (columnIndex === 1 && row.tag && row.delete) {
return [0, 0];
}
return 1;
},
enableUpdate(row: ITagRow): void {
if (row.tag) {
this.$emit('updateEnable', row.tag.id);
this.$emit('newNameChange', row.tag.name);
this.focusOnInput();
}
},
enableDelete(row: ITagRow): void {
if (row.tag) {
this.$emit('deleteEnable', row.tag.id);
this.focusOnDelete();
}
},
cancel(): void {
this.$emit('cancelOperation');
},
apply(): void {
this.$emit('applyOperation');
},
onNewNameChange(name: string): void {
this.$emit('newNameChange', name);
},
focusOnInput(): void {
setTimeout(() => {
const input = this.$refs.nameInput as any; // tslint:disable-line:no-any
if (input && input.focus) {
input.focus();
}
}, INPUT_TRANSITION_TIMEOUT);
},
focusOnDelete(): void {
setTimeout(() => {
const input = this.$refs.deleteHiddenInput as any; // tslint:disable-line:no-any
if (input && input.focus) {
input.focus();
}
}, DELETE_TRANSITION_TIMEOUT);
},
focusOnCreate(): void {
((this.$refs.table as Vue).$refs.bodyWrapper as Element).scrollTop = 0;
this.focusOnInput();
},
},
watch: {
rows(newValue: ITagRow[] | undefined) {
if (newValue && newValue[0] && newValue[0].create) {
this.focusOnCreate();
}
},
},
});
</script>
<style lang="scss" scoped>
.name {
min-height: 45px;
display: flex;
align-items: center;
/deep/ input {
border: 1px solid $--color-primary;
background: white;
}
}
.ops {
min-height: 45px;
justify-content: flex-end;
align-items: center;
display: flex;
flex-wrap: nowrap;
> .el-button {
margin: 2px;
}
}
.disabled {
color: #afafaf;
}
.hidden {
position: absolute;
z-index: 0;
opacity: 0;
}
.ops.main > .el-button {
display: none;
float: right;
margin-left: 2px;
}
/deep/ tr.disabled {
pointer-events: none;
}
/deep/ tr:hover .ops:not(.disabled) .el-button {
display: block;
}
/deep/ .el-input.is-disabled > input {
border: none;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style>

View file

@ -0,0 +1,60 @@
<template>
<el-row class="tags-header">
<el-col :span="10">
<el-input
placeholder="Search tags"
:value="search"
@input="onSearchChange"
:disabled="disabled"
clearable
:maxlength="maxLength"
>
<i slot="prefix" class="el-input__icon el-icon-search"></i>
</el-input>
</el-col>
<el-col :span="14">
<el-button @click="onAddNew" :disabled="disabled" plain>
<font-awesome-icon icon="plus" />
<div class="next-icon-text">Add new</div>
</el-button>
</el-col>
</el-row>
</template>
<script lang="ts">
import { MAX_TAG_NAME_LENGTH } from "@/constants";
import Vue from "vue";
export default Vue.extend({
props: {
disabled: {
default: false,
},
search: {
default: "",
},
},
data() {
return {
maxLength: MAX_TAG_NAME_LENGTH,
};
},
methods: {
onAddNew() {
this.$emit("createEnable");
},
onSearchChange(search: string) {
this.$emit("searchChange", search);
},
},
});
</script>
<style lang="scss" scoped>
.tags-header {
margin-bottom: 15px;
}
.el-button {
float: right;
}
</style>

View file

@ -0,0 +1,180 @@
<template>
<div @keyup.enter="applyOperation" @keyup.esc="cancelOperation">
<TagsTableHeader
:search="search"
:disabled="isHeaderDisabled()"
@searchChange="onSearchChange"
@createEnable="onCreateEnable"
/>
<TagsTable
:rows="rows"
:isLoading="isLoading"
:isSaving="isSaving"
:newName="newName"
@newNameChange="onNewNameChange"
@updateEnable="onUpdateEnable"
@deleteEnable="onDeleteEnable"
@cancelOperation="cancelOperation"
@applyOperation="applyOperation"
ref="tagsTable"
/>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import { ITag, ITagRow } from "@/Interface";
import TagsTableHeader from "@/components/TagsManager/TagsView/TagsTableHeader.vue";
import TagsTable from "@/components/TagsManager/TagsView/TagsTable.vue";
const matches = (name: string, filter: string) => name.toLowerCase().trim().includes(filter.toLowerCase().trim());
const getUsage = (count: number | undefined) => count && count > 0 ? `${count} workflow${count > 1 ? "s" : ""}` : 'Not being used';
export default Vue.extend({
components: { TagsTableHeader, TagsTable },
name: "TagsView",
props: ["tags", "isLoading"],
data() {
return {
createEnabled: false,
deleteId: "",
updateId: "",
search: "",
newName: "",
stickyIds: new Set(),
isSaving: false,
};
},
computed: {
isCreateEnabled(): boolean {
return (this.$props.tags || []).length === 0 || this.$data.createEnabled;
},
rows(): ITagRow[] {
const disabled = this.isCreateEnabled || this.$data.updateId || this.$data.deleteId;
const tagRows = (this.$props.tags || [])
.filter((tag: ITag) => this.stickyIds.has(tag.id) || matches(tag.name, this.$data.search))
.map((tag: ITag): ITagRow => ({
tag,
usage: getUsage(tag.usageCount),
disable: disabled && tag.id !== this.deleteId && tag.id !== this.$data.updateId,
update: disabled && tag.id === this.$data.updateId,
delete: disabled && tag.id === this.$data.deleteId,
}));
return this.isCreateEnabled
? [{ create: true }, ...tagRows]
: tagRows;
},
},
methods: {
onNewNameChange(name: string): void {
this.newName = name;
},
onSearchChange(search: string): void {
this.$data.stickyIds.clear();
this.$data.search = search;
},
isHeaderDisabled(): boolean {
return (
this.$props.isLoading ||
!!(this.isCreateEnabled || this.$data.updateId || this.$data.deleteId)
);
},
onUpdateEnable(updateId: string): void {
this.updateId = updateId;
},
disableUpdate(): void {
this.updateId = "";
this.newName = "";
},
updateTag(): void {
this.$data.isSaving = true;
const name = this.newName.trim();
const onUpdate = (updated: boolean) => {
this.$data.isSaving = false;
if (updated) {
this.stickyIds.add(this.updateId);
this.disableUpdate();
}
};
this.$emit("update", this.updateId, name, onUpdate);
},
onDeleteEnable(deleteId: string): void {
this.deleteId = deleteId;
},
disableDelete(): void {
this.deleteId = "";
},
deleteTag(): void {
this.$data.isSaving = true;
const onDelete = (deleted: boolean) => {
if (deleted) {
this.disableDelete();
}
this.$data.isSaving = false;
};
this.$emit("delete", this.deleteId, onDelete);
},
onCreateEnable(): void {
this.$data.createEnabled = true;
this.$data.newName = "";
},
disableCreate(): void {
this.$data.createEnabled = false;
this.$emit("disableCreate");
},
createTag(): void {
this.$data.isSaving = true;
const name = this.$data.newName.trim();
const onCreate = (created: ITag | null, error?: Error) => {
if (created) {
this.stickyIds.add(created.id);
this.disableCreate();
}
this.$data.isSaving = false;
};
this.$emit("create", name, onCreate);
},
applyOperation(): void {
if (this.$data.isSaving) {
return;
}
else if (this.isCreateEnabled) {
this.createTag();
}
else if (this.$data.updateId) {
this.updateTag();
}
else if (this.$data.deleteId) {
this.deleteTag();
}
},
cancelOperation(): void {
if (this.$data.isSaving) {
return;
}
else if (this.isCreateEnabled) {
this.disableCreate();
}
else if (this.$data.updateId) {
this.disableUpdate();
}
else if (this.$data.deleteId) {
this.disableDelete();
}
},
},
});
</script>

View file

@ -0,0 +1,32 @@
<template>
<span :title="name">
<slot :shortenedName="shortenedName"></slot>
</span>
</template>
<script lang="ts">
import Vue from "vue";
const DEFAULT_WORKFLOW_NAME_LIMIT = 25;
const WORKFLOW_NAME_END_COUNT_TO_KEEP = 4;
export default Vue.extend({
name: "WorkflowNameShort",
props: ["name", "limit"],
computed: {
shortenedName(): string {
const name = this.$props.name;
const limit = this.$props.limit || DEFAULT_WORKFLOW_NAME_LIMIT;
if (name.length <= limit) {
return name;
}
const first = name.slice(0, limit - WORKFLOW_NAME_END_COUNT_TO_KEEP);
const last = name.slice(name.length - WORKFLOW_NAME_END_COUNT_TO_KEEP, name.length);
return `${first}...${last}`;
},
},
});
</script>

View file

@ -1,44 +1,68 @@
<template>
<span>
<el-dialog :visible="dialogVisible" append-to-body width="80%" title="Open Workflow" :before-close="closeDialog" top="5vh">
<Modal
:name="modalName"
size="xl"
>
<template v-slot:header>
<div class="workflows-header">
<div class="title">
<h1>Open Workflow</h1>
</div>
<div class="tags-filter">
<TagsDropdown
placeholder="Filter by tags..."
:currentTagIds="filterTagIds"
:createEnabled="false"
@update="updateTagsFilter"
@esc="onTagsFilterEsc"
@blur="onTagsFilterBlur"
/>
</div>
<div class="search-filter">
<el-input placeholder="Search workflows..." ref="inputFieldFilter" v-model="filterText">
<i slot="prefix" class="el-input__icon el-icon-search"></i>
</el-input>
</div>
</div>
</template>
<div class="text-very-light">
Select a workflow to open:
</div>
<div class="search-wrapper ignore-key-press">
<el-input placeholder="Workflow filter..." ref="inputFieldFilter" v-model="filterText">
<i slot="prefix" class="el-input__icon el-icon-search"></i>
</el-input>
</div>
<el-table class="search-table" :data="filteredWorkflows" stripe @cell-click="openWorkflow" :default-sort = "{prop: 'updatedAt', order: 'descending'}" v-loading="isDataLoading">
<el-table-column property="name" label="Name" class-name="clickable" sortable></el-table-column>
<el-table-column property="createdAt" label="Created" class-name="clickable" width="225" sortable></el-table-column>
<el-table-column property="updatedAt" label="Updated" class-name="clickable" width="225" sortable></el-table-column>
<el-table-column label="Active" width="90">
<template slot-scope="scope">
<workflow-activator :workflow-active="scope.row.active" :workflow-id="scope.row.id" @workflowActiveChanged="workflowActiveChanged" />
</template>
</el-table-column>
</el-table>
</el-dialog>
</span>
<template v-slot:content>
<el-table class="search-table" :data="filteredWorkflows" stripe @cell-click="openWorkflow" :default-sort = "{prop: 'updatedAt', order: 'descending'}" v-loading="isDataLoading">
<el-table-column property="name" label="Name" class-name="clickable" sortable>
<template slot-scope="scope">
<div :key="scope.row.id">
<span class="name">{{scope.row.name}}</span>
<TagsContainer class="hidden-sm-and-down" :tagIds="getIds(scope.row.tags)" :limit="3" @click="onTagClick" :hoverable="true"/>
</div>
</template>
</el-table-column>
<el-table-column property="createdAt" label="Created" class-name="clickable" width="155" sortable></el-table-column>
<el-table-column property="updatedAt" label="Updated" class-name="clickable" width="155" sortable></el-table-column>
<el-table-column label="Active" width="75">
<template slot-scope="scope">
<workflow-activator :workflow-active="scope.row.active" :workflow-id="scope.row.id" @workflowActiveChanged="workflowActiveChanged" />
</template>
</el-table-column>
</el-table>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import mixins from 'vue-typed-mixins';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { ITag, IWorkflowShortResponse } from '@/Interface';
import { restApi } from '@/components/mixins/restApi';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import { titleChange } from '@/components/mixins/titleChange';
import { IWorkflowShortResponse } from '@/Interface';
import mixins from 'vue-typed-mixins';
import Modal from '@/components/Modal.vue';
import TagsContainer from '@/components/TagsContainer.vue';
import TagsDropdown from '@/components/TagsDropdown.vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
export default mixins(
genericHelpers,
@ -47,48 +71,63 @@ export default mixins(
workflowHelpers,
).extend({
name: 'WorkflowOpen',
props: [
'dialogVisible',
],
components: {
WorkflowActivator,
TagsContainer,
TagsDropdown,
Modal,
},
props: ['modalName'],
data () {
return {
filterText: '',
isDataLoading: false,
workflows: [] as IWorkflowShortResponse[],
filterTagIds: [] as string[],
prevFilterTagIds: [] as string[],
};
},
computed: {
filteredWorkflows (): IWorkflowShortResponse[] {
return this.workflows.filter((workflow: IWorkflowShortResponse) => {
if (this.filterText === '' || workflow.name.toLowerCase().indexOf(this.filterText.toLowerCase()) !== -1) {
return true;
}
return false;
});
return this.workflows
.filter((workflow: IWorkflowShortResponse) => {
if (this.filterText && !workflow.name.toLowerCase().includes(this.filterText.toLowerCase())) {
return false;
}
if (this.filterTagIds.length === 0) {
return true;
}
if (!workflow.tags || workflow.tags.length === 0) {
return false;
}
return this.filterTagIds.reduce((accu: boolean, id: string) => accu && !!workflow.tags.find(tag => tag.id === id), true);
});
},
},
watch: {
dialogVisible (newValue, oldValue) {
if (newValue) {
this.filterText = '';
this.openDialog();
mounted() {
this.filterText = '';
this.filterTagIds = [];
this.openDialog();
Vue.nextTick(() => {
// Make sure that users can directly type in the filter
(this.$refs.inputFieldFilter as HTMLInputElement).focus();
});
}
},
Vue.nextTick(() => {
// Make sure that users can directly type in the filter
(this.$refs.inputFieldFilter as HTMLInputElement).focus();
});
},
methods: {
closeDialog () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
getIds(tags: ITag[] | undefined) {
return (tags || []).map((tag) => tag.id);
},
updateTagsFilter(tags: string[]) {
this.filterTagIds = tags;
},
onTagClick(tagId: string) {
if (tagId !== 'count' && !this.filterTagIds.includes(tagId)) {
this.filterTagIds.push(tagId);
}
},
async openWorkflow (data: IWorkflowShortResponse, column: any) { // tslint:disable-line:no-any
if (column.label !== 'Active') {
@ -114,11 +153,19 @@ export default mixins(
} else {
// This is used to avoid duplicating the message
this.$store.commit('setStateDirty', false);
this.$emit('openWorkflow', data.id);
this.$router.push({
name: 'NodeViewExisting',
params: { name: data.id },
});
}
} else {
this.$emit('openWorkflow', data.id);
this.$router.push({
name: 'NodeViewExisting',
params: { name: data.id },
});
}
this.$store.commit('ui/closeTopModal');
}
},
openDialog () {
@ -149,21 +196,45 @@ export default mixins(
}
}
},
onTagsFilterBlur() {
this.prevFilterTagIds = this.filterTagIds;
},
onTagsFilterEsc() {
// revert last applied tags
this.filterTagIds = this.prevFilterTagIds;
},
},
});
</script>
<style scoped lang="scss">
.workflows-header {
display: flex;
.search-wrapper {
position: absolute;
right: 20px;
top: 20px;
width: 200px;
.title {
flex-grow: 1;
h1 {
font-weight: 600;
line-height: 24px;
font-size: 18px;
}
}
.search-filter {
margin-left: 10px;
min-width: 160px;
}
.tags-filter {
flex-grow: 1;
max-width: 270px;
min-width: 220px;
}
}
.search-table {
margin-top: 2em;
.search-table .name {
font-weight: 400;
margin-right: 10px;
}
</style>

View file

@ -0,0 +1,40 @@
import Vue from 'vue';
function broadcast(componentName: string, eventName: string, params: any) { // tslint:disable-line:no-any
// @ts-ignore
(this as Vue).$children.forEach(child => {
const name = child.$options.name;
if (name === componentName) {
// @ts-ignore
child.$emit.apply(child, [eventName].concat(params));
} else {
// @ts-ignore
broadcast.apply(child, [componentName, eventName].concat([params]));
}
});
}
export default Vue.extend({
methods: {
$dispatch(componentName: string, eventName: string, params: any) { // tslint:disable-line:no-any
let parent = this.$parent || this.$root;
let name = parent.$options.name;
while (parent && (!name || name !== componentName)) {
parent = parent.$parent;
if (parent) {
name = parent.$options.name;
}
}
if (parent) {
// @ts-ignore
parent.$emit.apply(parent, [eventName].concat(params));
}
},
$broadcast(componentName: string, eventName: string, params: any) { // tslint:disable-line:no-any
broadcast.call(this, componentName, eventName, params);
},
},
});

View file

@ -1,11 +1,11 @@
import { IExternalHooks } from '@/Interface';
import { IExternalHooks, IRootState } from '@/Interface';
import { IDataObject } from 'n8n-workflow';
import Vue from 'vue';
import { Store } from 'vuex';
export async function runExternalHook(
eventName: string,
store: Store<IDataObject>,
store: Store<IRootState>,
metadata?: IDataObject,
) {
// @ts-ignore

View file

@ -2,6 +2,7 @@ import dateformat from 'dateformat';
import { showMessage } from '@/components/mixins/showMessage';
import { MessageType } from '@/Interface';
import { debounce } from 'lodash';
import mixins from 'vue-typed-mixins';
@ -9,6 +10,7 @@ export const genericHelpers = mixins(showMessage).extend({
data () {
return {
loadingService: null as any | null, // tslint:disable-line:no-any
debouncedFunctions: [] as any[], // tslint:disable-line:no-any
};
},
computed: {
@ -73,6 +75,19 @@ export const genericHelpers = mixins(showMessage).extend({
}
},
async callDebounced (...inputParameters: any[]): Promise<void> { // tslint:disable-line:no-any
const functionName = inputParameters.shift() as string;
const debounceTime = inputParameters.shift() as number;
// @ts-ignore
if (this.debouncedFunctions[functionName] === undefined) {
// @ts-ignore
this.debouncedFunctions[functionName] = debounce(this[functionName], debounceTime, { leading: true });
}
// @ts-ignore
await this.debouncedFunctions[functionName].apply(this, inputParameters);
},
async confirmMessage (message: string, headline: string, type = 'warning' as MessageType, confirmButtonText = 'OK', cancelButtonText = 'Cancel'): Promise<boolean> {
try {
await this.$confirm(message, headline, {

View file

@ -12,6 +12,7 @@ import { externalHooks } from '@/components/mixins/externalHooks';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import { titleChange } from '@/components/mixins/titleChange';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
@ -20,6 +21,7 @@ export const pushConnection = mixins(
nodeHelpers,
showMessage,
titleChange,
workflowHelpers,
)
.extend({
data () {
@ -227,7 +229,7 @@ export const pushConnection = mixins(
runDataExecutedErrorMessage = errorMessage;
this.$titleSet(workflow.name, 'ERROR');
this.$titleSet(workflow.name as string, 'ERROR');
this.$showMessage({
title: 'Problem executing workflow',
message: errorMessage,
@ -235,7 +237,7 @@ export const pushConnection = mixins(
});
} else {
// Workflow did execute without a problem
this.$titleSet(workflow.name, 'IDLE');
this.$titleSet(workflow.name as string, 'IDLE');
this.$showMessage({
title: 'Workflow got executed',
message: 'Workflow did get executed successfully!',

View file

@ -30,6 +30,7 @@ import {
INodePropertyOptions,
INodeTypeDescription,
} from 'n8n-workflow';
import { makeRestApiRequest } from '@/api/helpers';
/**
* Unflattens the Execution data.
@ -55,75 +56,13 @@ function unflattenExecutionData (fullExecutionData: IExecutionFlattedResponse):
return returnData;
}
export class ResponseError extends Error {
// The HTTP status code of response
httpStatusCode?: number;
// The error code in the resonse
errorCode?: number;
// The stack trace of the server
serverStackTrace?: string;
/**
* Creates an instance of ResponseError.
* @param {string} message The error message
* @param {number} [errorCode] The error code which can be used by frontend to identify the actual error
* @param {number} [httpStatusCode] The HTTP status code the response should have
* @param {string} [stack] The stack trace
* @memberof ResponseError
*/
constructor (message: string, errorCode?: number, httpStatusCode?: number, stack?: string) {
super(message);
this.name = 'ResponseError';
if (errorCode) {
this.errorCode = errorCode;
}
if (httpStatusCode) {
this.httpStatusCode = httpStatusCode;
}
if (stack) {
this.serverStackTrace = stack;
}
}
}
export const restApi = Vue.extend({
methods: {
restApi (): IRestApi {
const self = this;
return {
async makeRestApiRequest (method: Method, endpoint: string, data?: IDataObject): Promise<any> { // tslint:disable-line:no-any
try {
const options: AxiosRequestConfig = {
method,
url: endpoint,
baseURL: self.$store.getters.getRestUrl,
headers: {
sessionid: self.$store.getters.sessionId,
},
};
if (['PATCH', 'POST', 'PUT'].includes(method)) {
options.data = data;
} else {
options.params = data;
}
const response = await axios.request(options);
return response.data.data;
} catch (error) {
if (error.message === 'Network Error') {
throw new ResponseError('API-Server can not be reached. It is probably down.');
}
const errorResponseData = error.response.data;
if (errorResponseData !== undefined && errorResponseData.message !== undefined) {
throw new ResponseError(errorResponseData.message, errorResponseData.code, error.response.status, errorResponseData.stack);
}
throw error;
}
return makeRestApiRequest(self.$store.getters.getRestApiContext, method, endpoint, data);
},
getActiveWorkflows: (): Promise<string[]> => {
return self.restApi().makeRestApiRequest('GET', `/active`);
@ -179,7 +118,7 @@ export const restApi = Vue.extend({
},
// Creates new credentials
createNewWorkflow: (sendData: IWorkflowData): Promise<IWorkflowDb> => {
createNewWorkflow: (sendData: IWorkflowDataUpdate): Promise<IWorkflowDb> => {
return self.restApi().makeRestApiRequest('POST', `/workflows`, sendData);
},

View file

@ -28,6 +28,7 @@ import {
IWorkflowDb,
IWorkflowDataUpdate,
XYPositon,
ITag,
} from '../../Interface';
import { externalHooks } from '@/components/mixins/externalHooks';
@ -238,6 +239,7 @@ export const workflowHelpers = mixins(
connections: workflowConnections,
active: this.$store.getters.isActive,
settings: this.$store.getters.workflowSettings,
tags: this.$store.getters.workflowTags,
};
const workflowId = this.$store.getters.workflowId;
@ -383,86 +385,43 @@ export const workflowHelpers = mixins(
return returnData['__xxxxxxx__'];
},
// Saves the currently loaded workflow to the database.
async saveCurrentWorkflow (withNewName = false) {
async saveCurrentWorkflow({name, tags}: {name?: string, tags?: string[]} = {}): Promise<boolean> {
const currentWorkflow = this.$route.params.name;
let workflowName: string | null | undefined = '';
if (currentWorkflow === undefined || withNewName === true) {
// Currently no workflow name is set to get it from user
workflowName = await this.$prompt(
'Enter workflow name',
'Name',
{
confirmButtonText: 'Save',
cancelButtonText: 'Cancel',
},
)
.then((data) => {
// @ts-ignore
return data.value;
})
.catch(() => {
// User did cancel
return undefined;
});
if (workflowName === undefined) {
// User did cancel
return;
} else if (['', null].includes(workflowName)) {
// User did not enter a name
this.$showMessage({
title: 'Name missing',
message: `No name for the workflow got entered and could so not be saved!`,
type: 'error',
});
return;
}
if (!currentWorkflow) {
return this.saveAsNewWorkflow({name, tags});
}
// Workflow exists already so update it
try {
this.$store.commit('addActiveAction', 'workflowSaving');
let workflowData: IWorkflowData = await this.getWorkflowDataToSave();
const workflowDataRequest: IWorkflowDataUpdate = await this.getWorkflowDataToSave();
if (currentWorkflow === undefined || withNewName === true) {
// Workflow is new or is supposed to get saved under a new name
// so create a new entry in database
workflowData.name = workflowName!.trim() as string;
if (withNewName === true) {
// If an existing workflow gets resaved with a new name
// make sure that the new ones is not active
workflowData.active = false;
}
workflowData = await this.restApi().createNewWorkflow(workflowData);
this.$store.commit('setActive', workflowData.active || false);
this.$store.commit('setWorkflowId', workflowData.id);
this.$store.commit('setWorkflowName', {newName: workflowData.name, setStateDirty: false});
this.$store.commit('setWorkflowSettings', workflowData.settings || {});
this.$store.commit('setStateDirty', false);
} else {
// Workflow exists already so update it
await this.restApi().updateWorkflow(currentWorkflow, workflowData);
if (name) {
workflowDataRequest.name = name.trim();
}
if (this.$route.params.name !== workflowData.id) {
this.$router.push({
name: 'NodeViewExisting',
params: { name: workflowData.id as string, action: 'workflowSave' },
});
if (tags) {
workflowDataRequest.tags = tags;
}
const workflowData = await this.restApi().updateWorkflow(currentWorkflow, workflowDataRequest);
if (name) {
this.$store.commit('setWorkflowName', {newName: workflowData.name});
}
if (tags) {
const createdTags = (workflowData.tags || []) as ITag[];
const tagIds = createdTags.map((tag: ITag): string => tag.id);
this.$store.commit('setWorkflowTagIds', tagIds);
}
this.$store.commit('removeActiveAction', 'workflowSaving');
this.$store.commit('setStateDirty', false);
this.$showMessage({
title: 'Workflow saved',
message: `The workflow "${workflowData.name}" got saved!`,
type: 'success',
});
this.$store.commit('removeActiveAction', 'workflowSaving');
this.$externalHooks().run('workflow.afterUpdate', { workflowData });
return true;
} catch (e) {
this.$store.commit('removeActiveAction', 'workflowSaving');
@ -471,6 +430,58 @@ export const workflowHelpers = mixins(
message: `There was a problem saving the workflow: "${e.message}"`,
type: 'error',
});
return false;
}
},
async saveAsNewWorkflow ({name, tags}: {name?: string, tags?: string[]} = {}): Promise<boolean> {
try {
this.$store.commit('addActiveAction', 'workflowSaving');
const workflowDataRequest: IWorkflowDataUpdate = await this.getWorkflowDataToSave();
// make sure that the new ones are not active
workflowDataRequest.active = false;
if (name) {
workflowDataRequest.name = name.trim();
}
if (tags) {
workflowDataRequest.tags = tags;
}
const workflowData = await this.restApi().createNewWorkflow(workflowDataRequest);
this.$store.commit('setActive', workflowData.active || false);
this.$store.commit('setWorkflowId', workflowData.id);
this.$store.commit('setWorkflowName', {newName: workflowData.name, setStateDirty: false});
this.$store.commit('setWorkflowSettings', workflowData.settings || {});
this.$store.commit('setStateDirty', false);
const createdTags = (workflowData.tags || []) as ITag[];
const tagIds = createdTags.map((tag: ITag): string => tag.id);
this.$store.commit('setWorkflowTagIds', tagIds);
this.$router.push({
name: 'NodeViewExisting',
params: { name: workflowData.id as string, action: 'workflowSave' },
});
this.$store.commit('removeActiveAction', 'workflowSaving');
this.$store.commit('setStateDirty', false);
this.$externalHooks().run('workflow.afterUpdate', { workflowData });
return true;
} catch (e) {
this.$store.commit('removeActiveAction', 'workflowSaving');
this.$showMessage({
title: 'Problem saving workflow',
message: `There was a problem saving the workflow: "${e.message}"`,
type: 'error',
});
return false;
}
},

View file

@ -1,4 +1,22 @@
export const MAX_DISPLAY_DATA_SIZE = 204800;
export const MAX_DISPLAY_ITEMS_AUTO_ALL = 250;
export const NODE_NAME_PREFIX = 'node-';
// workflows
export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__';
export const DEFAULT_NEW_WORKFLOW_NAME = 'My workflow';
export const MIN_WORKFLOW_NAME_LENGTH = 1;
export const MAX_WORKFLOW_NAME_LENGTH = 128;
export const DUPLICATE_POSTFFIX = ' copy';
// tags
export const MAX_TAG_NAME_LENGTH = 24;
export const DUPLICATE_MODAL_KEY = 'duplicate';
export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
export const WORKLOW_OPEN_MODAL_KEY = 'workflowOpen';
export const BREAKPOINT_SM = 768;
export const BREAKPOINT_MD = 992;
export const BREAKPOINT_LG = 1200;
export const BREAKPOINT_XL = 1920;

View file

@ -19,6 +19,9 @@ import router from './router';
import { runExternalHook } from './components/mixins/externalHooks';
// @ts-ignore
import vClickOutside from 'v-click-outside';
import { library } from '@fortawesome/fontawesome-svg-core';
import {
faAngleDoubleLeft,
@ -70,6 +73,7 @@ import {
faPlay,
faPlayCircle,
faPlus,
faPlusCircle,
faQuestion,
faQuestionCircle,
faRedo,
@ -102,6 +106,7 @@ import { store } from './store';
Vue.use(Vue2TouchEvents);
Vue.use(ElementUI, { locale });
Vue.use(vClickOutside);
library.add(faAngleDoubleLeft);
library.add(faAngleDown);
@ -152,6 +157,7 @@ library.add(faPen);
library.add(faPlay);
library.add(faPlayCircle);
library.add(faPlus);
library.add(faPlusCircle);
library.add(faQuestion);
library.add(faQuestionCircle);
library.add(faRedo);

View file

@ -0,0 +1,105 @@
import { ActionContext, Module } from 'vuex';
import {
ITag,
ITagsState,
IRootState,
} from '../Interface';
import { createTag, deleteTag, getTags, updateTag } from '../api/tags';
import Vue from 'vue';
const module: Module<ITagsState, IRootState> = {
namespaced: true,
state: {
tags: {},
isLoading: false,
fetchedAll: false,
fetchedUsageCount: false,
},
mutations: {
setLoading: (state: ITagsState, isLoading: boolean) => {
state.isLoading = isLoading;
},
setAllTags: (state: ITagsState, tags: ITag[]) => {
state.tags = tags
.reduce((accu: { [id: string]: ITag }, tag: ITag) => {
accu[tag.id] = tag;
return accu;
}, {});
state.fetchedAll = true;
},
upsertTags(state: ITagsState, tags: ITag[]) {
tags.forEach((tag) => {
const tagId = tag.id;
const currentTag = state.tags[tagId];
if (currentTag) {
const newTag = {
...currentTag,
...tag,
};
Vue.set(state.tags, tagId, newTag);
}
else {
Vue.set(state.tags, tagId, tag);
}
});
},
deleteTag(state: ITagsState, id: string) {
Vue.delete(state.tags, id);
},
},
getters: {
allTags(state: ITagsState): ITag[] {
return Object.values(state.tags)
.sort((a, b) => a.name.localeCompare(b.name));
},
isLoading: (state: ITagsState): boolean => {
return state.isLoading;
},
hasTags: (state: ITagsState): boolean => {
return Object.keys(state.tags).length > 0;
},
getTagById: (state: ITagsState) => {
return (id: string) => state.tags[id];
},
},
actions: {
fetchAll: async (context: ActionContext<ITagsState, IRootState>, params?: { force?: boolean, withUsageCount?: boolean }) => {
const { force = false, withUsageCount = false } = params || {};
if (!force && context.state.fetchedAll && context.state.fetchedUsageCount === withUsageCount) {
return context.state.tags;
}
context.commit('setLoading', true);
const tags = await getTags(context.rootGetters.getRestApiContext, Boolean(withUsageCount));
context.commit('setAllTags', tags);
context.commit('setLoading', false);
return tags;
},
create: async (context: ActionContext<ITagsState, IRootState>, name: string) => {
const tag = await createTag(context.rootGetters.getRestApiContext, { name });
context.commit('upsertTags', [tag]);
return tag;
},
rename: async (context: ActionContext<ITagsState, IRootState>, { id, name }: { id: string, name: string }) => {
const tag = await updateTag(context.rootGetters.getRestApiContext, id, { name });
context.commit('upsertTags', [tag]);
return tag;
},
delete: async (context: ActionContext<ITagsState, IRootState>, id: string) => {
const deleted = await deleteTag(context.rootGetters.getRestApiContext, id);
if (deleted) {
context.commit('deleteTag', id);
context.commit('removeWorkflowTagId', id, {root: true});
}
return deleted;
},
},
};
export default module;

View file

@ -0,0 +1,67 @@
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY } from '@/constants';
import Vue from 'vue';
import { ActionContext, Module } from 'vuex';
import {
IRootState,
IUiState,
} from '../Interface';
const module: Module<IUiState, IRootState> = {
namespaced: true,
state: {
modals: {
[DUPLICATE_MODAL_KEY]: {
open: false,
},
[TAGS_MANAGER_MODAL_KEY]: {
open: false,
},
[WORKLOW_OPEN_MODAL_KEY]: {
open: false,
},
},
modalStack: [],
sidebarMenuCollapsed: true,
isPageLoading: true,
},
getters: {
isModalOpen: (state: IUiState) => {
return (name: string) => state.modals[name].open;
},
isModalActive: (state: IUiState) => {
return (name: string) => state.modalStack.length > 0 && name === state.modalStack[0];
},
anyModalsOpen: (state: IUiState) => {
return state.modalStack.length > 0;
},
sidebarMenuCollapsed: (state: IUiState): boolean => state.sidebarMenuCollapsed,
},
mutations: {
openModal: (state: IUiState, name: string) => {
Vue.set(state.modals[name], 'open', true);
state.modalStack = [name].concat(state.modalStack);
},
closeTopModal: (state: IUiState) => {
const name = state.modalStack[0];
Vue.set(state.modals[name], 'open', false);
state.modalStack = state.modalStack.slice(1);
},
toggleSidebarMenuCollapse: (state: IUiState) => {
state.sidebarMenuCollapsed = !state.sidebarMenuCollapsed;
},
},
actions: {
openTagsManagerModal: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', TAGS_MANAGER_MODAL_KEY);
},
openWorklfowOpenModal: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', WORKLOW_OPEN_MODAL_KEY);
},
openDuplicateModal: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', DUPLICATE_MODAL_KEY);
},
},
};
export default module;

View file

@ -0,0 +1,48 @@
import { getNewWorkflow } from '@/api/workflows';
import { DUPLICATE_POSTFFIX, MAX_WORKFLOW_NAME_LENGTH, DEFAULT_NEW_WORKFLOW_NAME } from '@/constants';
import { ActionContext, Module } from 'vuex';
import {
IRootState,
IWorkflowsState,
} from '../Interface';
const module: Module<IWorkflowsState, IRootState> = {
namespaced: true,
state: {},
actions: {
setNewWorkflowName: async (context: ActionContext<IWorkflowsState, IRootState>): Promise<void> => {
let newName = '';
try {
const newWorkflow = await getNewWorkflow(context.rootGetters.getRestApiContext);
newName = newWorkflow.name;
}
catch (e) {
// in case of error, default to original name
newName = DEFAULT_NEW_WORKFLOW_NAME;
}
context.commit('setWorkflowName', { newName }, { root: true });
},
getDuplicateCurrentWorkflowName: async (context: ActionContext<IWorkflowsState, IRootState>): Promise<string> => {
const currentWorkflowName = context.rootGetters.workflowName;
if (currentWorkflowName && (currentWorkflowName.length + DUPLICATE_POSTFFIX.length) >= MAX_WORKFLOW_NAME_LENGTH) {
return currentWorkflowName;
}
let newName = `${currentWorkflowName}${DUPLICATE_POSTFFIX}`;
try {
const newWorkflow = await getNewWorkflow(context.rootGetters.getRestApiContext, newName );
newName = newWorkflow.name;
}
catch (e) {
}
return newName;
},
},
};
export default module;

View file

@ -6,6 +6,8 @@ $--color-primary-light: #fbebed;
$--custom-dialog-text-color: #666;
$--custom-dialog-background: #fff;
$--custom-font-black: #000;
$--custom-font-dark: #595e67;
$--custom-font-light: #777;
$--custom-font-very-light: #999;
@ -21,6 +23,7 @@ $--custom-error-text : #eb2222;
$--custom-running-background : #ffffe5;
$--custom-running-text : #eb9422;
$--custom-success-background : #e3f0e4;
$--custom-success-text-light: #2f4;
$--custom-success-text : #40c351;
$--custom-warning-background : #ffffe5;
$--custom-warning-text : #eb9422;
@ -28,14 +31,35 @@ $--custom-warning-text : #eb9422;
$--custom-node-view-background : #faf9fe;
// Table
$--custom-table-background-main: $--custom-header-background ;
$--custom-table-background-alternative: #f5f5f5;
$--custom-table-background-alternative2: lighten($--custom-table-background-main, 60% );
$--custom-table-background-main: $--custom-header-background;
$--custom-table-background-stripe-color: #f6f6f6;
$--custom-table-background-hover-color: #e9f0f4;
$--custom-input-background: #f0f0f0;
$--custom-input-background-disabled: #ccc;
$--custom-input-font: #333;
$--custom-input-border-color: #dcdfe6;
$--custom-input-font-disabled: #555;
$--custom-input-border-shadow: 1px solid $--custom-input-border-color;
$--header-height: 65px;
$--sidebar-width: 65px;
$--sidebar-expanded-width: 200px;
$--tags-manager-min-height: 300px;
// based on element.io breakpoints
$--breakpoint-xs: 768px;
$--breakpoint-sm: 992px;
$--breakpoint-md: 1200px;
$--breakpoint-lg: 1920px;
// scrollbars
$--scrollbar-thumb-color: lighten($--color-primary, 20%);
// tags
$--tag-background-color: #dce1e9;
$--tag-text-color: #3d3f46;
$--tag-close-background-color: #717782;
$--tag-close-background-hover-color: #3d3f46;
$--table-row-hover-background: lighten( $--custom-table-background-alternative, 15% );
$--table-current-row-background: $--table-row-hover-background;

View file

@ -6,6 +6,7 @@
$--font-path: '~element-ui/lib/theme-chalk/fonts';
@import "~element-ui/packages/theme-chalk/src/index";
@import "~element-ui/lib/theme-chalk/display.css";
body {
font-family: 'Open Sans', sans-serif;
@ -197,14 +198,14 @@ h1, h2, h3, h4, h5, h6 {
.el-table--striped {
.el-table__body {
tr.el-table__row--striped {
background-color: $--custom-table-background-alternative;
background-color: $--custom-table-background-stripe-color;
td {
background: none;
}
}
tr.el-table__row:hover,
tr.el-table__row:hover > td {
background-color: $--custom-table-background-alternative2;
background-color: $--custom-table-background-hover-color;
}
}
}
@ -327,7 +328,7 @@ h1, h2, h3, h4, h5, h6 {
}
.el-input-number__decrease.is-disabled,
.el-input-number__increase.is-disabled {
background-color: $--custom-table-background-alternative2;
background-color: $--custom-input-background-disabled;
}
}
@ -384,7 +385,11 @@ h1, h2, h3, h4, h5, h6 {
border-color: #555;
color: $--custom-input-font-disabled;
}
.el-button.is-plain,.el-button.is-plain:hover {
color: $--color-primary;
border: 1px solid $--color-primary;
background-color: #fff;
}
// Textarea
.ql-editor,
@ -477,7 +482,7 @@ h1, h2, h3, h4, h5, h6 {
}
::-webkit-scrollbar-thumb {
border-radius: 6px;
background: lighten($--color-primary, 20%);
background: $--scrollbar-thumb-color;
}
::-webkit-scrollbar-thumb:hover {
background: $--color-primary;
@ -493,3 +498,28 @@ h1, h2, h3, h4, h5, h6 {
border-radius: 6px;
}
}
.tags-container {
.el-tag {
color: $--tag-text-color;
font-size: 12px;
background-color: $--tag-background-color;
border-radius: 12px;
height: auto;
border-color: $--tag-background-color;
font-weight: 400;
.el-icon-close {
color: $--tag-background-color;
background-color: $--tag-close-background-color !important;
max-height: 15px;
max-width: 15px;
margin-right: 6px;
&:hover {
background-color: $--tag-close-background-hover-color !important;
}
}
}
}

View file

@ -1,6 +1,6 @@
import Vue from 'vue';
import Router from 'vue-router';
import MainHeader from '@/components/MainHeader.vue';
import MainHeader from '@/components/MainHeader/MainHeader.vue';
import MainSidebar from '@/components/MainSidebar.vue';
import NodeView from '@/views/NodeView.vue';

View file

@ -21,6 +21,7 @@ import {
ICredentialsResponse,
IExecutionResponse,
IExecutionsCurrentSummaryExtended,
IRootState,
IMenuItem,
INodeUi,
INodeUpdatePropertiesInformation,
@ -29,59 +30,74 @@ import {
IUpdateInformation,
IWorkflowDb,
XYPositon,
IRestApiContext,
} from './Interface';
import tags from './modules/tags';
import ui from './modules/ui';
import workflows from './modules/workflows';
Vue.use(Vuex);
const state: IRootState = {
activeExecutions: [],
activeWorkflows: [],
activeActions: [],
activeNode: null,
// @ts-ignore
baseUrl: process.env.VUE_APP_URL_BASE_API ? process.env.VUE_APP_URL_BASE_API : (window.BASE_PATH === '/%BASE_PATH%/' ? '/' : window.BASE_PATH),
credentials: null,
credentialTypes: null,
endpointWebhook: 'webhook',
endpointWebhookTest: 'webhook-test',
executionId: null,
executingNode: '',
executionWaitingForWebhook: false,
pushConnectionActive: true,
saveDataErrorExecution: 'all',
saveDataSuccessExecution: 'all',
saveManualExecutions: false,
timezone: 'America/New_York',
stateIsDirty: false,
executionTimeout: -1,
maxExecutionTimeout: Number.MAX_SAFE_INTEGER,
versionCli: '0.0.0',
oauthCallbackUrls: {},
n8nMetadata: {},
workflowExecutionData: null,
lastSelectedNode: null,
lastSelectedNodeOutputIndex: null,
nodeIndex: [],
nodeTypes: [],
nodeViewOffsetPosition: [0, 0],
nodeViewMoveInProgress: false,
selectedNodes: [],
sessionId: Math.random().toString(36).substring(2, 15),
urlBaseWebhook: 'http://localhost:5678/',
workflow: {
id: PLACEHOLDER_EMPTY_WORKFLOW_ID,
name: '',
active: false,
createdAt: -1,
updatedAt: -1,
connections: {},
nodes: [],
settings: {},
tags: [],
},
sidebarMenuItems: [],
};
const modules = {
tags,
ui,
workflows,
};
export const store = new Vuex.Store({
strict: process.env.NODE_ENV !== 'production',
state: {
activeExecutions: [] as IExecutionsCurrentSummaryExtended[],
activeWorkflows: [] as string[],
activeActions: [] as string[],
activeNode: null as string | null,
// @ts-ignore
baseUrl: process.env.VUE_APP_URL_BASE_API ? process.env.VUE_APP_URL_BASE_API : (window.BASE_PATH === '/%BASE_PATH%/' ? '/' : window.BASE_PATH),
credentials: null as ICredentialsResponse[] | null,
credentialTypes: null as ICredentialType[] | null,
endpointWebhook: 'webhook',
endpointWebhookTest: 'webhook-test',
executionId: null as string | null,
executingNode: '' as string | null,
executionWaitingForWebhook: false,
pushConnectionActive: false,
saveDataErrorExecution: 'all',
saveDataSuccessExecution: 'all',
saveManualExecutions: false,
timezone: 'America/New_York',
stateIsDirty: false,
executionTimeout: -1,
maxExecutionTimeout: Number.MAX_SAFE_INTEGER,
versionCli: '0.0.0',
oauthCallbackUrls: {},
n8nMetadata: {},
workflowExecutionData: null as IExecutionResponse | null,
lastSelectedNode: null as string | null,
lastSelectedNodeOutputIndex: null as number | null,
nodeIndex: [] as Array<string | null>,
nodeTypes: [] as INodeTypeDescription[],
nodeViewOffsetPosition: [0, 0] as XYPositon,
nodeViewMoveInProgress: false,
selectedNodes: [] as INodeUi[],
sessionId: Math.random().toString(36).substring(2, 15),
urlBaseWebhook: 'http://localhost:5678/',
workflow: {
id: PLACEHOLDER_EMPTY_WORKFLOW_ID,
name: '',
active: false,
createdAt: -1,
updatedAt: -1,
connections: {} as IConnections,
nodes: [] as INodeUi[],
settings: {} as IWorkflowSettings,
} as IWorkflowDb,
sidebarMenuItems: [] as IMenuItem[],
},
modules,
state,
mutations: {
// Active Actions
addActiveAction (state, action: string) {
@ -565,6 +581,17 @@ export const store = new Vuex.Store({
Vue.set(state.workflow, 'settings', workflowSettings);
},
setWorkflowTagIds (state, tags: string[]) {
Vue.set(state.workflow, 'tags', tags);
},
removeWorkflowTagId (state, tagId: string) {
const tags = state.workflow.tags as string[];
const updated = tags.filter((id: string) => id !== tagId);
Vue.set(state.workflow, 'tags', updated);
},
// Workflow
setWorkflow (state, workflow: IWorkflowDb) {
Vue.set(state, 'workflow', workflow);
@ -625,6 +652,16 @@ export const store = new Vuex.Store({
}
return `${state.baseUrl}${endpoint}`;
},
getRestApiContext(state): IRestApiContext {
let endpoint = 'rest';
if (process.env.VUE_APP_ENDPOINT_REST) {
endpoint = process.env.VUE_APP_ENDPOINT_REST;
}
return {
baseUrl: `${state.baseUrl}${endpoint}`,
sessionId: state.sessionId,
};
},
getWebhookBaseUrl: (state): string => {
return state.urlBaseWebhook;
},
@ -818,6 +855,10 @@ export const store = new Vuex.Store({
return state.workflow.settings;
},
workflowTags: (state): string[] => {
return state.workflow.tags as string[];
},
// Workflow Result Data
getWorkflowExecution: (state): IExecutionResponse | null => {
return state.workflowExecutionData;
@ -845,22 +886,4 @@ export const store = new Vuex.Store({
return state.sidebarMenuItems;
},
},
});
// import Vue from 'vue';
// import Vuex from 'vuex';
// Vue.use(Vuex)
// export default new Vuex.Store({
// state: {
// },
// mutations: {
// },
// actions: {
// }
// });

View file

@ -38,7 +38,7 @@
@nodeTypeSelected="nodeTypeSelected"
@closeNodeCreator="closeNodeCreator"
></node-creator>
<div class="zoom-menu">
<div :class="{ 'zoom-menu': true, expanded: !sidebarMenuCollapsed }">
<button @click="setZoom('in')" class="button-white" title="Zoom In">
<font-awesome-icon icon="search-plus"/>
</button>
@ -102,6 +102,7 @@
<font-awesome-icon icon="trash" class="clear-execution-icon" />
</el-button>
</div>
<Modals />
</div>
</template>
@ -126,6 +127,7 @@ import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { workflowRun } from '@/components/mixins/workflowRun';
import DataDisplay from '@/components/DataDisplay.vue';
import Modals from '@/components/Modals.vue';
import Node from '@/components/Node.vue';
import NodeCreator from '@/components/NodeCreator.vue';
import NodeSettings from '@/components/NodeSettings.vue';
@ -133,7 +135,6 @@ import RunData from '@/components/RunData.vue';
import mixins from 'vue-typed-mixins';
import { v4 as uuidv4} from 'uuid';
import { debounce } from 'lodash';
import axios from 'axios';
import {
IConnection,
@ -163,7 +164,9 @@ import {
IWorkflowDataUpdate,
XYPositon,
IPushDataExecutionFinished,
ITag,
} from '../Interface';
import { mapGetters } from 'vuex';
export default mixins(
copyPaste,
@ -181,6 +184,7 @@ export default mixins(
name: 'NodeView',
components: {
DataDisplay,
Modals,
Node,
NodeCreator,
NodeSettings,
@ -234,6 +238,9 @@ export default mixins(
}
},
computed: {
...mapGetters('ui', [
'sidebarMenuCollapsed',
]),
activeNode (): INodeUi | null {
return this.$store.getters.activeNode;
},
@ -303,7 +310,6 @@ export default mixins(
lastClickPosition: [450, 450] as XYPositon,
nodeViewScale: 1,
ctrlKeyPressed: false,
debouncedFunctions: [] as any[], // tslint:disable-line:no-any
stopExecutionInProgress: false,
};
},
@ -314,18 +320,6 @@ export default mixins(
document.removeEventListener('keyup', this.keyUp);
},
methods: {
async callDebounced (...inputParameters: any[]): Promise<void> { // tslint:disable-line:no-any
const functionName = inputParameters.shift() as string;
const debounceTime = inputParameters.shift() as number;
// @ts-ignore
if (this.debouncedFunctions[functionName] === undefined) {
// @ts-ignore
this.debouncedFunctions[functionName] = debounce(this[functionName], debounceTime, { leading: true });
}
// @ts-ignore
await this.debouncedFunctions[functionName].apply(this, inputParameters);
},
clearExecutionData () {
this.$store.commit('setWorkflowExecutionData', null);
this.updateNodesExecutionIssues();
@ -378,6 +372,12 @@ export default mixins(
this.$store.commit('setWorkflowName', {newName: data.name, setStateDirty: false});
this.$store.commit('setWorkflowSettings', data.settings || {});
const tags = (data.tags || []) as ITag[];
this.$store.commit('tags/upsertTags', tags);
const tagIds = tags.map((tag) => tag.id);
this.$store.commit('setWorkflowTagIds', tagIds || []);
await this.addNodes(data.nodes, data.connections);
this.$store.commit('setStateDirty', false);
@ -440,6 +440,10 @@ export default mixins(
return;
}
}
const anyModalsOpen = this.$store.getters['ui/anyModalsOpen'];
if (anyModalsOpen) {
return;
}
if (e.key === 'd') {
this.callDebounced('deactivateSelectedNode', 350);
@ -485,7 +489,7 @@ export default mixins(
e.stopPropagation();
e.preventDefault();
this.$root.$emit('openWorkflowDialog');
this.$store.dispatch('ui/openWorklfowOpenModal');
} else if (e.key === 'n' && this.isCtrlKeyPressed(e) === true && e.altKey === true) {
// Create a new workflow
e.stopPropagation();
@ -503,7 +507,9 @@ export default mixins(
e.stopPropagation();
e.preventDefault();
this.$store.commit('setStateDirty', false);
if (this.isReadOnly) {
return;
}
this.callDebounced('saveCurrentWorkflow', 1000);
} else if (e.key === 'Enter') {
@ -1392,6 +1398,8 @@ export default mixins(
},
async newWorkflow (): Promise<void> {
await this.resetWorkspace();
await this.$store.dispatch('workflows/setNewWorkflowName');
this.$store.commit('setStateDirty', false);
// Create start node
const defaultNodes = [
@ -1440,6 +1448,9 @@ export default mixins(
}
if (workflowId !== null) {
const workflow = await this.restApi().getWorkflow(workflowId);
if (!workflow) {
throw new Error('Could not find workflow');
}
this.$titleSet(workflow.name, 'IDLE');
// Open existing workflow
await this.openWorkflow(workflowId);
@ -1988,6 +1999,7 @@ export default mixins(
this.$store.commit('setWorkflowId', PLACEHOLDER_EMPTY_WORKFLOW_ID);
this.$store.commit('setWorkflowName', {newName: '', setStateDirty: false});
this.$store.commit('setWorkflowSettings', {});
this.$store.commit('setWorkflowTagIds', []);
this.$store.commit('setActiveExecutionId', null);
this.$store.commit('setExecutingNode', null);
@ -2097,14 +2109,20 @@ export default mixins(
<style scoped lang="scss">
.zoom-menu {
$--zoom-menu-margin: 5;
position: fixed;
left: 70px;
left: $--sidebar-width + $--zoom-menu-margin;
width: 200px;
bottom: 45px;
line-height: 25px;
z-index: 18;
color: #444;
padding-right: 5px;
&.expanded {
left: $--sidebar-expanded-width + $--zoom-menu-margin;
}
}
.node-creator-button {