From d3c59a6fe3d493d21eaee35632e11805dc919add Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Fri, 28 May 2021 18:13:15 -0500 Subject: [PATCH 01/39] :zap: Fix issue with option parameters that are named the same #1808 --- .../src/components/CollectionParameter.vue | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/editor-ui/src/components/CollectionParameter.vue b/packages/editor-ui/src/components/CollectionParameter.vue index d18b8ae8ed..ac299c130d 100644 --- a/packages/editor-ui/src/components/CollectionParameter.vue +++ b/packages/editor-ui/src/components/CollectionParameter.vue @@ -68,7 +68,7 @@ export default mixins( for (const name of this.propertyNames) { tempProperties = this.getOptionProperties(name); if (tempProperties !== undefined) { - returnProperties.push(tempProperties); + returnProperties.push(...tempProperties); } } return returnProperties; @@ -104,14 +104,15 @@ export default mixins( return this.parameter.typeOptions[argumentName]; }, - getOptionProperties (optionName: string): INodeProperties | undefined { + getOptionProperties (optionName: string): INodeProperties[] { + const properties: INodeProperties[] = []; for (const option of this.parameter.options) { if (option.name === optionName) { - return option; + properties.push(option); } } - return undefined; + return properties; }, displayNodeParameter (parameter: INodeProperties) { if (parameter.displayOptions === undefined) { @@ -121,10 +122,12 @@ export default mixins( return this.displayParameter(this.nodeValues, parameter, this.path); }, optionSelected (optionName: string) { - const option = this.getOptionProperties(optionName); - if (option === undefined) { + const options = this.getOptionProperties(optionName); + if (options.length === 0) { return; } + + const option = options[0]; const name = `${this.path}.${option.name}`; let parameterData; From 383a3449b77305a162571e1bcb0299a1f86c2be2 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Sat, 29 May 2021 00:45:59 -0400 Subject: [PATCH 02/39] :sparkles: Add SSH Node (#1837) * :sparkles: SSH-Node * :zap: Fix issue * :zap: Add file resource * :zap: Improvements * :zap: Some improvements Co-authored-by: Jan Oberhauser --- .../credentials/SshPassword.credentials.ts | 41 ++ .../credentials/SshPrivateKey.credentials.ts | 49 ++ packages/nodes-base/nodes/Ssh/Ssh.node.ts | 421 ++++++++++++++++++ packages/nodes-base/package.json | 6 + 4 files changed, 517 insertions(+) create mode 100644 packages/nodes-base/credentials/SshPassword.credentials.ts create mode 100644 packages/nodes-base/credentials/SshPrivateKey.credentials.ts create mode 100644 packages/nodes-base/nodes/Ssh/Ssh.node.ts diff --git a/packages/nodes-base/credentials/SshPassword.credentials.ts b/packages/nodes-base/credentials/SshPassword.credentials.ts new file mode 100644 index 0000000000..36f9118464 --- /dev/null +++ b/packages/nodes-base/credentials/SshPassword.credentials.ts @@ -0,0 +1,41 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class SshPassword implements ICredentialType { + name = 'sshPassword'; + displayName = 'SSH'; + properties = [ + { + displayName: 'Host', + name: 'host', + required: true, + type: 'string' as NodePropertyTypes, + default: '', + placeholder: 'localhost', + }, + { + displayName: 'Port', + name: 'port', + required: true, + type: 'number' as NodePropertyTypes, + default: 22, + }, + { + displayName: 'Username', + name: 'username', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Password', + name: 'password', + type: 'string' as NodePropertyTypes, + typeOptions: { + password: true, + }, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/credentials/SshPrivateKey.credentials.ts b/packages/nodes-base/credentials/SshPrivateKey.credentials.ts new file mode 100644 index 0000000000..773046d2e2 --- /dev/null +++ b/packages/nodes-base/credentials/SshPrivateKey.credentials.ts @@ -0,0 +1,49 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class SshPrivateKey implements ICredentialType { + name = 'sshPrivateKey'; + displayName = 'SSH'; + properties = [ + { + displayName: 'Host', + name: 'host', + required: true, + type: 'string' as NodePropertyTypes, + default: '', + placeholder: 'localhost', + }, + { + displayName: 'Port', + name: 'port', + required: true, + type: 'number' as NodePropertyTypes, + default: 22, + }, + { + displayName: 'Username', + name: 'username', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Private Key', + name: 'privateKey', + type: 'string' as NodePropertyTypes, + typeOptions: { + rows: 4, + }, + default: '', + }, + { + displayName: 'Passphrase', + name: 'passphrase', + type: 'string' as NodePropertyTypes, + default: '', + description: 'Passphase used to create the key, if no passphase was used leave empty', + }, + + ]; +} diff --git a/packages/nodes-base/nodes/Ssh/Ssh.node.ts b/packages/nodes-base/nodes/Ssh/Ssh.node.ts new file mode 100644 index 0000000000..18759f869a --- /dev/null +++ b/packages/nodes-base/nodes/Ssh/Ssh.node.ts @@ -0,0 +1,421 @@ +import { + BINARY_ENCODING, + IExecuteFunctions, +} from 'n8n-core'; + +import { + IBinaryData, + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + readFile, + rm, + writeFile, +} from 'fs/promises' + +import { file } from 'tmp-promise'; + +const nodeSSH = require('node-ssh'); + +export class Ssh implements INodeType { + description: INodeTypeDescription = { + displayName: 'SSH', + name: 'Ssh', + icon: 'fa:terminal', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Execute commands via SSH', + defaults: { + name: 'SSH', + color: '#000000', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'sshPassword', + required: true, + displayOptions: { + show: { + authentication: [ + 'password', + ], + }, + }, + }, + { + name: 'sshPrivateKey', + required: true, + displayOptions: { + show: { + authentication: [ + 'privateKey', + ], + }, + }, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Password', + value: 'password', + }, + { + name: 'Private Key', + value: 'privateKey', + }, + ], + default: 'password', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Command', + value: 'command', + }, + { + name: 'File', + value: 'file', + }, + ], + default: 'command', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'command', + ], + }, + }, + options: [ + { + name: 'Execute', + value: 'execute', + description: 'Execute a command', + }, + ], + default: 'execute', + description: 'Operation to perform.', + }, + { + displayName: 'Command', + name: 'command', + type: 'string', + displayOptions: { + show: { + resource: [ + 'command', + ], + operation: [ + 'execute', + ], + }, + }, + default: '', + description: 'The command to be executed on a remote device.', + }, + { + displayName: 'Working Directory', + name: 'cwd', + type: 'string', + displayOptions: { + show: { + resource: [ + 'command', + ], + operation: [ + 'execute', + ], + }, + }, + default: '/', + required: true, + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'file', + ], + }, + }, + options: [ + { + name: 'Download', + value: 'download', + description: 'Download a file', + }, + { + name: 'Upload', + value: 'upload', + description: 'Upload a file', + }, + ], + default: 'upload', + description: 'Operation to perform.', + }, + { + displayName: 'Binary Property', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + operation: [ + 'upload', + ], + resource: [ + 'file', + ], + }, + }, + placeholder: '', + description: 'Name of the binary property which contains
the data for the file to be uploaded.', + }, + { + displayName: 'Target Directory', + name: 'path', + type: 'string', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'upload', + ], + }, + }, + default: '', + required: true, + placeholder: '/home/user', + description: `The directory to upload the file to. The name of the file does not need to be specified,
+ it's taken from the binary data file name. To override this behavior, set the parameter
+ "File Name" under options.`, + }, + { + displayName: 'Path', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'download', + ], + }, + }, + name: 'path', + type: 'string', + default: '', + placeholder: '/home/user/invoice.txt', + description: 'The file path of the file to download. Has to contain the full path including file name.', + required: true, + }, + { + displayName: 'Binary Property', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'download', + ], + }, + }, + name: 'binaryPropertyName', + type: 'string', + default: 'data', + description: 'Object property name which holds binary data.', + required: true, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'upload', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'File Name', + name: 'fileName', + type: 'string', + default: '', + description: `Overrides the binary data file name.`, + }, + ], + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + const returnData: IDataObject[] = []; + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + const authentication = this.getNodeParameter('authentication', 0) as string; + + const cleanupFiles: string[] = []; + + const ssh = new nodeSSH.NodeSSH(); + + try { + if (authentication === 'password') { + + const credentials = this.getCredentials('sshPassword') as IDataObject; + + await ssh.connect({ + host: credentials.host as string, + username: credentials.username as string, + port: credentials.port as number, + password: credentials.password as string, + }); + + } else if (authentication === 'privateKey') { + + const credentials = this.getCredentials('sshPrivateKey') as IDataObject; + + const { path, } = await file(); + cleanupFiles.push(path); + await writeFile(path, credentials.privateKey as string); + + const options = { + host: credentials.host as string, + username: credentials.username as string, + port: credentials.port as number, + privateKey: path, + } as any; // tslint:disable-line: no-any + + if (!credentials.passphrase) { + options.passphrase = credentials.passphrase as string; + } + + await ssh.connect(options); + } + + for (let i = 0; i < items.length; i++) { + + if (resource === 'command') { + + if (operation === 'execute') { + + const command = this.getNodeParameter('command', i) as string; + const cwd = this.getNodeParameter('cwd', i) as string; + returnData.push(await ssh.execCommand(command, { cwd, })); + } + } + + if (resource === 'file') { + + if (operation === 'download') { + + const dataPropertyNameDownload = this.getNodeParameter('binaryPropertyName', i) as string; + const parameterPath = this.getNodeParameter('path', i) as string; + + const { path } = await file({mode: 0x0777, prefix: 'prefix-'}); + cleanupFiles.push(path); + + await ssh.getFile(path, parameterPath); + + const newItem: INodeExecutionData = { + json: items[i].json, + binary: {}, + }; + + if (items[i].binary !== undefined) { + // Create a shallow copy of the binary data so that the old + // data references which do not get changed still stay behind + // but the incoming data does not get changed. + Object.assign(newItem.binary, items[i].binary); + } + + items[i] = newItem; + + const data = await readFile(path as string); + + items[i].binary![dataPropertyNameDownload] = await this.helpers.prepareBinaryData(data, parameterPath); + } + + if (operation === 'upload') { + + const parameterPath = this.getNodeParameter('path', i) as string; + const fileName = this.getNodeParameter('options.fileName', i, '') as string; + + console.log('path', parameterPath); + + const item = items[i]; + + if (item.binary === undefined) { + throw new Error('No binary data exists on item!'); + } + + const propertyNameUpload = this.getNodeParameter('binaryPropertyName', i) as string; + + const binaryData = item.binary[propertyNameUpload] as IBinaryData; + + if (item.binary[propertyNameUpload] === undefined) { + throw new Error(`No binary data property "${propertyNameUpload}" does not exists on item!`); + } + + const { fd, path } = await file(); + cleanupFiles.push(path); + await fsWriteFileAsync(fd, Buffer.from(binaryData.data, BINARY_ENCODING)); + + await ssh.putFile(path, `${parameterPath}${(parameterPath.charAt(parameterPath.length -1) === '/') ? '' : '/'}${fileName || binaryData.fileName}`); + + returnData.push({ success: true }); + } + } + } + } catch (error) { + ssh.dispose(); + for (const cleanup of cleanupFiles) await rm(cleanup); + throw error; + } + + for (const cleanup of cleanupFiles) await rm(cleanup); + + ssh.dispose(); + + if (resource === 'file' && operation === 'download') { + // For file downloads the files get attached to the existing items + return this.prepareOutputData(items); + } else { + return [this.helpers.returnJsonArray(returnData)]; + } + } +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 16c050f145..9c1a149ce6 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -225,6 +225,10 @@ "dist/credentials/StackbyApi.credentials.js", "dist/credentials/StravaOAuth2Api.credentials.js", "dist/credentials/StripeApi.credentials.js", + "dist/credentials/SalesmateApi.credentials.js", + "dist/credentials/SegmentApi.credentials.js", + "dist/credentials/SshPassword.credentials.js", + "dist/credentials/SshPrivateKey.credentials.js", "dist/credentials/Sftp.credentials.js", "dist/credentials/Signl4Api.credentials.js", "dist/credentials/SpontitApi.credentials.js", @@ -508,6 +512,7 @@ "dist/nodes/SpreadsheetFile.node.js", "dist/nodes/Stackby/Stackby.node.js", "dist/nodes/SseTrigger.node.js", + "dist/nodes/Ssh/Ssh.node.js", "dist/nodes/Start.node.js", "dist/nodes/Storyblok/Storyblok.node.js", "dist/nodes/Strapi/Strapi.node.js", @@ -628,6 +633,7 @@ "mongodb": "3.6.6", "mqtt": "4.2.6", "mssql": "^6.2.0", + "node-ssh": "^11.0.0", "mysql2": "~2.2.0", "n8n-core": "~0.72.0", "nodemailer": "^6.5.0", From 335673d329905a5b63ffd781e87d534081064500 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Fri, 28 May 2021 23:53:17 -0500 Subject: [PATCH 03/39] :zap: Minor improvement and fix --- packages/nodes-base/nodes/Ssh/Ssh.node.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/Ssh/Ssh.node.ts b/packages/nodes-base/nodes/Ssh/Ssh.node.ts index 18759f869a..b2e1bea12c 100644 --- a/packages/nodes-base/nodes/Ssh/Ssh.node.ts +++ b/packages/nodes-base/nodes/Ssh/Ssh.node.ts @@ -289,7 +289,7 @@ export class Ssh implements INodeType { const operation = this.getNodeParameter('operation', 0) as string; const authentication = this.getNodeParameter('authentication', 0) as string; - const cleanupFiles: string[] = []; + const temporaryFiles: string[] = []; const ssh = new nodeSSH.NodeSSH(); @@ -310,7 +310,7 @@ export class Ssh implements INodeType { const credentials = this.getCredentials('sshPrivateKey') as IDataObject; const { path, } = await file(); - cleanupFiles.push(path); + temporaryFiles.push(path); await writeFile(path, credentials.privateKey as string); const options = { @@ -347,7 +347,7 @@ export class Ssh implements INodeType { const parameterPath = this.getNodeParameter('path', i) as string; const { path } = await file({mode: 0x0777, prefix: 'prefix-'}); - cleanupFiles.push(path); + temporaryFiles.push(path); await ssh.getFile(path, parameterPath); @@ -391,9 +391,9 @@ export class Ssh implements INodeType { throw new Error(`No binary data property "${propertyNameUpload}" does not exists on item!`); } - const { fd, path } = await file(); - cleanupFiles.push(path); - await fsWriteFileAsync(fd, Buffer.from(binaryData.data, BINARY_ENCODING)); + const { path } = await file(); + temporaryFiles.push(path); + await writeFile(path, Buffer.from(binaryData.data, BINARY_ENCODING)); await ssh.putFile(path, `${parameterPath}${(parameterPath.charAt(parameterPath.length -1) === '/') ? '' : '/'}${fileName || binaryData.fileName}`); @@ -403,11 +403,11 @@ export class Ssh implements INodeType { } } catch (error) { ssh.dispose(); - for (const cleanup of cleanupFiles) await rm(cleanup); + for (const tempFile of temporaryFiles) await rm(tempFile); throw error; } - for (const cleanup of cleanupFiles) await rm(cleanup); + for (const tempFile of temporaryFiles) await rm(tempFile); ssh.dispose(); From 05eec87d1d5700aebd93bea8bb379d7a02d84c78 Mon Sep 17 00:00:00 2001 From: Ben Hesseldieck <1849459+BHesseldieck@users.noreply.github.com> Date: Sat, 29 May 2021 20:31:21 +0200 Subject: [PATCH 04/39] :sparkles: Add tagging of workflows (#1647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * clean up dropdown * clean up focusoncreate * :zap: Ignore mistaken ID in POST /workflows * :zap: Fix undefined tag ID in PATCH /workflows * :zap: Shorten response for POST /tags * remove scss mixins * clean up imports * :zap: 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 * :zap: Fix duplicate entry error for pg and MySQL * update rename messaging * update order of buttons * fix firefox overflow on windows * fix dropdown height * :hammer: refactor tag crud controllers * 🧹 remove unused imports * use variable for number of items * fix dropdown spacing * :zap: Restore type to fix build * :zap: Fix post-refactor PATCH /workflows/:id * :zap: Fix PATCH /workflows/:id for zero tags * :zap: 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 * :zap: return tag id as string * :hammer: add hooks to tag CUD operations * 🏎 simplify timestamp pruning * remove blur event * fix onblur bug * :zap: Fix fs import to fix build * address max's comments * implement responsive tag container * fix lint issues * Set default dates in entities * :shirt: 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 * :zap: Implement unique workflow names * :zap: 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 * :zap: 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 * :construction: 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 * :zap: 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 * :zap: Adjust quotes in MySQL migration * :zap: 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 * :zap: Refactor endpoint per new guidelines * support naming endpoint * :zap: Refactor to support numeric suffixes * :shirt: Lint migrations for unique workflow names * :zap: Add migrations set default dates to indexes * fix connection push bug * :zap: Lowercase default workflow name * :zap: Add prefixes to set default dates migration * :zap: Fix indentation on default dates migrations * :zap: Add temp ts-ignore for unrelated change * :zap: 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. * :zap: 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 * :hammer: Remove mistakenly added nullable property * :fire: Remove unneeded index drop-create (PG) * :fire: Remove unneeded table copying * :zap: Merge dates migration with tags migration * :hammer: 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 * :zap: Update name missing toast message * :zap: 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 * :zap: Update comment * add hover style for workflow open, fix up font weight Co-authored-by: Mutasem Co-authored-by: Iván Ovejero Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> --- .editorconfig | 3 +- packages/cli/config/index.ts | 9 + packages/cli/databases/sqlite/database.sqlite | Bin 32768 -> 0 bytes packages/cli/migrations/ormconfig.ts | 22 +- packages/cli/package.json | 1 + packages/cli/src/ActiveExecutions.ts | 5 +- packages/cli/src/ActiveWorkflowRunner.ts | 2 +- packages/cli/src/Db.ts | 17 +- packages/cli/src/GenericHelpers.ts | 80 +++- packages/cli/src/Interfaces.ts | 59 ++- packages/cli/src/Server.ts | 252 +++++++++--- packages/cli/src/TagHelpers.ts | 112 ++++++ packages/cli/src/WorkflowHelpers.ts | 34 +- .../CredentialsEntity.ts | 22 +- .../ExecutionEntity.ts | 12 +- .../cli/src/databases/entities/TagEntity.ts | 37 ++ .../{mysqldb => entities}/WebhookEntity.ts | 0 .../src/databases/entities/WorkflowEntity.ts | 94 +++++ packages/cli/src/databases/entities/index.ts | 13 + packages/cli/src/databases/index.ts | 9 - .../databases/mysqldb/CredentialsEntity.ts | 44 --- .../src/databases/mysqldb/ExecutionEntity.ts | 52 --- .../src/databases/mysqldb/WorkflowEntity.ts | 55 --- packages/cli/src/databases/mysqldb/index.ts | 4 - .../1617268711084-CreateTagEntity.ts | 50 +++ .../1620826335440-UniqueWorkflowNames.ts | 48 +++ .../src/databases/mysqldb/migrations/index.ts | 4 + .../src/databases/postgresdb/WebhookEntity.ts | 33 -- .../databases/postgresdb/WorkflowEntity.ts | 55 --- .../cli/src/databases/postgresdb/index.ts | 5 - .../1617270242566-CreateTagEntity.ts | 76 ++++ .../1620824779533-UniqueWorkflowNames.ts | 60 +++ .../databases/postgresdb/migrations/index.ts | 4 + .../src/databases/sqlite/CredentialsEntity.ts | 44 --- .../src/databases/sqlite/ExecutionEntity.ts | 52 --- .../cli/src/databases/sqlite/WebhookEntity.ts | 33 -- .../src/databases/sqlite/WorkflowEntity.ts | 55 --- packages/cli/src/databases/sqlite/index.ts | 4 - .../1617213344594-CreateTagEntity.ts | 69 ++++ .../1620821879465-UniqueWorkflowNames.ts | 47 +++ .../src/databases/sqlite/migrations/index.ts | 4 + packages/cli/src/databases/utils.ts | 42 ++ packages/editor-ui/package.json | 4 +- packages/editor-ui/src/Interface.ts | 88 ++++- packages/editor-ui/src/api/helpers.ts | 76 ++++ packages/editor-ui/src/api/tags.ts | 18 + packages/editor-ui/src/api/workflows.ts | 6 + .../src/components/BreakpointsObserver.vue | 101 +++++ .../components/DuplicateWorkflowDialog.vue | 125 ++++++ .../ExpandableInput/ExpandableInputBase.vue | 70 ++++ .../ExpandableInput/ExpandableInputEdit.vue | 64 +++ .../ExpandableInputPreview.vue | 40 ++ .../src/components/InlineTextEdit.vue | 100 +++++ .../src/components/IntersectionObserved.vue | 30 ++ .../src/components/IntersectionObserver.vue | 56 +++ .../editor-ui/src/components/MainHeader.vue | 288 -------------- .../ExecutionDetails/ExecutionDetails.vue | 104 +++++ .../MainHeader/ExecutionDetails/ReadOnly.vue | 13 + .../src/components/MainHeader/MainHeader.vue | 94 +++++ .../components/MainHeader/WorkflowDetails.vue | 279 +++++++++++++ .../editor-ui/src/components/MainSidebar.vue | 99 ++--- packages/editor-ui/src/components/Modal.vue | 115 ++++++ .../editor-ui/src/components/ModalRoot.vue | 24 ++ packages/editor-ui/src/components/Modals.vue | 53 +++ .../src/components/PushConnectionTracker.vue | 29 ++ packages/editor-ui/src/components/RunData.vue | 2 +- .../src/components/SaveWorkflowButton.vue | 65 +++ .../src/components/TagsContainer.vue | 151 +++++++ .../editor-ui/src/components/TagsDropdown.vue | 369 ++++++++++++++++++ .../src/components/TagsManager/NoTagsView.vue | 61 +++ .../components/TagsManager/TagsManager.vue | 190 +++++++++ .../TagsManager/TagsView/TagsTable.vue | 228 +++++++++++ .../TagsManager/TagsView/TagsTableHeader.vue | 60 +++ .../TagsManager/TagsView/TagsView.vue | 180 +++++++++ .../src/components/WorkflowNameShort.vue | 32 ++ .../editor-ui/src/components/WorkflowOpen.vue | 197 +++++++--- .../src/components/mixins/emitter.ts | 40 ++ .../src/components/mixins/externalHooks.ts | 4 +- .../src/components/mixins/genericHelpers.ts | 15 + .../src/components/mixins/pushConnection.ts | 6 +- .../src/components/mixins/restApi.ts | 67 +--- .../src/components/mixins/workflowHelpers.ts | 145 +++---- packages/editor-ui/src/constants.ts | 18 + packages/editor-ui/src/main.ts | 6 + packages/editor-ui/src/modules/tags.ts | 105 +++++ packages/editor-ui/src/modules/ui.ts | 67 ++++ packages/editor-ui/src/modules/workflows.ts | 48 +++ .../editor-ui/src/n8n-theme-variables.scss | 34 +- packages/editor-ui/src/n8n-theme.scss | 40 +- packages/editor-ui/src/router.ts | 2 +- packages/editor-ui/src/store.ts | 153 +++++--- packages/editor-ui/src/views/NodeView.vue | 54 ++- 92 files changed, 4602 insertions(+), 1236 deletions(-) delete mode 100644 packages/cli/databases/sqlite/database.sqlite create mode 100644 packages/cli/src/TagHelpers.ts rename packages/cli/src/databases/{postgresdb => entities}/CredentialsEntity.ts (53%) rename packages/cli/src/databases/{postgresdb => entities}/ExecutionEntity.ts (75%) create mode 100644 packages/cli/src/databases/entities/TagEntity.ts rename packages/cli/src/databases/{mysqldb => entities}/WebhookEntity.ts (100%) create mode 100644 packages/cli/src/databases/entities/WorkflowEntity.ts create mode 100644 packages/cli/src/databases/entities/index.ts delete mode 100644 packages/cli/src/databases/index.ts delete mode 100644 packages/cli/src/databases/mysqldb/CredentialsEntity.ts delete mode 100644 packages/cli/src/databases/mysqldb/ExecutionEntity.ts delete mode 100644 packages/cli/src/databases/mysqldb/WorkflowEntity.ts delete mode 100644 packages/cli/src/databases/mysqldb/index.ts create mode 100644 packages/cli/src/databases/mysqldb/migrations/1617268711084-CreateTagEntity.ts create mode 100644 packages/cli/src/databases/mysqldb/migrations/1620826335440-UniqueWorkflowNames.ts delete mode 100644 packages/cli/src/databases/postgresdb/WebhookEntity.ts delete mode 100644 packages/cli/src/databases/postgresdb/WorkflowEntity.ts delete mode 100644 packages/cli/src/databases/postgresdb/index.ts create mode 100644 packages/cli/src/databases/postgresdb/migrations/1617270242566-CreateTagEntity.ts create mode 100644 packages/cli/src/databases/postgresdb/migrations/1620824779533-UniqueWorkflowNames.ts delete mode 100644 packages/cli/src/databases/sqlite/CredentialsEntity.ts delete mode 100644 packages/cli/src/databases/sqlite/ExecutionEntity.ts delete mode 100644 packages/cli/src/databases/sqlite/WebhookEntity.ts delete mode 100644 packages/cli/src/databases/sqlite/WorkflowEntity.ts delete mode 100644 packages/cli/src/databases/sqlite/index.ts create mode 100644 packages/cli/src/databases/sqlite/migrations/1617213344594-CreateTagEntity.ts create mode 100644 packages/cli/src/databases/sqlite/migrations/1620821879465-UniqueWorkflowNames.ts create mode 100644 packages/cli/src/databases/utils.ts create mode 100644 packages/editor-ui/src/api/helpers.ts create mode 100644 packages/editor-ui/src/api/tags.ts create mode 100644 packages/editor-ui/src/api/workflows.ts create mode 100644 packages/editor-ui/src/components/BreakpointsObserver.vue create mode 100644 packages/editor-ui/src/components/DuplicateWorkflowDialog.vue create mode 100644 packages/editor-ui/src/components/ExpandableInput/ExpandableInputBase.vue create mode 100644 packages/editor-ui/src/components/ExpandableInput/ExpandableInputEdit.vue create mode 100644 packages/editor-ui/src/components/ExpandableInput/ExpandableInputPreview.vue create mode 100644 packages/editor-ui/src/components/InlineTextEdit.vue create mode 100644 packages/editor-ui/src/components/IntersectionObserved.vue create mode 100644 packages/editor-ui/src/components/IntersectionObserver.vue delete mode 100644 packages/editor-ui/src/components/MainHeader.vue create mode 100644 packages/editor-ui/src/components/MainHeader/ExecutionDetails/ExecutionDetails.vue create mode 100644 packages/editor-ui/src/components/MainHeader/ExecutionDetails/ReadOnly.vue create mode 100644 packages/editor-ui/src/components/MainHeader/MainHeader.vue create mode 100644 packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue create mode 100644 packages/editor-ui/src/components/Modal.vue create mode 100644 packages/editor-ui/src/components/ModalRoot.vue create mode 100644 packages/editor-ui/src/components/Modals.vue create mode 100644 packages/editor-ui/src/components/PushConnectionTracker.vue create mode 100644 packages/editor-ui/src/components/SaveWorkflowButton.vue create mode 100644 packages/editor-ui/src/components/TagsContainer.vue create mode 100644 packages/editor-ui/src/components/TagsDropdown.vue create mode 100644 packages/editor-ui/src/components/TagsManager/NoTagsView.vue create mode 100644 packages/editor-ui/src/components/TagsManager/TagsManager.vue create mode 100644 packages/editor-ui/src/components/TagsManager/TagsView/TagsTable.vue create mode 100644 packages/editor-ui/src/components/TagsManager/TagsView/TagsTableHeader.vue create mode 100644 packages/editor-ui/src/components/TagsManager/TagsView/TagsView.vue create mode 100644 packages/editor-ui/src/components/WorkflowNameShort.vue create mode 100644 packages/editor-ui/src/components/mixins/emitter.ts create mode 100644 packages/editor-ui/src/modules/tags.ts create mode 100644 packages/editor-ui/src/modules/ui.ts create mode 100644 packages/editor-ui/src/modules/workflows.ts diff --git a/.editorconfig b/.editorconfig index 5d02a5688b..5ab90f90d7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 \ No newline at end of file +quote_type = single diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index c8d97684d9..0da11e786a 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -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. diff --git a/packages/cli/databases/sqlite/database.sqlite b/packages/cli/databases/sqlite/database.sqlite deleted file mode 100644 index 5250bbe78a4f07d9c6fa3f0b512f6d8e605f3b86..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI(%SyvQ6adgktJNwc-MP(DskqS(@Ieto!ACdJg;a?kw2E3k$?x!+`~blT8W9)n zx^O%bE;D(|kbAPa3^_YFyqT88_1(i{G%YqmH)L7ZE{YIBE5>4s#^-n`_H!}nmeqq_ z`c~N3DmwWvWO+N}?d5~zgZA6U@yjf?2oNAZfB*pk1ZE@9+0Twt*ND(dD>&ydTG^_~Nm=e=2XUex5e>PWwB9elghDJ?s}ho?X2iP0IB+ zb1kYo{}q|}_@Y%*^?g { - 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; } diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index f03b1940c7..c91c872693 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -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 { 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 { 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 { 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 { 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 { collections.Execution = getRepository(entities.ExecutionEntity); collections.Workflow = getRepository(entities.WorkflowEntity); collections.Webhook = getRepository(entities.WebhookEntity); + collections.Tag = getRepository(entities.TagEntity); return collections; } diff --git a/packages/cli/src/GenericHelpers.ts b/packages/cli/src/GenericHelpers.ts index 80b107f2be..d6da9e87da 100644 --- a/packages/cli/src/GenericHelpers.ts +++ b/packages/cli/src/GenericHelpers.ts @@ -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 { 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 { * @returns {(Promise)} */ export async function getConfigValue(configKey: string): Promise { - 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 | null; Execution: Repository | null; - Workflow: Repository | null; + Workflow: Repository | null; Webhook: Repository | null; + Tag: Repository | 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; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index ba4eb543d0..b7ce5394b5 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -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 => { + this.app.post(`/${this.restEndpoint}/workflows`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + 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 => { - const findQuery = {} as FindManyOptions; + this.app.get(`/${this.restEndpoint}/workflows`, ResponseHelper.send(async (req: express.Request, res: express.Response) => { + const findQuery: FindManyOptions = { + 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 => { - 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 => { + 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 => { + this.app.patch(`/${this.restEndpoint}/workflows/:id`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise => { + 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 => { 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 => { + 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 => { + 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 => { + 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 => { + 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 diff --git a/packages/cli/src/TagHelpers.ts b/packages/cli/src/TagHelpers.ts new file mode 100644 index 0000000000..4a9052a300 --- /dev/null +++ b/packages/cli/src/TagHelpers.ts @@ -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 { + 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(); +} diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index 76c264d643..68718dc4cf 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -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 { // 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; + } +}; + diff --git a/packages/cli/src/databases/postgresdb/CredentialsEntity.ts b/packages/cli/src/databases/entities/CredentialsEntity.ts similarity index 53% rename from packages/cli/src/databases/postgresdb/CredentialsEntity.ts rename to packages/cli/src/databases/entities/CredentialsEntity.ts index d2a3f78713..5fd094e85b 100644 --- a/packages/cli/src/databases/postgresdb/CredentialsEntity.ts +++ b/packages/cli/src/databases/entities/CredentialsEntity.ts @@ -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(); + } } diff --git a/packages/cli/src/databases/postgresdb/ExecutionEntity.ts b/packages/cli/src/databases/entities/ExecutionEntity.ts similarity index 75% rename from packages/cli/src/databases/postgresdb/ExecutionEntity.ts rename to packages/cli/src/databases/entities/ExecutionEntity.ts index 901ac9e203..b788f5e153 100644 --- a/packages/cli/src/databases/postgresdb/ExecutionEntity.ts +++ b/packages/cli/src/databases/entities/ExecutionEntity.ts @@ -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() diff --git a/packages/cli/src/databases/entities/TagEntity.ts b/packages/cli/src/databases/entities/TagEntity.ts new file mode 100644 index 0000000000..45b438c9e3 --- /dev/null +++ b/packages/cli/src/databases/entities/TagEntity.ts @@ -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(); + } +} diff --git a/packages/cli/src/databases/mysqldb/WebhookEntity.ts b/packages/cli/src/databases/entities/WebhookEntity.ts similarity index 100% rename from packages/cli/src/databases/mysqldb/WebhookEntity.ts rename to packages/cli/src/databases/entities/WebhookEntity.ts diff --git a/packages/cli/src/databases/entities/WorkflowEntity.ts b/packages/cli/src/databases/entities/WorkflowEntity.ts new file mode 100644 index 0000000000..ac7d294ef5 --- /dev/null +++ b/packages/cli/src/databases/entities/WorkflowEntity.ts @@ -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(); + } +} diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts new file mode 100644 index 0000000000..925ec36d7b --- /dev/null +++ b/packages/cli/src/databases/entities/index.ts @@ -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, +}; diff --git a/packages/cli/src/databases/index.ts b/packages/cli/src/databases/index.ts deleted file mode 100644 index 4cf2fbb0fb..0000000000 --- a/packages/cli/src/databases/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as PostgresDb from './postgresdb'; -import * as SQLite from './sqlite'; -import * as MySQLDb from './mysqldb'; - -export { - PostgresDb, - SQLite, - MySQLDb, -}; diff --git a/packages/cli/src/databases/mysqldb/CredentialsEntity.ts b/packages/cli/src/databases/mysqldb/CredentialsEntity.ts deleted file mode 100644 index 037835fcfa..0000000000 --- a/packages/cli/src/databases/mysqldb/CredentialsEntity.ts +++ /dev/null @@ -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; -} diff --git a/packages/cli/src/databases/mysqldb/ExecutionEntity.ts b/packages/cli/src/databases/mysqldb/ExecutionEntity.ts deleted file mode 100644 index 737e8d6e22..0000000000 --- a/packages/cli/src/databases/mysqldb/ExecutionEntity.ts +++ /dev/null @@ -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; -} diff --git a/packages/cli/src/databases/mysqldb/WorkflowEntity.ts b/packages/cli/src/databases/mysqldb/WorkflowEntity.ts deleted file mode 100644 index ea96195ca2..0000000000 --- a/packages/cli/src/databases/mysqldb/WorkflowEntity.ts +++ /dev/null @@ -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; -} diff --git a/packages/cli/src/databases/mysqldb/index.ts b/packages/cli/src/databases/mysqldb/index.ts deleted file mode 100644 index a3494531db..0000000000 --- a/packages/cli/src/databases/mysqldb/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './CredentialsEntity'; -export * from './ExecutionEntity'; -export * from './WorkflowEntity'; -export * from './WebhookEntity'; diff --git a/packages/cli/src/databases/mysqldb/migrations/1617268711084-CreateTagEntity.ts b/packages/cli/src/databases/mysqldb/migrations/1617268711084-CreateTagEntity.ts new file mode 100644 index 0000000000..54f36959ed --- /dev/null +++ b/packages/cli/src/databases/mysqldb/migrations/1617268711084-CreateTagEntity.ts @@ -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 { + 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 { + 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"); + } + +} diff --git a/packages/cli/src/databases/mysqldb/migrations/1620826335440-UniqueWorkflowNames.ts b/packages/cli/src/databases/mysqldb/migrations/1620826335440-UniqueWorkflowNames.ts new file mode 100644 index 0000000000..defe831fd3 --- /dev/null +++ b/packages/cli/src/databases/mysqldb/migrations/1620826335440-UniqueWorkflowNames.ts @@ -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 { + 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 { + const tablePrefix = config.get('database.tablePrefix'); + + await queryRunner.query('ALTER TABLE `' + tablePrefix + 'workflow_entity` DROP INDEX `IDX_' + tablePrefix + '943d8f922be094eb507cb9a7f9`'); + } + +} diff --git a/packages/cli/src/databases/mysqldb/migrations/index.ts b/packages/cli/src/databases/mysqldb/migrations/index.ts index f5c110fec2..943359a9f8 100644 --- a/packages/cli/src/databases/mysqldb/migrations/index.ts +++ b/packages/cli/src/databases/mysqldb/migrations/index.ts @@ -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, ]; diff --git a/packages/cli/src/databases/postgresdb/WebhookEntity.ts b/packages/cli/src/databases/postgresdb/WebhookEntity.ts deleted file mode 100644 index 515e85f775..0000000000 --- a/packages/cli/src/databases/postgresdb/WebhookEntity.ts +++ /dev/null @@ -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; -} diff --git a/packages/cli/src/databases/postgresdb/WorkflowEntity.ts b/packages/cli/src/databases/postgresdb/WorkflowEntity.ts deleted file mode 100644 index d6d097ef89..0000000000 --- a/packages/cli/src/databases/postgresdb/WorkflowEntity.ts +++ /dev/null @@ -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; -} diff --git a/packages/cli/src/databases/postgresdb/index.ts b/packages/cli/src/databases/postgresdb/index.ts deleted file mode 100644 index bd6b9abd60..0000000000 --- a/packages/cli/src/databases/postgresdb/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './CredentialsEntity'; -export * from './ExecutionEntity'; -export * from './WorkflowEntity'; -export * from './WebhookEntity'; - diff --git a/packages/cli/src/databases/postgresdb/migrations/1617270242566-CreateTagEntity.ts b/packages/cli/src/databases/postgresdb/migrations/1617270242566-CreateTagEntity.ts new file mode 100644 index 0000000000..1e5176d9bc --- /dev/null +++ b/packages/cli/src/databases/postgresdb/migrations/1617270242566-CreateTagEntity.ts @@ -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 { + 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 { + 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)`); + } + +} diff --git a/packages/cli/src/databases/postgresdb/migrations/1620824779533-UniqueWorkflowNames.ts b/packages/cli/src/databases/postgresdb/migrations/1620824779533-UniqueWorkflowNames.ts new file mode 100644 index 0000000000..8c12e24003 --- /dev/null +++ b/packages/cli/src/databases/postgresdb/migrations/1620824779533-UniqueWorkflowNames.ts @@ -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 { + 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 { + 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"`); + + } + +} diff --git a/packages/cli/src/databases/postgresdb/migrations/index.ts b/packages/cli/src/databases/postgresdb/migrations/index.ts index f0ee0b51e1..0f6cc669c9 100644 --- a/packages/cli/src/databases/postgresdb/migrations/index.ts +++ b/packages/cli/src/databases/postgresdb/migrations/index.ts @@ -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, ]; diff --git a/packages/cli/src/databases/sqlite/CredentialsEntity.ts b/packages/cli/src/databases/sqlite/CredentialsEntity.ts deleted file mode 100644 index 8b49d779de..0000000000 --- a/packages/cli/src/databases/sqlite/CredentialsEntity.ts +++ /dev/null @@ -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; -} diff --git a/packages/cli/src/databases/sqlite/ExecutionEntity.ts b/packages/cli/src/databases/sqlite/ExecutionEntity.ts deleted file mode 100644 index 1083994467..0000000000 --- a/packages/cli/src/databases/sqlite/ExecutionEntity.ts +++ /dev/null @@ -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; -} diff --git a/packages/cli/src/databases/sqlite/WebhookEntity.ts b/packages/cli/src/databases/sqlite/WebhookEntity.ts deleted file mode 100644 index 8045880127..0000000000 --- a/packages/cli/src/databases/sqlite/WebhookEntity.ts +++ /dev/null @@ -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; -} diff --git a/packages/cli/src/databases/sqlite/WorkflowEntity.ts b/packages/cli/src/databases/sqlite/WorkflowEntity.ts deleted file mode 100644 index 933a146cac..0000000000 --- a/packages/cli/src/databases/sqlite/WorkflowEntity.ts +++ /dev/null @@ -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; -} diff --git a/packages/cli/src/databases/sqlite/index.ts b/packages/cli/src/databases/sqlite/index.ts deleted file mode 100644 index a3494531db..0000000000 --- a/packages/cli/src/databases/sqlite/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './CredentialsEntity'; -export * from './ExecutionEntity'; -export * from './WorkflowEntity'; -export * from './WebhookEntity'; diff --git a/packages/cli/src/databases/sqlite/migrations/1617213344594-CreateTagEntity.ts b/packages/cli/src/databases/sqlite/migrations/1617213344594-CreateTagEntity.ts new file mode 100644 index 0000000000..4cf8d5685a --- /dev/null +++ b/packages/cli/src/databases/sqlite/migrations/1617213344594-CreateTagEntity.ts @@ -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 { + 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 { + 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") `); + } + +} diff --git a/packages/cli/src/databases/sqlite/migrations/1620821879465-UniqueWorkflowNames.ts b/packages/cli/src/databases/sqlite/migrations/1620821879465-UniqueWorkflowNames.ts new file mode 100644 index 0000000000..2f38cf35bd --- /dev/null +++ b/packages/cli/src/databases/sqlite/migrations/1620821879465-UniqueWorkflowNames.ts @@ -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 { + 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 { + const tablePrefix = config.get('database.tablePrefix'); + await queryRunner.query(`DROP INDEX "IDX_${tablePrefix}943d8f922be094eb507cb9a7f9"`); + } + +} diff --git a/packages/cli/src/databases/sqlite/migrations/index.ts b/packages/cli/src/databases/sqlite/migrations/index.ts index 7a9c53b376..14b5184954 100644 --- a/packages/cli/src/databases/sqlite/migrations/index.ts +++ b/packages/cli/src/databases/sqlite/migrations/index.ts @@ -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, ]; diff --git a/packages/cli/src/databases/utils.ts b/packages/cli/src/databases/utils.ts new file mode 100644 index 0000000000..e0a833522e --- /dev/null +++ b/packages/cli/src/databases/utils.ts @@ -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]; +} + diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index b86a1f9a4e..c14f62e774 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -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", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 4fdbd770f8..799540c1e8 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -134,7 +134,7 @@ export interface IRestApi { getNodeParameterOptions(nodeType: string, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise; removeTestWebhook(workflowId: string): Promise; runWorkflow(runData: IStartRunData): Promise; - createNewWorkflow(sendData: IWorkflowData): Promise; + createNewWorkflow(sendData: IWorkflowDataUpdate): Promise; updateWorkflow(id: string, data: IWorkflowDataUpdate): Promise; deleteWorkflow(name: string): Promise; getWorkflow(id: string): Promise; @@ -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; + 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; +} diff --git a/packages/editor-ui/src/api/helpers.ts b/packages/editor-ui/src/api/helpers.ts new file mode 100644 index 0000000000..ea4d16e10c --- /dev/null +++ b/packages/editor-ui/src/api/helpers.ts @@ -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; + } +} \ No newline at end of file diff --git a/packages/editor-ui/src/api/tags.ts b/packages/editor-ui/src/api/tags.ts new file mode 100644 index 0000000000..8e12b5e13f --- /dev/null +++ b/packages/editor-ui/src/api/tags.ts @@ -0,0 +1,18 @@ +import { IRestApiContext, ITag } from '@/Interface'; +import { makeRestApiRequest } from './helpers'; + +export async function getTags(context: IRestApiContext, withUsageCount = false): Promise { + return await makeRestApiRequest(context, 'GET', '/tags', { withUsageCount }); +} + +export async function createTag(context: IRestApiContext, params: { name: string }): Promise { + return await makeRestApiRequest(context, 'POST', '/tags', params); +} + +export async function updateTag(context: IRestApiContext, id: string, params: { name: string }): Promise { + return await makeRestApiRequest(context, 'PATCH', `/tags/${id}`, params); +} + +export async function deleteTag(context: IRestApiContext, id: string): Promise { + return await makeRestApiRequest(context, 'DELETE', `/tags/${id}`); +} diff --git a/packages/editor-ui/src/api/workflows.ts b/packages/editor-ui/src/api/workflows.ts new file mode 100644 index 0000000000..b8cf319efd --- /dev/null +++ b/packages/editor-ui/src/api/workflows.ts @@ -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 } : {}); +} \ No newline at end of file diff --git a/packages/editor-ui/src/components/BreakpointsObserver.vue b/packages/editor-ui/src/components/BreakpointsObserver.vue new file mode 100644 index 0000000000..808dc3917b --- /dev/null +++ b/packages/editor-ui/src/components/BreakpointsObserver.vue @@ -0,0 +1,101 @@ + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/DuplicateWorkflowDialog.vue b/packages/editor-ui/src/components/DuplicateWorkflowDialog.vue new file mode 100644 index 0000000000..839e00efac --- /dev/null +++ b/packages/editor-ui/src/components/DuplicateWorkflowDialog.vue @@ -0,0 +1,125 @@ + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/ExpandableInput/ExpandableInputBase.vue b/packages/editor-ui/src/components/ExpandableInput/ExpandableInputBase.vue new file mode 100644 index 0000000000..a1e65affad --- /dev/null +++ b/packages/editor-ui/src/components/ExpandableInput/ExpandableInputBase.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/packages/editor-ui/src/components/ExpandableInput/ExpandableInputEdit.vue b/packages/editor-ui/src/components/ExpandableInput/ExpandableInputEdit.vue new file mode 100644 index 0000000000..bed1a0c495 --- /dev/null +++ b/packages/editor-ui/src/components/ExpandableInput/ExpandableInputEdit.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/packages/editor-ui/src/components/ExpandableInput/ExpandableInputPreview.vue b/packages/editor-ui/src/components/ExpandableInput/ExpandableInputPreview.vue new file mode 100644 index 0000000000..832b75011b --- /dev/null +++ b/packages/editor-ui/src/components/ExpandableInput/ExpandableInputPreview.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/packages/editor-ui/src/components/InlineTextEdit.vue b/packages/editor-ui/src/components/InlineTextEdit.vue new file mode 100644 index 0000000000..b52fbb7a53 --- /dev/null +++ b/packages/editor-ui/src/components/InlineTextEdit.vue @@ -0,0 +1,100 @@ + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/IntersectionObserved.vue b/packages/editor-ui/src/components/IntersectionObserved.vue new file mode 100644 index 0000000000..386944a30e --- /dev/null +++ b/packages/editor-ui/src/components/IntersectionObserved.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/editor-ui/src/components/IntersectionObserver.vue b/packages/editor-ui/src/components/IntersectionObserver.vue new file mode 100644 index 0000000000..c9c6abc597 --- /dev/null +++ b/packages/editor-ui/src/components/IntersectionObserver.vue @@ -0,0 +1,56 @@ + + + + diff --git a/packages/editor-ui/src/components/MainHeader.vue b/packages/editor-ui/src/components/MainHeader.vue deleted file mode 100644 index 97a4a55f53..0000000000 --- a/packages/editor-ui/src/components/MainHeader.vue +++ /dev/null @@ -1,288 +0,0 @@ - - - - - - - diff --git a/packages/editor-ui/src/components/MainHeader/ExecutionDetails/ExecutionDetails.vue b/packages/editor-ui/src/components/MainHeader/ExecutionDetails/ExecutionDetails.vue new file mode 100644 index 0000000000..ff409c58f8 --- /dev/null +++ b/packages/editor-ui/src/components/MainHeader/ExecutionDetails/ExecutionDetails.vue @@ -0,0 +1,104 @@ + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/MainHeader/ExecutionDetails/ReadOnly.vue b/packages/editor-ui/src/components/MainHeader/ExecutionDetails/ReadOnly.vue new file mode 100644 index 0000000000..f94558a963 --- /dev/null +++ b/packages/editor-ui/src/components/MainHeader/ExecutionDetails/ReadOnly.vue @@ -0,0 +1,13 @@ + \ No newline at end of file diff --git a/packages/editor-ui/src/components/MainHeader/MainHeader.vue b/packages/editor-ui/src/components/MainHeader/MainHeader.vue new file mode 100644 index 0000000000..552ce2b30e --- /dev/null +++ b/packages/editor-ui/src/components/MainHeader/MainHeader.vue @@ -0,0 +1,94 @@ + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue new file mode 100644 index 0000000000..4162435a71 --- /dev/null +++ b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue @@ -0,0 +1,279 @@ + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index d6c53be80e..316dc16879 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -4,12 +4,11 @@ -
-
+
@@ -41,22 +40,16 @@ Open - + - + - - - @@ -143,7 +136,6 @@ @@ -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; } } diff --git a/packages/editor-ui/src/components/Modal.vue b/packages/editor-ui/src/components/Modal.vue new file mode 100644 index 0000000000..274d6fc942 --- /dev/null +++ b/packages/editor-ui/src/components/Modal.vue @@ -0,0 +1,115 @@ + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/ModalRoot.vue b/packages/editor-ui/src/components/ModalRoot.vue new file mode 100644 index 0000000000..aeae24e2fb --- /dev/null +++ b/packages/editor-ui/src/components/ModalRoot.vue @@ -0,0 +1,24 @@ + + + diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue new file mode 100644 index 0000000000..464936c33d --- /dev/null +++ b/packages/editor-ui/src/components/Modals.vue @@ -0,0 +1,53 @@ + + + diff --git a/packages/editor-ui/src/components/PushConnectionTracker.vue b/packages/editor-ui/src/components/PushConnectionTracker.vue new file mode 100644 index 0000000000..06c5662823 --- /dev/null +++ b/packages/editor-ui/src/components/PushConnectionTracker.vue @@ -0,0 +1,29 @@ + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/RunData.vue b/packages/editor-ui/src/components/RunData.vue index da72d6ed1b..41a2b911fe 100644 --- a/packages/editor-ui/src/components/RunData.vue +++ b/packages/editor-ui/src/components/RunData.vue @@ -762,7 +762,7 @@ export default mixins( background: #fff;; } tr:nth-child(odd) { - background: $--custom-table-background-alternative; + background: $--custom-table-background-stripe-color; } } } diff --git a/packages/editor-ui/src/components/SaveWorkflowButton.vue b/packages/editor-ui/src/components/SaveWorkflowButton.vue new file mode 100644 index 0000000000..c62cb3712c --- /dev/null +++ b/packages/editor-ui/src/components/SaveWorkflowButton.vue @@ -0,0 +1,65 @@ + + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/TagsContainer.vue b/packages/editor-ui/src/components/TagsContainer.vue new file mode 100644 index 0000000000..12c6aebc41 --- /dev/null +++ b/packages/editor-ui/src/components/TagsContainer.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/packages/editor-ui/src/components/TagsDropdown.vue b/packages/editor-ui/src/components/TagsDropdown.vue new file mode 100644 index 0000000000..5afc839159 --- /dev/null +++ b/packages/editor-ui/src/components/TagsDropdown.vue @@ -0,0 +1,369 @@ + + + + + + + diff --git a/packages/editor-ui/src/components/TagsManager/NoTagsView.vue b/packages/editor-ui/src/components/TagsManager/NoTagsView.vue new file mode 100644 index 0000000000..d12fcb0eb2 --- /dev/null +++ b/packages/editor-ui/src/components/TagsManager/NoTagsView.vue @@ -0,0 +1,61 @@ + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/TagsManager/TagsManager.vue b/packages/editor-ui/src/components/TagsManager/TagsManager.vue new file mode 100644 index 0000000000..f6fb4f45a5 --- /dev/null +++ b/packages/editor-ui/src/components/TagsManager/TagsManager.vue @@ -0,0 +1,190 @@ + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/TagsManager/TagsView/TagsTable.vue b/packages/editor-ui/src/components/TagsManager/TagsView/TagsTable.vue new file mode 100644 index 0000000000..6afb3b6e0d --- /dev/null +++ b/packages/editor-ui/src/components/TagsManager/TagsView/TagsTable.vue @@ -0,0 +1,228 @@ + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/TagsManager/TagsView/TagsTableHeader.vue b/packages/editor-ui/src/components/TagsManager/TagsView/TagsTableHeader.vue new file mode 100644 index 0000000000..3977104eab --- /dev/null +++ b/packages/editor-ui/src/components/TagsManager/TagsView/TagsTableHeader.vue @@ -0,0 +1,60 @@ + + + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/TagsManager/TagsView/TagsView.vue b/packages/editor-ui/src/components/TagsManager/TagsView/TagsView.vue new file mode 100644 index 0000000000..71a801f6a0 --- /dev/null +++ b/packages/editor-ui/src/components/TagsManager/TagsView/TagsView.vue @@ -0,0 +1,180 @@ + + + \ No newline at end of file diff --git a/packages/editor-ui/src/components/WorkflowNameShort.vue b/packages/editor-ui/src/components/WorkflowNameShort.vue new file mode 100644 index 0000000000..eb9b73c063 --- /dev/null +++ b/packages/editor-ui/src/components/WorkflowNameShort.vue @@ -0,0 +1,32 @@ + + + diff --git a/packages/editor-ui/src/components/WorkflowOpen.vue b/packages/editor-ui/src/components/WorkflowOpen.vue index 6d7a1efe8f..bc2d7079a6 100644 --- a/packages/editor-ui/src/components/WorkflowOpen.vue +++ b/packages/editor-ui/src/components/WorkflowOpen.vue @@ -1,44 +1,68 @@ diff --git a/packages/editor-ui/src/components/mixins/emitter.ts b/packages/editor-ui/src/components/mixins/emitter.ts new file mode 100644 index 0000000000..0df8bcd534 --- /dev/null +++ b/packages/editor-ui/src/components/mixins/emitter.ts @@ -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); + }, + }, +}); \ No newline at end of file diff --git a/packages/editor-ui/src/components/mixins/externalHooks.ts b/packages/editor-ui/src/components/mixins/externalHooks.ts index 0729cce732..6bb4d0b951 100644 --- a/packages/editor-ui/src/components/mixins/externalHooks.ts +++ b/packages/editor-ui/src/components/mixins/externalHooks.ts @@ -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, + store: Store, metadata?: IDataObject, ) { // @ts-ignore diff --git a/packages/editor-ui/src/components/mixins/genericHelpers.ts b/packages/editor-ui/src/components/mixins/genericHelpers.ts index 9fadcc7a7c..fe943beb82 100644 --- a/packages/editor-ui/src/components/mixins/genericHelpers.ts +++ b/packages/editor-ui/src/components/mixins/genericHelpers.ts @@ -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 { // 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 { try { await this.$confirm(message, headline, { diff --git a/packages/editor-ui/src/components/mixins/pushConnection.ts b/packages/editor-ui/src/components/mixins/pushConnection.ts index 5c23dd6cda..6a80f67920 100644 --- a/packages/editor-ui/src/components/mixins/pushConnection.ts +++ b/packages/editor-ui/src/components/mixins/pushConnection.ts @@ -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!', diff --git a/packages/editor-ui/src/components/mixins/restApi.ts b/packages/editor-ui/src/components/mixins/restApi.ts index f51df9183e..2d77f036b3 100644 --- a/packages/editor-ui/src/components/mixins/restApi.ts +++ b/packages/editor-ui/src/components/mixins/restApi.ts @@ -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 { // 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 => { return self.restApi().makeRestApiRequest('GET', `/active`); @@ -179,7 +118,7 @@ export const restApi = Vue.extend({ }, // Creates new credentials - createNewWorkflow: (sendData: IWorkflowData): Promise => { + createNewWorkflow: (sendData: IWorkflowDataUpdate): Promise => { return self.restApi().makeRestApiRequest('POST', `/workflows`, sendData); }, diff --git a/packages/editor-ui/src/components/mixins/workflowHelpers.ts b/packages/editor-ui/src/components/mixins/workflowHelpers.ts index c7cc565ee9..ec4acf820c 100644 --- a/packages/editor-ui/src/components/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/components/mixins/workflowHelpers.ts @@ -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 { 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 { + 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; } }, diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 22ec0a5198..925e990c82 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -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; + diff --git a/packages/editor-ui/src/main.ts b/packages/editor-ui/src/main.ts index bc982d7aa4..ae0179abe4 100644 --- a/packages/editor-ui/src/main.ts +++ b/packages/editor-ui/src/main.ts @@ -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); diff --git a/packages/editor-ui/src/modules/tags.ts b/packages/editor-ui/src/modules/tags.ts new file mode 100644 index 0000000000..f0fbde557c --- /dev/null +++ b/packages/editor-ui/src/modules/tags.ts @@ -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 = { + 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, 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, name: string) => { + const tag = await createTag(context.rootGetters.getRestApiContext, { name }); + context.commit('upsertTags', [tag]); + + return tag; + }, + rename: async (context: ActionContext, { 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, 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; \ No newline at end of file diff --git a/packages/editor-ui/src/modules/ui.ts b/packages/editor-ui/src/modules/ui.ts new file mode 100644 index 0000000000..9fbe35fb37 --- /dev/null +++ b/packages/editor-ui/src/modules/ui.ts @@ -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 = { + 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) => { + context.commit('openModal', TAGS_MANAGER_MODAL_KEY); + }, + openWorklfowOpenModal: async (context: ActionContext) => { + context.commit('openModal', WORKLOW_OPEN_MODAL_KEY); + }, + openDuplicateModal: async (context: ActionContext) => { + context.commit('openModal', DUPLICATE_MODAL_KEY); + }, + }, +}; + +export default module; \ No newline at end of file diff --git a/packages/editor-ui/src/modules/workflows.ts b/packages/editor-ui/src/modules/workflows.ts new file mode 100644 index 0000000000..8292318052 --- /dev/null +++ b/packages/editor-ui/src/modules/workflows.ts @@ -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 = { + namespaced: true, + state: {}, + actions: { + setNewWorkflowName: async (context: ActionContext): Promise => { + 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): Promise => { + 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; \ No newline at end of file diff --git a/packages/editor-ui/src/n8n-theme-variables.scss b/packages/editor-ui/src/n8n-theme-variables.scss index 6561ffe922..7f85711206 100644 --- a/packages/editor-ui/src/n8n-theme-variables.scss +++ b/packages/editor-ui/src/n8n-theme-variables.scss @@ -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; diff --git a/packages/editor-ui/src/n8n-theme.scss b/packages/editor-ui/src/n8n-theme.scss index 65610ef8c1..a32a6d3d79 100644 --- a/packages/editor-ui/src/n8n-theme.scss +++ b/packages/editor-ui/src/n8n-theme.scss @@ -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; + } + } + } +} + diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 348a1ea66b..18551097f3 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -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'; diff --git a/packages/editor-ui/src/store.ts b/packages/editor-ui/src/store.ts index 0309075d68..bf2ce03737 100644 --- a/packages/editor-ui/src/store.ts +++ b/packages/editor-ui/src/store.ts @@ -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, - 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: { - -// } -// }); diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 482530cffd..0bc1275d26 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -38,7 +38,7 @@ @nodeTypeSelected="nodeTypeSelected" @closeNodeCreator="closeNodeCreator" > -
+
@@ -102,6 +102,7 @@
+
@@ -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 { // 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 { 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(