mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
✨ 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:
parent
335673d329
commit
05eec87d1d
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
Binary file not shown.
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
112
packages/cli/src/TagHelpers.ts
Normal file
112
packages/cli/src/TagHelpers.ts
Normal 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();
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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()
|
37
packages/cli/src/databases/entities/TagEntity.ts
Normal file
37
packages/cli/src/databases/entities/TagEntity.ts
Normal 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();
|
||||
}
|
||||
}
|
94
packages/cli/src/databases/entities/WorkflowEntity.ts
Normal file
94
packages/cli/src/databases/entities/WorkflowEntity.ts
Normal 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();
|
||||
}
|
||||
}
|
13
packages/cli/src/databases/entities/index.ts
Normal file
13
packages/cli/src/databases/entities/index.ts
Normal 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,
|
||||
};
|
|
@ -1,9 +0,0 @@
|
|||
import * as PostgresDb from './postgresdb';
|
||||
import * as SQLite from './sqlite';
|
||||
import * as MySQLDb from './mysqldb';
|
||||
|
||||
export {
|
||||
PostgresDb,
|
||||
SQLite,
|
||||
MySQLDb,
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
export * from './CredentialsEntity';
|
||||
export * from './ExecutionEntity';
|
||||
export * from './WorkflowEntity';
|
||||
export * from './WebhookEntity';
|
|
@ -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");
|
||||
}
|
||||
|
||||
}
|
|
@ -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`');
|
||||
}
|
||||
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
export * from './CredentialsEntity';
|
||||
export * from './ExecutionEntity';
|
||||
export * from './WorkflowEntity';
|
||||
export * from './WebhookEntity';
|
||||
|
|
@ -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)`);
|
||||
}
|
||||
|
||||
}
|
|
@ -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"`);
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
export * from './CredentialsEntity';
|
||||
export * from './ExecutionEntity';
|
||||
export * from './WorkflowEntity';
|
||||
export * from './WebhookEntity';
|
|
@ -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") `);
|
||||
}
|
||||
|
||||
}
|
|
@ -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"`);
|
||||
}
|
||||
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
42
packages/cli/src/databases/utils.ts
Normal file
42
packages/cli/src/databases/utils.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
76
packages/editor-ui/src/api/helpers.ts
Normal file
76
packages/editor-ui/src/api/helpers.ts
Normal 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;
|
||||
}
|
||||
}
|
18
packages/editor-ui/src/api/tags.ts
Normal file
18
packages/editor-ui/src/api/tags.ts
Normal 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}`);
|
||||
}
|
6
packages/editor-ui/src/api/workflows.ts
Normal file
6
packages/editor-ui/src/api/workflows.ts
Normal 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 } : {});
|
||||
}
|
101
packages/editor-ui/src/components/BreakpointsObserver.vue
Normal file
101
packages/editor-ui/src/components/BreakpointsObserver.vue
Normal 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>
|
125
packages/editor-ui/src/components/DuplicateWorkflowDialog.vue
Normal file
125
packages/editor-ui/src/components/DuplicateWorkflowDialog.vue
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
100
packages/editor-ui/src/components/InlineTextEdit.vue
Normal file
100
packages/editor-ui/src/components/InlineTextEdit.vue
Normal 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>
|
30
packages/editor-ui/src/components/IntersectionObserved.vue
Normal file
30
packages/editor-ui/src/components/IntersectionObserved.vue
Normal 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>
|
56
packages/editor-ui/src/components/IntersectionObserver.vue
Normal file
56
packages/editor-ui/src/components/IntersectionObserver.vue
Normal 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>
|
|
@ -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>
|
||||
<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" />
|
||||
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>
|
|
@ -0,0 +1,104 @@
|
|||
<template>
|
||||
<div class="container">
|
||||
<span class="title">
|
||||
Execution Id:
|
||||
<span>
|
||||
<strong>{{ executionId }}</strong
|
||||
>
|
||||
<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>
|
|
@ -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>
|
94
packages/editor-ui/src/components/MainHeader/MainHeader.vue
Normal file
94
packages/editor-ui/src/components/MainHeader/MainHeader.vue
Normal 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>
|
279
packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue
Normal file
279
packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue
Normal 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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
115
packages/editor-ui/src/components/Modal.vue
Normal file
115
packages/editor-ui/src/components/Modal.vue
Normal 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>
|
24
packages/editor-ui/src/components/ModalRoot.vue
Normal file
24
packages/editor-ui/src/components/ModalRoot.vue
Normal 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>
|
53
packages/editor-ui/src/components/Modals.vue
Normal file
53
packages/editor-ui/src/components/Modals.vue
Normal 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>
|
29
packages/editor-ui/src/components/PushConnectionTracker.vue
Normal file
29
packages/editor-ui/src/components/PushConnectionTracker.vue
Normal 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" /> 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>
|
|
@ -762,7 +762,7 @@ export default mixins(
|
|||
background: #fff;;
|
||||
}
|
||||
tr:nth-child(odd) {
|
||||
background: $--custom-table-background-alternative;
|
||||
background: $--custom-table-background-stripe-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
65
packages/editor-ui/src/components/SaveWorkflowButton.vue
Normal file
65
packages/editor-ui/src/components/SaveWorkflowButton.vue
Normal 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>
|
151
packages/editor-ui/src/components/TagsContainer.vue
Normal file
151
packages/editor-ui/src/components/TagsContainer.vue
Normal 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>
|
369
packages/editor-ui/src/components/TagsDropdown.vue
Normal file
369
packages/editor-ui/src/components/TagsDropdown.vue
Normal 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>
|
61
packages/editor-ui/src/components/TagsManager/NoTagsView.vue
Normal file
61
packages/editor-ui/src/components/TagsManager/NoTagsView.vue
Normal 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>
|
190
packages/editor-ui/src/components/TagsManager/TagsManager.vue
Normal file
190
packages/editor-ui/src/components/TagsManager/TagsManager.vue
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
32
packages/editor-ui/src/components/WorkflowNameShort.vue
Normal file
32
packages/editor-ui/src/components/WorkflowNameShort.vue
Normal 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>
|
|
@ -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>
|
||||
|
|
40
packages/editor-ui/src/components/mixins/emitter.ts
Normal file
40
packages/editor-ui/src/components/mixins/emitter.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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!',
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
105
packages/editor-ui/src/modules/tags.ts
Normal file
105
packages/editor-ui/src/modules/tags.ts
Normal 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;
|
67
packages/editor-ui/src/modules/ui.ts
Normal file
67
packages/editor-ui/src/modules/ui.ts
Normal 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;
|
48
packages/editor-ui/src/modules/workflows.ts
Normal file
48
packages/editor-ui/src/modules/workflows.ts
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
||||
// }
|
||||
// });
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue