mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-10 06:34:05 -08:00
Merge branch 'n8n-io:master' into Add-schema-registry-into-kafka
This commit is contained in:
commit
e253523dbe
|
@ -2,7 +2,6 @@
|
|||
|
||||
Great that you are here and you want to contribute to n8n
|
||||
|
||||
|
||||
## Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
|
@ -15,16 +14,14 @@ Great that you are here and you want to contribute to n8n
|
|||
- [Extend Documentation](#extend-documentation)
|
||||
- [Contributor License Agreement](#contributor-license-agreement)
|
||||
|
||||
|
||||
## Code of Conduct
|
||||
## Code of conduct
|
||||
|
||||
This project and everyone participating in it are governed by the Code of
|
||||
Conduct which can be found in the file [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
|
||||
By participating, you are expected to uphold this code. Please report
|
||||
unacceptable behavior to jan@n8n.io.
|
||||
|
||||
|
||||
## Directory Structure
|
||||
## Directory structure
|
||||
|
||||
n8n is split up in different modules which are all in a single mono repository.
|
||||
|
||||
|
@ -36,7 +33,8 @@ The most important directories:
|
|||
- [/packages/cli](/packages/cli) - CLI code to run front- & backend
|
||||
- [/packages/core](/packages/core) - Core code which handles workflow
|
||||
execution, active webhooks and
|
||||
workflows
|
||||
workflows. **Contact n8n before
|
||||
starting on any changes here**
|
||||
- [/packages/design-system](/packages/design-system) - Vue frontend components
|
||||
- [/packages/editor-ui](/packages/editor-ui) - Vue frontend workflow editor
|
||||
- [/packages/node-dev](/packages/node-dev) - CLI to create new n8n-nodes
|
||||
|
@ -44,17 +42,14 @@ The most important directories:
|
|||
- [/packages/workflow](/packages/workflow) - Workflow code with interfaces which
|
||||
get used by front- & backend
|
||||
|
||||
|
||||
## Development Setup
|
||||
## Development setup
|
||||
|
||||
If you want to change or extend n8n you have to make sure that all needed
|
||||
dependencies are installed and the packages get linked correctly. Here a short guide on how that can be done:
|
||||
|
||||
|
||||
### Requirements
|
||||
|
||||
|
||||
#### Build Tools
|
||||
#### Build tools
|
||||
|
||||
The packages which n8n uses depend on a few build tools:
|
||||
|
||||
|
@ -86,18 +81,23 @@ So for the setup to work correctly lerna has to be installed globally like this:
|
|||
npm install -g lerna
|
||||
```
|
||||
|
||||
|
||||
### Actual n8n setup
|
||||
|
||||
> **IMPORTANT**: All the steps bellow have to get executed at least once to get the development setup up and running!
|
||||
|
||||
|
||||
Now that everything n8n requires to run is installed the actual n8n code can be
|
||||
checked out and set up:
|
||||
|
||||
1. Clone the repository
|
||||
1. [Fork](https://guides.github.com/activities/forking/#fork) the n8n repository
|
||||
|
||||
1. Clone your forked repository
|
||||
```
|
||||
git clone https://github.com/n8n-io/n8n.git
|
||||
git clone https://github.com/<your_github_username>/n8n.git
|
||||
```
|
||||
|
||||
1. Add the original n8n repository as `upstream` to your forked repository
|
||||
```
|
||||
git remote add upstream https://github.com/n8n-io/n8n.git
|
||||
```
|
||||
|
||||
1. Go into repository folder
|
||||
|
@ -115,8 +115,6 @@ checked out and set up:
|
|||
npm run build
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Start
|
||||
|
||||
To start n8n execute:
|
||||
|
@ -130,7 +128,7 @@ To start n8n with tunnel:
|
|||
./packages/cli/bin/n8n start --tunnel
|
||||
```
|
||||
|
||||
## Development Cycle
|
||||
## Development cycle
|
||||
|
||||
While iterating on n8n modules code, you can run `npm run dev`. It will then
|
||||
automatically build your code, restart the backend and refresh the frontend
|
||||
|
@ -147,12 +145,11 @@ automatically build your code, restart the backend and refresh the frontend
|
|||
npm run start
|
||||
```
|
||||
1. Create tests
|
||||
1. Run all tests
|
||||
1. Run all [tests](#test-suite)
|
||||
```
|
||||
npm run test
|
||||
```
|
||||
1. Commit code and create pull request
|
||||
|
||||
1. Commit code and [create a pull request](https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork)
|
||||
|
||||
### Test suite
|
||||
|
||||
|
@ -165,34 +162,26 @@ If that gets executed in one of the package folders it will only run the tests
|
|||
of this package. If it gets executed in the n8n-root folder it will run all
|
||||
tests of all packages.
|
||||
|
||||
## Create custom nodes
|
||||
|
||||
|
||||
## Create Custom Nodes
|
||||
> **IMPORTANT**: Avoid use of external libraries to ensure your custom nodes can be reviewed and merged quickly.
|
||||
|
||||
Learn about [using the node dev CLI](https://docs.n8n.io/nodes/creating-nodes/node-dev-cli.html) to create custom nodes for n8n.
|
||||
|
||||
More information can
|
||||
be found in the documentation of [n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev), which is a small CLI which
|
||||
helps with n8n-node-development.
|
||||
|
||||
|
||||
More information can be found in the documentation of [n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev), a small CLI which helps with n8n-node-development.
|
||||
|
||||
## Create a new node to contribute to n8n
|
||||
|
||||
Follow this tutorial on [creating your first node](https://docs.n8n.io/nodes/creating-nodes/create-node.html) for n8n.
|
||||
|
||||
|
||||
|
||||
## Checklist before submitting a new node
|
||||
|
||||
There are several things to keep in mind when creating a node. To help you, we prepared a [checklist](https://docs.n8n.io/nodes/creating-nodes/node-review-checklist.html) that covers the requirements for creating nodes, from preparation to submission. This will help us be quicker to review and merge your PR.
|
||||
|
||||
|
||||
## Extend Documentation
|
||||
## Extend documentation
|
||||
|
||||
The repository for the n8n documentation on [docs.n8n.io](https://docs.n8n.io) can be found [here](https://github.com/n8n-io/n8n-docs).
|
||||
|
||||
|
||||
## Contributor License Agreement
|
||||
|
||||
That we do not have any potential problems later it is sadly necessary to sign a [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md). That can be done literally with the push of a button.
|
||||
|
|
|
@ -8,8 +8,8 @@
|
|||
"dev": "lerna exec npm run dev --parallel",
|
||||
"clean:dist": "lerna exec -- rimraf ./dist",
|
||||
"format": "lerna exec npm run format",
|
||||
"lint": "lerna exec npm run lint",
|
||||
"lintfix": "lerna exec npm run lintfix",
|
||||
"lint": "lerna exec npm run lint",
|
||||
"lintfix": "lerna exec npm run lintfix",
|
||||
"optimize-svg": "find ./packages -name '*.svg' ! -name 'pipedrive.svg' -print0 | xargs -0 -P16 -L20 npx svgo",
|
||||
"start": "run-script-os",
|
||||
"start:default": "cd packages/cli/bin && ./n8n",
|
||||
|
|
|
@ -2,6 +2,20 @@
|
|||
|
||||
This list shows all the versions which include breaking changes and how to upgrade.
|
||||
|
||||
## 0.139.0
|
||||
|
||||
### What changed?
|
||||
|
||||
For the HubSpot Trigger node, the authentication process has changed to OAuth2.
|
||||
|
||||
### When is action necessary?
|
||||
|
||||
If you are using the Hubspot Trigger.
|
||||
|
||||
### How to upgrade:
|
||||
|
||||
Create an app in HubSpot, use the Client ID, Client Secret, App ID, and the Developer Key, and complete the OAuth2 flow.
|
||||
|
||||
## 0.135.0
|
||||
|
||||
### What changed?
|
||||
|
|
|
@ -170,6 +170,7 @@ export class ExecuteBatch extends Command {
|
|||
'missing a required parameter',
|
||||
'insufficient credit balance',
|
||||
'request timed out',
|
||||
'status code 401',
|
||||
];
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
|
|
|
@ -148,6 +148,12 @@ const config = convict({
|
|||
env: 'CREDENTIALS_OVERWRITE_ENDPOINT',
|
||||
},
|
||||
},
|
||||
defaultName: {
|
||||
doc: 'Default name for credentials',
|
||||
format: String,
|
||||
default: 'My credentials',
|
||||
env: 'CREDENTIALS_DEFAULT_NAME',
|
||||
},
|
||||
},
|
||||
|
||||
workflows: {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"version": "0.136.0",
|
||||
"version": "0.139.1",
|
||||
"description": "n8n Workflow Automation Tool",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"homepage": "https://n8n.io",
|
||||
|
@ -88,7 +88,7 @@
|
|||
"basic-auth": "^2.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"body-parser": "^1.18.3",
|
||||
"body-parser-xml": "^1.1.0",
|
||||
"body-parser-xml": "^2.0.3",
|
||||
"bull": "^3.19.0",
|
||||
"callsites": "^3.1.0",
|
||||
"class-validator": "^0.13.1",
|
||||
|
@ -109,10 +109,10 @@
|
|||
"localtunnel": "^2.0.0",
|
||||
"lodash.get": "^4.4.2",
|
||||
"mysql2": "~2.3.0",
|
||||
"n8n-core": "~0.81.0",
|
||||
"n8n-editor-ui": "~0.104.0",
|
||||
"n8n-nodes-base": "~0.133.0",
|
||||
"n8n-workflow": "~0.66.0",
|
||||
"n8n-core": "~0.84.0",
|
||||
"n8n-editor-ui": "~0.107.1",
|
||||
"n8n-nodes-base": "~0.136.0",
|
||||
"n8n-workflow": "~0.70.0",
|
||||
"oauth-1.0a": "^2.2.6",
|
||||
"open": "^7.0.0",
|
||||
"pg": "^8.3.0",
|
||||
|
|
|
@ -46,6 +46,7 @@ import {
|
|||
WorkflowHelpers,
|
||||
WorkflowRunner,
|
||||
} from '.';
|
||||
import config = require('../config');
|
||||
|
||||
const WEBHOOK_PROD_UNREGISTERED_HINT = `The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)`;
|
||||
|
||||
|
@ -67,8 +68,17 @@ export class ActiveWorkflowRunner {
|
|||
active: true,
|
||||
})) as IWorkflowDb[];
|
||||
|
||||
// Clear up active workflow table
|
||||
await Db.collections.Webhook?.clear();
|
||||
if (!config.get('endpoints.skipWebhoooksDeregistrationOnShutdown')) {
|
||||
// Do not clean up database when skip registration is done.
|
||||
// This flag is set when n8n is running in scaled mode.
|
||||
// Impact is minimal, but for a short while, n8n will stop accepting requests.
|
||||
// Also, users had issues when running multiple "main process"
|
||||
// instances if many of them start at the same time
|
||||
// This is not officially supported but there is no reason
|
||||
// it should not work.
|
||||
// Clear up active workflow table
|
||||
await Db.collections.Webhook?.clear();
|
||||
}
|
||||
|
||||
this.activeWorkflows = new ActiveWorkflows();
|
||||
|
||||
|
@ -426,6 +436,20 @@ export class ActiveWorkflowRunner {
|
|||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
activation === 'init' &&
|
||||
config.get('endpoints.skipWebhoooksDeregistrationOnShutdown') &&
|
||||
error.name === 'QueryFailedError'
|
||||
) {
|
||||
// When skipWebhoooksDeregistrationOnShutdown is enabled,
|
||||
// n8n does not remove the registered webhooks on exit.
|
||||
// This means that further initializations will always fail
|
||||
// when inserting to database. This is why we ignore this error
|
||||
// as it's expected to happen.
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.removeWorkflowWebhooks(workflow.id as string);
|
||||
} catch (error) {
|
||||
|
|
|
@ -30,6 +30,9 @@ const mockNodeTypes: INodeTypes = {
|
|||
getByName: (nodeType: string): INodeType | undefined => {
|
||||
return undefined;
|
||||
},
|
||||
getByNameAndVersion: (): INodeType | undefined => {
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
export class CredentialsHelper extends ICredentialsHelper {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
@ -11,7 +12,11 @@ import { IDataObject } from 'n8n-workflow';
|
|||
import * as config from '../config';
|
||||
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import { IPackageVersions } from '.';
|
||||
import { Db, ICredentialsDb, IPackageVersions } from '.';
|
||||
// eslint-disable-next-line import/order
|
||||
import { Like } from 'typeorm';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import { WorkflowEntity } from './databases/entities/WorkflowEntity';
|
||||
|
||||
let versionCache: IPackageVersions | undefined;
|
||||
|
||||
|
@ -170,3 +175,56 @@ export function getConfigValueSync(configKey: string): string | boolean | number
|
|||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique name for a workflow or credentials entity.
|
||||
*
|
||||
* - If the name does not yet exist, it returns the requested name.
|
||||
* - If the name already exists once, it returns the requested name suffixed with 2.
|
||||
* - If the name already exists more than once with suffixes, it looks for the max suffix
|
||||
* and returns the requested name with max suffix + 1.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export async function generateUniqueName(
|
||||
requestedName: string,
|
||||
entityType: 'workflow' | 'credentials',
|
||||
) {
|
||||
const findConditions = {
|
||||
select: ['name' as const],
|
||||
where: {
|
||||
name: Like(`${requestedName}%`),
|
||||
},
|
||||
};
|
||||
|
||||
const found: Array<WorkflowEntity | ICredentialsDb> =
|
||||
entityType === 'workflow'
|
||||
? await Db.collections.Workflow!.find(findConditions)
|
||||
: await Db.collections.Credentials!.find(findConditions);
|
||||
|
||||
// name is unique
|
||||
if (found.length === 0) {
|
||||
return { name: requestedName };
|
||||
}
|
||||
|
||||
const maxSuffix = found.reduce((acc, { name }) => {
|
||||
const parts = name.split(`${requestedName} `);
|
||||
|
||||
if (parts.length > 2) return acc;
|
||||
|
||||
const suffix = Number(parts[1]);
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
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: `${requestedName} 2` };
|
||||
}
|
||||
|
||||
return { name: `${requestedName} ${maxSuffix + 1}` };
|
||||
}
|
||||
|
|
|
@ -121,6 +121,7 @@ export interface ICredentialsBase {
|
|||
|
||||
export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted {
|
||||
id: number | string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ICredentialsResponse extends ICredentialsDb {
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
ILogger,
|
||||
INodeType,
|
||||
INodeTypeData,
|
||||
INodeVersionedType,
|
||||
LoggerProxy,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
|
@ -151,6 +152,14 @@ class LoadNodesAndCredentialsClass {
|
|||
let tempCredential: ICredentialType;
|
||||
try {
|
||||
tempCredential = new tempModule[credentialName]() as ICredentialType;
|
||||
|
||||
if (tempCredential.icon && tempCredential.icon.startsWith('file:')) {
|
||||
// If a file icon gets used add the full path
|
||||
tempCredential.icon = `file:${path.join(
|
||||
path.dirname(filePath),
|
||||
tempCredential.icon.substr(5),
|
||||
)}`;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof TypeError) {
|
||||
throw new Error(
|
||||
|
@ -173,13 +182,14 @@ class LoadNodesAndCredentialsClass {
|
|||
* @returns {Promise<void>}
|
||||
*/
|
||||
async loadNodeFromFile(packageName: string, nodeName: string, filePath: string): Promise<void> {
|
||||
let tempNode: INodeType;
|
||||
let tempNode: INodeType | INodeVersionedType;
|
||||
let fullNodeName: string;
|
||||
|
||||
// eslint-disable-next-line import/no-dynamic-require, global-require, @typescript-eslint/no-var-requires
|
||||
const tempModule = require(filePath);
|
||||
|
||||
try {
|
||||
tempNode = new tempModule[nodeName]() as INodeType;
|
||||
tempNode = new tempModule[nodeName]();
|
||||
this.addCodex({ node: tempNode, filePath, isCustom: packageName === 'CUSTOM' });
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
@ -199,13 +209,36 @@ class LoadNodesAndCredentialsClass {
|
|||
)}`;
|
||||
}
|
||||
|
||||
if (tempNode.executeSingle) {
|
||||
if (tempNode.hasOwnProperty('executeSingle')) {
|
||||
this.logger.warn(
|
||||
`"executeSingle" will get deprecated soon. Please update the code of node "${packageName}.${nodeName}" to use "execute" instead!`,
|
||||
{ filePath },
|
||||
);
|
||||
}
|
||||
|
||||
if (tempNode.hasOwnProperty('nodeVersions')) {
|
||||
const versionedNodeType = (tempNode as INodeVersionedType).getNodeType();
|
||||
this.addCodex({ node: versionedNodeType, filePath, isCustom: packageName === 'CUSTOM' });
|
||||
|
||||
if (
|
||||
versionedNodeType.description.icon !== undefined &&
|
||||
versionedNodeType.description.icon.startsWith('file:')
|
||||
) {
|
||||
// If a file icon gets used add the full path
|
||||
versionedNodeType.description.icon = `file:${path.join(
|
||||
path.dirname(filePath),
|
||||
versionedNodeType.description.icon.substr(5),
|
||||
)}`;
|
||||
}
|
||||
|
||||
if (versionedNodeType.hasOwnProperty('executeSingle')) {
|
||||
this.logger.warn(
|
||||
`"executeSingle" will get deprecated soon. Please update the code of node "${packageName}.${nodeName}" to use "execute" instead!`,
|
||||
{ filePath },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.includeNodes !== undefined && !this.includeNodes.includes(fullNodeName)) {
|
||||
return;
|
||||
}
|
||||
|
@ -249,7 +282,15 @@ class LoadNodesAndCredentialsClass {
|
|||
* @param obj.isCustom Whether the node is custom
|
||||
* @returns {void}
|
||||
*/
|
||||
addCodex({ node, filePath, isCustom }: { node: INodeType; filePath: string; isCustom: boolean }) {
|
||||
addCodex({
|
||||
node,
|
||||
filePath,
|
||||
isCustom,
|
||||
}: {
|
||||
node: INodeType | INodeVersionedType;
|
||||
filePath: string;
|
||||
isCustom: boolean;
|
||||
}) {
|
||||
try {
|
||||
const codex = this.getCodex(filePath);
|
||||
|
||||
|
|
|
@ -1,4 +1,14 @@
|
|||
import { INodeType, INodeTypeData, INodeTypes, NodeHelpers } from 'n8n-workflow';
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import {
|
||||
INodeType,
|
||||
INodeTypeData,
|
||||
INodeTypes,
|
||||
INodeVersionedType,
|
||||
NodeHelpers,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
class NodeTypesClass implements INodeTypes {
|
||||
nodeTypes: INodeTypeData = {};
|
||||
|
@ -8,29 +18,30 @@ class NodeTypesClass implements INodeTypes {
|
|||
// polling nodes the polling times
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const nodeTypeData of Object.values(nodeTypes)) {
|
||||
const applyParameters = NodeHelpers.getSpecialNodeParameters(nodeTypeData.type);
|
||||
const nodeType = NodeHelpers.getVersionedTypeNode(nodeTypeData.type);
|
||||
const applyParameters = NodeHelpers.getSpecialNodeParameters(nodeType);
|
||||
|
||||
if (applyParameters.length) {
|
||||
// eslint-disable-next-line prefer-spread
|
||||
nodeTypeData.type.description.properties.unshift.apply(
|
||||
nodeTypeData.type.description.properties,
|
||||
applyParameters,
|
||||
);
|
||||
nodeType.description.properties.unshift(...applyParameters);
|
||||
}
|
||||
}
|
||||
this.nodeTypes = nodeTypes;
|
||||
}
|
||||
|
||||
getAll(): INodeType[] {
|
||||
getAll(): Array<INodeType | INodeVersionedType> {
|
||||
return Object.values(this.nodeTypes).map((data) => data.type);
|
||||
}
|
||||
|
||||
getByName(nodeType: string): INodeType | undefined {
|
||||
getByName(nodeType: string): INodeType | INodeVersionedType | undefined {
|
||||
if (this.nodeTypes[nodeType] === undefined) {
|
||||
throw new Error(`The node-type "${nodeType}" is not known!`);
|
||||
}
|
||||
return this.nodeTypes[nodeType].type;
|
||||
}
|
||||
|
||||
getByNameAndVersion(nodeType: string, version?: number): INodeType {
|
||||
return NodeHelpers.getVersionedTypeNode(this.nodeTypes[nodeType].type, version);
|
||||
}
|
||||
}
|
||||
|
||||
let nodeTypesInstance: NodeTypesClass | undefined;
|
||||
|
|
|
@ -52,24 +52,39 @@ import { createHash, createHmac } from 'crypto';
|
|||
import { compare } from 'bcryptjs';
|
||||
import * as promClient from 'prom-client';
|
||||
|
||||
import { Credentials, LoadNodeParameterOptions, UserSettings } from 'n8n-core';
|
||||
import {
|
||||
Credentials,
|
||||
ICredentialTestFunctions,
|
||||
LoadNodeParameterOptions,
|
||||
NodeExecuteFunctions,
|
||||
UserSettings,
|
||||
} from 'n8n-core';
|
||||
|
||||
import {
|
||||
ICredentialsDecrypted,
|
||||
ICredentialsEncrypted,
|
||||
ICredentialType,
|
||||
IDataObject,
|
||||
INodeCredentials,
|
||||
INodeParameters,
|
||||
INodePropertyOptions,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
INodeTypeNameVersion,
|
||||
IRunData,
|
||||
INodeVersionedType,
|
||||
IWorkflowBase,
|
||||
IWorkflowCredentials,
|
||||
LoggerProxy,
|
||||
NodeCredentialTestRequest,
|
||||
NodeCredentialTestResult,
|
||||
NodeHelpers,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { NodeVersionedType } from 'n8n-nodes-base';
|
||||
|
||||
import * as basicAuth from 'basic-auth';
|
||||
import * as compression from 'compression';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
@ -131,7 +146,7 @@ import * as config from '../config';
|
|||
import * as TagHelpers from './TagHelpers';
|
||||
import { TagEntity } from './databases/entities/TagEntity';
|
||||
import { WorkflowEntity } from './databases/entities/WorkflowEntity';
|
||||
import { WorkflowNameRequest } from './WorkflowHelpers';
|
||||
import { NameRequest } from './WorkflowHelpers';
|
||||
|
||||
require('body-parser-xml')(bodyParser);
|
||||
|
||||
|
@ -156,6 +171,8 @@ class App {
|
|||
|
||||
defaultWorkflowName: string;
|
||||
|
||||
defaultCredentialsName: string;
|
||||
|
||||
saveDataErrorExecution: string;
|
||||
|
||||
saveDataSuccessExecution: string;
|
||||
|
@ -196,6 +213,7 @@ class App {
|
|||
this.endpointWebhookTest = config.get('endpoints.webhookTest') as string;
|
||||
|
||||
this.defaultWorkflowName = config.get('workflows.defaultName') as string;
|
||||
this.defaultCredentialsName = config.get('credentials.defaultName') as string;
|
||||
|
||||
this.saveDataErrorExecution = config.get('executions.saveDataOnError') as string;
|
||||
this.saveDataSuccessExecution = config.get('executions.saveDataOnSuccess') as string;
|
||||
|
@ -527,6 +545,7 @@ class App {
|
|||
// support application/x-www-form-urlencoded post data
|
||||
this.app.use(
|
||||
bodyParser.urlencoded({
|
||||
limit: `${this.payloadSizeMax}mb`,
|
||||
extended: false,
|
||||
verify: (req, res, buf) => {
|
||||
// @ts-ignore
|
||||
|
@ -719,41 +738,11 @@ class App {
|
|||
this.app.get(
|
||||
`/${this.restEndpoint}/workflows/new`,
|
||||
ResponseHelper.send(
|
||||
async (req: WorkflowNameRequest, res: express.Response): Promise<{ name: string }> => {
|
||||
const nameToReturn =
|
||||
async (req: NameRequest, res: express.Response): Promise<{ name: string }> => {
|
||||
const requestedName =
|
||||
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 };
|
||||
}
|
||||
|
||||
const maxSuffix = workflows.reduce((acc: number, { name }) => {
|
||||
const parts = name.split(`${nameToReturn} `);
|
||||
|
||||
if (parts.length > 2) return acc;
|
||||
|
||||
const suffix = Number(parts[1]);
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
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}` };
|
||||
return await GenericHelpers.generateUniqueName(requestedName, 'workflow');
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@ -899,7 +888,6 @@ class App {
|
|||
await this.externalHooks.run('workflow.delete', [id]);
|
||||
|
||||
const isActive = await this.activeWorkflowRunner.isActive(id);
|
||||
|
||||
if (isActive) {
|
||||
// Before deleting a workflow deactivate it
|
||||
await this.activeWorkflowRunner.remove(id);
|
||||
|
@ -1077,7 +1065,9 @@ class App {
|
|||
`/${this.restEndpoint}/node-parameter-options`,
|
||||
ResponseHelper.send(
|
||||
async (req: express.Request, res: express.Response): Promise<INodePropertyOptions[]> => {
|
||||
const nodeType = req.query.nodeType as string;
|
||||
const nodeTypeAndVersion = JSON.parse(
|
||||
`${req.query.nodeTypeAndVersion}`,
|
||||
) as INodeTypeNameVersion;
|
||||
const path = req.query.path as string;
|
||||
let credentials: INodeCredentials | undefined;
|
||||
const currentNodeParameters = JSON.parse(
|
||||
|
@ -1092,10 +1082,10 @@ class App {
|
|||
|
||||
// @ts-ignore
|
||||
const loadDataInstance = new LoadNodeParameterOptions(
|
||||
nodeType,
|
||||
nodeTypeAndVersion,
|
||||
nodeTypes,
|
||||
path,
|
||||
JSON.parse(`${req.query.currentNodeParameters}`),
|
||||
currentNodeParameters,
|
||||
credentials,
|
||||
);
|
||||
|
||||
|
@ -1112,46 +1102,58 @@ class App {
|
|||
ResponseHelper.send(
|
||||
async (req: express.Request, res: express.Response): Promise<INodeTypeDescription[]> => {
|
||||
const returnData: INodeTypeDescription[] = [];
|
||||
const onlyLatest = req.query.onlyLatest === 'true';
|
||||
|
||||
const nodeTypes = NodeTypes();
|
||||
|
||||
const allNodes = nodeTypes.getAll();
|
||||
|
||||
allNodes.forEach((nodeData) => {
|
||||
// Make a copy of the object. If we don't do this, then when
|
||||
// The method below is called the properties are removed for good
|
||||
// This happens because nodes are returned as reference.
|
||||
const nodeInfo: INodeTypeDescription = { ...nodeData.description };
|
||||
const getNodeDescription = (nodeType: INodeType): INodeTypeDescription => {
|
||||
const nodeInfo: INodeTypeDescription = { ...nodeType.description };
|
||||
if (req.query.includeProperties !== 'true') {
|
||||
// @ts-ignore
|
||||
delete nodeInfo.properties;
|
||||
}
|
||||
returnData.push(nodeInfo);
|
||||
});
|
||||
return nodeInfo;
|
||||
};
|
||||
|
||||
if (onlyLatest) {
|
||||
allNodes.forEach((nodeData) => {
|
||||
const nodeType = NodeHelpers.getVersionedTypeNode(nodeData);
|
||||
const nodeInfo: INodeTypeDescription = getNodeDescription(nodeType);
|
||||
returnData.push(nodeInfo);
|
||||
});
|
||||
} else {
|
||||
allNodes.forEach((nodeData) => {
|
||||
const allNodeTypes = NodeHelpers.getVersionedTypeNodeAll(nodeData);
|
||||
allNodeTypes.forEach((element) => {
|
||||
const nodeInfo: INodeTypeDescription = getNodeDescription(element);
|
||||
returnData.push(nodeInfo);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return returnData;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Returns node information baesd on namese
|
||||
// Returns node information based on node names and versions
|
||||
this.app.post(
|
||||
`/${this.restEndpoint}/node-types`,
|
||||
ResponseHelper.send(
|
||||
async (req: express.Request, res: express.Response): Promise<INodeTypeDescription[]> => {
|
||||
const nodeNames = _.get(req, 'body.nodeNames', []) as string[];
|
||||
const nodeInfos = _.get(req, 'body.nodeInfos', []) as INodeTypeNameVersion[];
|
||||
const nodeTypes = NodeTypes();
|
||||
|
||||
return nodeNames
|
||||
.map((name) => {
|
||||
try {
|
||||
return nodeTypes.getByName(name);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
.filter((nodeData) => !!nodeData)
|
||||
.map((nodeData) => nodeData!.description);
|
||||
const returnData: INodeTypeDescription[] = [];
|
||||
nodeInfos.forEach((nodeInfo) => {
|
||||
const nodeType = nodeTypes.getByNameAndVersion(nodeInfo.name, nodeInfo.version);
|
||||
if (nodeType?.description) {
|
||||
returnData.push(nodeType.description);
|
||||
}
|
||||
});
|
||||
|
||||
return returnData;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@ -1173,7 +1175,7 @@ class App {
|
|||
}`;
|
||||
|
||||
const nodeTypes = NodeTypes();
|
||||
const nodeType = nodeTypes.getByName(nodeTypeName);
|
||||
const nodeType = nodeTypes.getByNameAndVersion(nodeTypeName);
|
||||
|
||||
if (nodeType === undefined) {
|
||||
res.status(404).send('The nodeType is not known.');
|
||||
|
@ -1236,6 +1238,18 @@ class App {
|
|||
// Credentials
|
||||
// ----------------------------------------
|
||||
|
||||
this.app.get(
|
||||
`/${this.restEndpoint}/credentials/new`,
|
||||
ResponseHelper.send(
|
||||
async (req: NameRequest, res: express.Response): Promise<{ name: string }> => {
|
||||
const requestedName =
|
||||
req.query.name && req.query.name !== '' ? req.query.name : this.defaultCredentialsName;
|
||||
|
||||
return await GenericHelpers.generateUniqueName(requestedName, 'credentials');
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Deletes a specific credential
|
||||
this.app.delete(
|
||||
`/${this.restEndpoint}/credentials/:id`,
|
||||
|
@ -1322,6 +1336,95 @@ class App {
|
|||
),
|
||||
);
|
||||
|
||||
// Test credentials
|
||||
this.app.post(
|
||||
`/${this.restEndpoint}/credentials-test`,
|
||||
ResponseHelper.send(
|
||||
async (req: express.Request, res: express.Response): Promise<NodeCredentialTestResult> => {
|
||||
const incomingData = req.body as NodeCredentialTestRequest;
|
||||
const credentialType = incomingData.credentials.type;
|
||||
|
||||
// Find nodes that can test this credential.
|
||||
const nodeTypes = NodeTypes();
|
||||
const allNodes = nodeTypes.getAll();
|
||||
|
||||
let foundTestFunction:
|
||||
| ((
|
||||
this: ICredentialTestFunctions,
|
||||
credential: ICredentialsDecrypted,
|
||||
) => Promise<NodeCredentialTestResult>)
|
||||
| undefined;
|
||||
const nodeThatCanTestThisCredential = allNodes.find((node) => {
|
||||
if (
|
||||
incomingData.nodeToTestWith &&
|
||||
node.description.name !== incomingData.nodeToTestWith
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node instanceof NodeVersionedType) {
|
||||
const versionNames = Object.keys((node as INodeVersionedType).nodeVersions);
|
||||
for (const versionName of versionNames) {
|
||||
const nodeType = (node as INodeVersionedType).nodeVersions[
|
||||
versionName as unknown as number
|
||||
];
|
||||
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||
const credentialTestable = nodeType.description.credentials?.find((credential) => {
|
||||
const testFunctionSearch =
|
||||
credential.name === credentialType && !!credential.testedBy;
|
||||
if (testFunctionSearch) {
|
||||
foundTestFunction = (node as unknown as INodeType).methods!.credentialTest![
|
||||
credential.testedBy!
|
||||
];
|
||||
}
|
||||
return testFunctionSearch;
|
||||
});
|
||||
if (credentialTestable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const credentialTestable = (node as INodeType).description.credentials?.find(
|
||||
(credential) => {
|
||||
const testFunctionSearch =
|
||||
credential.name === credentialType && !!credential.testedBy;
|
||||
if (testFunctionSearch) {
|
||||
foundTestFunction = (node as INodeType).methods!.credentialTest![
|
||||
credential.testedBy!
|
||||
];
|
||||
}
|
||||
return testFunctionSearch;
|
||||
},
|
||||
);
|
||||
return !!credentialTestable;
|
||||
});
|
||||
|
||||
if (!nodeThatCanTestThisCredential) {
|
||||
return Promise.resolve({
|
||||
status: 'Error',
|
||||
message: 'There are no nodes that can test this credential.',
|
||||
});
|
||||
}
|
||||
|
||||
if (foundTestFunction === undefined) {
|
||||
return Promise.resolve({
|
||||
status: 'Error',
|
||||
message: 'No testing function found for this credential.',
|
||||
});
|
||||
}
|
||||
|
||||
const credentialTestFunctions = NodeExecuteFunctions.getCredentialTestFunctions();
|
||||
|
||||
const output = await foundTestFunction.call(
|
||||
credentialTestFunctions,
|
||||
incomingData.credentials,
|
||||
);
|
||||
return Promise.resolve(output);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Updates existing credentials
|
||||
this.app.patch(
|
||||
`/${this.restEndpoint}/credentials/:id`,
|
||||
|
@ -1539,6 +1642,42 @@ class App {
|
|||
),
|
||||
);
|
||||
|
||||
this.app.get(
|
||||
`/${this.restEndpoint}/credential-icon/:credentialType`,
|
||||
async (req: express.Request, res: express.Response): Promise<void> => {
|
||||
try {
|
||||
const credentialName = req.params.credentialType;
|
||||
|
||||
const credentialType = CredentialTypes().getByName(credentialName);
|
||||
|
||||
if (credentialType === undefined) {
|
||||
res.status(404).send('The credentialType is not known.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (credentialType.icon === undefined) {
|
||||
res.status(404).send('No icon found for credential.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!credentialType.icon.startsWith('file:')) {
|
||||
res.status(404).send('Credential does not have a file icon.');
|
||||
return;
|
||||
}
|
||||
|
||||
const filepath = credentialType.icon.substr(5);
|
||||
|
||||
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
res.setHeader('Cache-control', `private max-age=${maxAge}`);
|
||||
|
||||
res.sendFile(filepath);
|
||||
} catch (error) {
|
||||
// Error response
|
||||
return ResponseHelper.sendErrorResponse(res, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ----------------------------------------
|
||||
// OAuth1-Credential/Auth
|
||||
// ----------------------------------------
|
||||
|
|
|
@ -139,7 +139,10 @@ export async function executeWebhook(
|
|||
responseCallback: (error: Error | null, data: IResponseCallbackData) => void,
|
||||
): Promise<string | undefined> {
|
||||
// Get the nodeType to know which responseMode is set
|
||||
const nodeType = workflow.nodeTypes.getByName(workflowStartNode.type);
|
||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(
|
||||
workflowStartNode.type,
|
||||
workflowStartNode.typeVersion,
|
||||
);
|
||||
if (nodeType === undefined) {
|
||||
const errorMessage = `The type of the webhook node "${workflowStartNode.name}" is not known.`;
|
||||
responseCallback(new Error(errorMessage), {});
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
/* eslint-disable no-continue */
|
||||
|
@ -226,13 +229,13 @@ export function getNodeTypeData(nodes: INode[]): ITransferNodeTypes {
|
|||
// can be loaded again in the process
|
||||
const returnData: ITransferNodeTypes = {};
|
||||
for (const nodeTypeName of neededNodeTypes) {
|
||||
if (nodeTypes.nodeTypes[nodeTypeName] === undefined) {
|
||||
throw new Error(`The NodeType "${nodeTypeName}" could not be found!`);
|
||||
if (nodeTypes.nodeTypes[nodeTypeName.type] === undefined) {
|
||||
throw new Error(`The NodeType "${nodeTypeName.type}" could not be found!`);
|
||||
}
|
||||
|
||||
returnData[nodeTypeName] = {
|
||||
className: nodeTypes.nodeTypes[nodeTypeName].type.constructor.name,
|
||||
sourcePath: nodeTypes.nodeTypes[nodeTypeName].sourcePath,
|
||||
returnData[nodeTypeName.type] = {
|
||||
className: nodeTypes.nodeTypes[nodeTypeName.type].type.constructor.name,
|
||||
sourcePath: nodeTypes.nodeTypes[nodeTypeName.type].sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -306,12 +309,12 @@ export function getCredentialsDataByNodes(nodes: INode[]): ICredentialsTypeData
|
|||
* @param {INode[]} nodes
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getNeededNodeTypes(nodes: INode[]): string[] {
|
||||
export function getNeededNodeTypes(nodes: INode[]): Array<{ type: string; version: number }> {
|
||||
// Check which node-types have to be loaded
|
||||
const neededNodeTypes: string[] = [];
|
||||
const neededNodeTypes: Array<{ type: string; version: number }> = [];
|
||||
for (const node of nodes) {
|
||||
if (!neededNodeTypes.includes(node.type)) {
|
||||
neededNodeTypes.push(node.type);
|
||||
if (neededNodeTypes.find((neededNodes) => node.type === neededNodes.type) === undefined) {
|
||||
neededNodeTypes.push({ type: node.type, version: node.typeVersion });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -408,9 +411,8 @@ export function throwDuplicateEntryError(error: Error) {
|
|||
throw new ResponseHelper.ResponseError(errorMessage, undefined, 400);
|
||||
}
|
||||
|
||||
export type WorkflowNameRequest = Express.Request & {
|
||||
export type NameRequest = Express.Request & {
|
||||
query: {
|
||||
name?: string;
|
||||
offset?: string;
|
||||
};
|
||||
};
|
||||
|
|
|
@ -357,7 +357,7 @@ export class WorkflowRunner {
|
|||
try {
|
||||
job = await this.jobQueue.add(jobData, jobOptions);
|
||||
|
||||
console.log(`Started with ID: ${job.id.toString()}`);
|
||||
console.log(`Started with job ID: ${job.id.toString()} (Execution ID: ${executionId})`);
|
||||
|
||||
hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerMain(
|
||||
data.executionMode,
|
||||
|
|
|
@ -98,7 +98,15 @@ export class WorkflowRunnerProcess {
|
|||
const tempModule = require(filePath);
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
const nodeObject = new tempModule[className]();
|
||||
if (nodeObject.getNodeType !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
tempNode = nodeObject.getNodeType();
|
||||
} else {
|
||||
tempNode = nodeObject;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
tempNode = new tempModule[className]() as INodeType;
|
||||
} catch (error) {
|
||||
throw new Error(`Error loading node "${nodeTypeName}" from: "${filePath}"`);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-core",
|
||||
"version": "0.81.0",
|
||||
"version": "0.84.0",
|
||||
"description": "Core functionality of n8n",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"homepage": "https://n8n.io",
|
||||
|
@ -42,15 +42,18 @@
|
|||
"typescript": "~4.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.21.1",
|
||||
"client-oauth2": "^4.2.5",
|
||||
"cron": "^1.7.2",
|
||||
"crypto-js": "~4.1.1",
|
||||
"file-type": "^14.6.2",
|
||||
"form-data": "^4.0.0",
|
||||
"lodash.get": "^4.4.2",
|
||||
"mime-types": "^2.1.27",
|
||||
"n8n-workflow": "~0.66.0",
|
||||
"n8n-workflow": "~0.70.0",
|
||||
"oauth-1.0a": "^2.2.6",
|
||||
"p-cancelable": "^2.0.0",
|
||||
"qs": "^6.10.1",
|
||||
"request": "^2.88.2",
|
||||
"request-promise-native": "^1.0.7"
|
||||
},
|
||||
|
|
|
@ -2,11 +2,13 @@
|
|||
import {
|
||||
IAllExecuteFunctions,
|
||||
IBinaryData,
|
||||
ICredentialTestFunctions as ICredentialTestFunctionsBase,
|
||||
ICredentialType,
|
||||
IDataObject,
|
||||
IExecuteFunctions as IExecuteFunctionsBase,
|
||||
IExecuteSingleFunctions as IExecuteSingleFunctionsBase,
|
||||
IHookFunctions as IHookFunctionsBase,
|
||||
IHttpRequestOptions,
|
||||
ILoadOptionsFunctions as ILoadOptionsFunctionsBase,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
|
@ -33,13 +35,14 @@ export interface IProcessMessage {
|
|||
|
||||
export interface IExecuteFunctions extends IExecuteFunctionsBase {
|
||||
helpers: {
|
||||
httpRequest(requestOptions: IHttpRequestOptions): Promise<any>; // tslint:disable-line:no-any
|
||||
prepareBinaryData(
|
||||
binaryData: Buffer,
|
||||
filePath?: string,
|
||||
mimeType?: string,
|
||||
): Promise<IBinaryData>;
|
||||
getBinaryDataBuffer(itemIndex: number, propertyName: string): Promise<Buffer>;
|
||||
request: requestPromise.RequestPromiseAPI;
|
||||
request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>; // tslint:disable-line:no-any
|
||||
requestOAuth2(
|
||||
this: IAllExecuteFunctions,
|
||||
credentialsType: string,
|
||||
|
@ -57,12 +60,13 @@ export interface IExecuteFunctions extends IExecuteFunctionsBase {
|
|||
|
||||
export interface IExecuteSingleFunctions extends IExecuteSingleFunctionsBase {
|
||||
helpers: {
|
||||
httpRequest(requestOptions: IHttpRequestOptions): Promise<any>; // tslint:disable-line:no-any
|
||||
prepareBinaryData(
|
||||
binaryData: Buffer,
|
||||
filePath?: string,
|
||||
mimeType?: string,
|
||||
): Promise<IBinaryData>;
|
||||
request: requestPromise.RequestPromiseAPI;
|
||||
request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>; // tslint:disable-line:no-any
|
||||
requestOAuth2(
|
||||
this: IAllExecuteFunctions,
|
||||
credentialsType: string,
|
||||
|
@ -79,12 +83,13 @@ export interface IExecuteSingleFunctions extends IExecuteSingleFunctionsBase {
|
|||
|
||||
export interface IPollFunctions extends IPollFunctionsBase {
|
||||
helpers: {
|
||||
httpRequest(requestOptions: IHttpRequestOptions): Promise<any>; // tslint:disable-line:no-any
|
||||
prepareBinaryData(
|
||||
binaryData: Buffer,
|
||||
filePath?: string,
|
||||
mimeType?: string,
|
||||
): Promise<IBinaryData>;
|
||||
request: requestPromise.RequestPromiseAPI;
|
||||
request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>; // tslint:disable-line:no-any
|
||||
requestOAuth2(
|
||||
this: IAllExecuteFunctions,
|
||||
credentialsType: string,
|
||||
|
@ -106,12 +111,13 @@ export interface IResponseError extends Error {
|
|||
|
||||
export interface ITriggerFunctions extends ITriggerFunctionsBase {
|
||||
helpers: {
|
||||
httpRequest(requestOptions: IHttpRequestOptions): Promise<any>; // tslint:disable-line:no-any
|
||||
prepareBinaryData(
|
||||
binaryData: Buffer,
|
||||
filePath?: string,
|
||||
mimeType?: string,
|
||||
): Promise<IBinaryData>;
|
||||
request: requestPromise.RequestPromiseAPI;
|
||||
request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>; // tslint:disable-line:no-any
|
||||
requestOAuth2(
|
||||
this: IAllExecuteFunctions,
|
||||
credentialsType: string,
|
||||
|
@ -143,7 +149,8 @@ export interface IUserSettings {
|
|||
|
||||
export interface ILoadOptionsFunctions extends ILoadOptionsFunctionsBase {
|
||||
helpers: {
|
||||
request?: requestPromise.RequestPromiseAPI;
|
||||
httpRequest(requestOptions: IHttpRequestOptions): Promise<any>; // tslint:disable-line:no-any
|
||||
request?: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>; // tslint:disable-line:no-any
|
||||
requestOAuth2?: (
|
||||
this: IAllExecuteFunctions,
|
||||
credentialsType: string,
|
||||
|
@ -158,9 +165,16 @@ export interface ILoadOptionsFunctions extends ILoadOptionsFunctionsBase {
|
|||
};
|
||||
}
|
||||
|
||||
export interface IHookFunctions extends IHookFunctionsBase {
|
||||
export interface ICredentialTestFunctions extends ICredentialTestFunctionsBase {
|
||||
helpers: {
|
||||
request: requestPromise.RequestPromiseAPI;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IHookFunctions extends IHookFunctionsBase {
|
||||
helpers: {
|
||||
httpRequest(requestOptions: IHttpRequestOptions): Promise<any>; // tslint:disable-line:no-any
|
||||
request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>; // tslint:disable-line:no-any
|
||||
requestOAuth2(
|
||||
this: IAllExecuteFunctions,
|
||||
credentialsType: string,
|
||||
|
@ -177,12 +191,13 @@ export interface IHookFunctions extends IHookFunctionsBase {
|
|||
|
||||
export interface IWebhookFunctions extends IWebhookFunctionsBase {
|
||||
helpers: {
|
||||
httpRequest(requestOptions: IHttpRequestOptions): Promise<any>; // tslint:disable-line:no-any
|
||||
prepareBinaryData(
|
||||
binaryData: Buffer,
|
||||
filePath?: string,
|
||||
mimeType?: string,
|
||||
): Promise<IBinaryData>;
|
||||
request: requestPromise.RequestPromiseAPI;
|
||||
request: (uriOrObject: string | IDataObject | any, options?: IDataObject) => Promise<any>; // tslint:disable-line:no-any
|
||||
requestOAuth2(
|
||||
this: IAllExecuteFunctions,
|
||||
credentialsType: string,
|
||||
|
|
|
@ -1,9 +1,15 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {
|
||||
INode,
|
||||
INodeCredentials,
|
||||
INodeParameters,
|
||||
INodePropertyOptions,
|
||||
INodeTypeNameVersion,
|
||||
INodeTypes,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
|
@ -21,27 +27,30 @@ export class LoadNodeParameterOptions {
|
|||
workflow: Workflow;
|
||||
|
||||
constructor(
|
||||
nodeTypeName: string,
|
||||
nodeTypeNameAndVersion: INodeTypeNameVersion,
|
||||
nodeTypes: INodeTypes,
|
||||
path: string,
|
||||
currentNodeParameters: INodeParameters,
|
||||
credentials?: INodeCredentials,
|
||||
) {
|
||||
const nodeType = nodeTypes.getByNameAndVersion(
|
||||
nodeTypeNameAndVersion.name,
|
||||
nodeTypeNameAndVersion.version,
|
||||
);
|
||||
this.path = path;
|
||||
const nodeType = nodeTypes.getByName(nodeTypeName);
|
||||
|
||||
if (nodeType === undefined) {
|
||||
throw new Error(`The node-type "${nodeTypeName}" is not known!`);
|
||||
throw new Error(
|
||||
`The node-type "${nodeTypeNameAndVersion.name} v${nodeTypeNameAndVersion.version}" is not known!`,
|
||||
);
|
||||
}
|
||||
|
||||
const nodeData: INode = {
|
||||
parameters: currentNodeParameters,
|
||||
name: TEMP_NODE_NAME,
|
||||
type: nodeTypeName,
|
||||
typeVersion: 1,
|
||||
type: nodeTypeNameAndVersion.name,
|
||||
typeVersion: nodeTypeNameAndVersion.version,
|
||||
position: [0, 0],
|
||||
};
|
||||
|
||||
if (credentials) {
|
||||
nodeData.credentials = credentials;
|
||||
}
|
||||
|
@ -91,12 +100,13 @@ export class LoadNodeParameterOptions {
|
|||
): Promise<INodePropertyOptions[]> {
|
||||
const node = this.workflow.getNode(TEMP_NODE_NAME);
|
||||
|
||||
const nodeType = this.workflow.nodeTypes.getByName(node!.type);
|
||||
const nodeType = this.workflow.nodeTypes.getByNameAndVersion(node!.type, node?.typeVersion);
|
||||
|
||||
if (
|
||||
nodeType!.methods === undefined ||
|
||||
nodeType!.methods.loadOptions === undefined ||
|
||||
nodeType!.methods.loadOptions[methodName] === undefined
|
||||
!nodeType ||
|
||||
nodeType.methods === undefined ||
|
||||
nodeType.methods.loadOptions === undefined ||
|
||||
nodeType.methods.loadOptions[methodName] === undefined
|
||||
) {
|
||||
throw new Error(
|
||||
`The node-type "${node!.type}" does not have the method "${methodName}" defined!`,
|
||||
|
@ -110,6 +120,6 @@ export class LoadNodeParameterOptions {
|
|||
additionalData,
|
||||
);
|
||||
|
||||
return nodeType!.methods.loadOptions[methodName].call(thisArgs);
|
||||
return nodeType.methods.loadOptions[methodName].call(thisArgs);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable no-lonely-if */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable no-prototype-builtins */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
@ -13,6 +14,7 @@
|
|||
/* eslint-disable @typescript-eslint/no-shadow */
|
||||
/* eslint-disable no-param-reassign */
|
||||
import {
|
||||
GenericValue,
|
||||
IAllExecuteFunctions,
|
||||
IBinaryData,
|
||||
IContextObject,
|
||||
|
@ -22,6 +24,9 @@ import {
|
|||
IExecuteFunctions,
|
||||
IExecuteSingleFunctions,
|
||||
IExecuteWorkflowInfo,
|
||||
IHttpRequestOptions,
|
||||
IN8nHttpFullResponse,
|
||||
IN8nHttpResponse,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
INodeParameters,
|
||||
|
@ -48,6 +53,8 @@ import {
|
|||
LoggerProxy as Logger,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { Agent } from 'https';
|
||||
import { stringify } from 'qs';
|
||||
import * as clientOAuth1 from 'oauth-1.0a';
|
||||
import { Token } from 'oauth-1.0a';
|
||||
import * as clientOAuth2 from 'client-oauth2';
|
||||
|
@ -55,6 +62,7 @@ import * as clientOAuth2 from 'client-oauth2';
|
|||
import { get } from 'lodash';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import * as express from 'express';
|
||||
import * as FormData from 'form-data';
|
||||
import * as path from 'path';
|
||||
import { OptionsWithUri, OptionsWithUrl } from 'request';
|
||||
import * as requestPromise from 'request-promise-native';
|
||||
|
@ -62,9 +70,12 @@ import { createHmac } from 'crypto';
|
|||
import { fromBuffer } from 'file-type';
|
||||
import { lookup } from 'mime-types';
|
||||
|
||||
import axios, { AxiosProxyConfig, AxiosRequestConfig, Method } from 'axios';
|
||||
import { URLSearchParams } from 'url';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import {
|
||||
BINARY_ENCODING,
|
||||
ICredentialTestFunctions,
|
||||
IHookFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
IResponseError,
|
||||
|
@ -72,10 +83,425 @@ import {
|
|||
PLACEHOLDER_EMPTY_EXECUTION_ID,
|
||||
} from '.';
|
||||
|
||||
axios.defaults.timeout = 300000;
|
||||
// Prevent axios from adding x-form-www-urlencoded headers by default
|
||||
axios.defaults.headers.post = {};
|
||||
|
||||
const requestPromiseWithDefaults = requestPromise.defaults({
|
||||
timeout: 300000, // 5 minutes
|
||||
});
|
||||
|
||||
async function parseRequestObject(requestObject: IDataObject) {
|
||||
// This function is a temporary implementation
|
||||
// That translates all http requests done via
|
||||
// the request library to axios directly
|
||||
// We are not using n8n's interface as it would
|
||||
// an unnecessary step, considering the `request`
|
||||
// helper can be deprecated and removed.
|
||||
const axiosConfig: AxiosRequestConfig = {};
|
||||
|
||||
if (requestObject.headers !== undefined) {
|
||||
axiosConfig.headers = requestObject.headers as string;
|
||||
}
|
||||
|
||||
// Let's start parsing the hardest part, which is the request body.
|
||||
// The process here is as following?
|
||||
// - Check if we have a `content-type` header. If this was set,
|
||||
// we will follow
|
||||
// - Check if the `form` property was set. If yes, then it's x-www-form-urlencoded
|
||||
// - Check if the `formData` property exists. If yes, then it's multipart/form-data
|
||||
// - Lastly, we should have a regular `body` that is probably a JSON.
|
||||
|
||||
const contentTypeHeaderKeyName =
|
||||
axiosConfig.headers &&
|
||||
Object.keys(axiosConfig.headers).find(
|
||||
(headerName) => headerName.toLowerCase() === 'content-type',
|
||||
);
|
||||
const contentType =
|
||||
contentTypeHeaderKeyName &&
|
||||
(axiosConfig.headers[contentTypeHeaderKeyName] as string | undefined);
|
||||
if (contentType === 'application/x-www-form-urlencoded' && requestObject.formData === undefined) {
|
||||
// there are nodes incorrectly created, informing the content type header
|
||||
// and also using formData. Request lib takes precedence for the formData.
|
||||
// We will do the same.
|
||||
// Merge body and form properties.
|
||||
// @ts-ignore
|
||||
axiosConfig.data =
|
||||
typeof requestObject.body === 'string'
|
||||
? requestObject.body
|
||||
: new URLSearchParams(
|
||||
Object.assign(requestObject.body || {}, requestObject.form || {}) as Record<
|
||||
string,
|
||||
string
|
||||
>,
|
||||
);
|
||||
} else if (contentType && contentType.includes('multipart/form-data') !== false) {
|
||||
if (requestObject.formData !== undefined && requestObject.formData instanceof FormData) {
|
||||
axiosConfig.data = requestObject.formData;
|
||||
} else {
|
||||
const allData = Object.assign(requestObject.body || {}, requestObject.formData || {});
|
||||
|
||||
const objectKeys = Object.keys(allData);
|
||||
if (objectKeys.length > 0) {
|
||||
// Should be a standard object. We must convert to formdata
|
||||
const form = new FormData();
|
||||
|
||||
objectKeys.forEach((key) => {
|
||||
const formField = (allData as IDataObject)[key] as IDataObject;
|
||||
if (formField.hasOwnProperty('value') && formField.value instanceof Buffer) {
|
||||
let filename;
|
||||
// @ts-ignore
|
||||
if (!!formField.options && formField.options.filename !== undefined) {
|
||||
filename = (formField.options as IDataObject).filename as string;
|
||||
}
|
||||
form.append(key, formField.value, filename);
|
||||
} else {
|
||||
form.append(key, formField);
|
||||
}
|
||||
});
|
||||
axiosConfig.data = form;
|
||||
}
|
||||
}
|
||||
// replace the existing header with a new one that
|
||||
// contains the boundary property.
|
||||
// @ts-ignore
|
||||
delete axiosConfig.headers[contentTypeHeaderKeyName];
|
||||
const headers = axiosConfig.data.getHeaders();
|
||||
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
|
||||
} else {
|
||||
// When using the `form` property it means the content should be x-www-form-urlencoded.
|
||||
if (requestObject.form !== undefined && requestObject.body === undefined) {
|
||||
// If we have only form
|
||||
axiosConfig.data = new URLSearchParams(requestObject.form as Record<string, string>);
|
||||
if (axiosConfig.headers !== undefined) {
|
||||
// remove possibly existing content-type headers
|
||||
const headers = Object.keys(axiosConfig.headers);
|
||||
headers.forEach((header) =>
|
||||
header.toLowerCase() === 'content-type' ? delete axiosConfig.headers[header] : null,
|
||||
);
|
||||
axiosConfig.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
} else {
|
||||
axiosConfig.headers = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
};
|
||||
}
|
||||
} else if (requestObject.formData !== undefined) {
|
||||
// remove any "content-type" that might exist.
|
||||
if (axiosConfig.headers !== undefined) {
|
||||
const headers = Object.keys(axiosConfig.headers);
|
||||
headers.forEach((header) =>
|
||||
header.toLowerCase() === 'content-type' ? delete axiosConfig.headers[header] : null,
|
||||
);
|
||||
}
|
||||
|
||||
if (requestObject.formData instanceof FormData) {
|
||||
axiosConfig.data = requestObject.formData;
|
||||
} else {
|
||||
const objectKeys = Object.keys(requestObject.formData as object);
|
||||
if (objectKeys.length > 0) {
|
||||
// Should be a standard object. We must convert to formdata
|
||||
const form = new FormData();
|
||||
|
||||
objectKeys.forEach((key) => {
|
||||
const formField = (requestObject.formData as IDataObject)[key] as IDataObject;
|
||||
if (formField.hasOwnProperty('value') && formField.value instanceof Buffer) {
|
||||
let filename;
|
||||
// @ts-ignore
|
||||
if (!!formField.options && formField.options.filename !== undefined) {
|
||||
filename = (formField.options as IDataObject).filename as string;
|
||||
}
|
||||
form.append(key, formField.value, filename);
|
||||
} else {
|
||||
form.append(key, formField);
|
||||
}
|
||||
});
|
||||
axiosConfig.data = form;
|
||||
}
|
||||
}
|
||||
// Mix in headers as FormData creates the boundary.
|
||||
const headers = axiosConfig.data.getHeaders();
|
||||
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
|
||||
} else if (requestObject.body !== undefined) {
|
||||
// If we have body and possibly form
|
||||
if (requestObject.form !== undefined) {
|
||||
// merge both objects when exist.
|
||||
requestObject.body = Object.assign(requestObject.body, requestObject.form);
|
||||
}
|
||||
axiosConfig.data = requestObject.body as FormData | GenericValue | GenericValue[];
|
||||
}
|
||||
}
|
||||
|
||||
if (requestObject.uri !== undefined) {
|
||||
axiosConfig.url = requestObject.uri as string;
|
||||
}
|
||||
|
||||
if (requestObject.url !== undefined) {
|
||||
axiosConfig.url = requestObject.url as string;
|
||||
}
|
||||
|
||||
if (requestObject.method !== undefined) {
|
||||
axiosConfig.method = requestObject.method as Method;
|
||||
}
|
||||
|
||||
if (requestObject.qs !== undefined && Object.keys(requestObject.qs as object).length > 0) {
|
||||
axiosConfig.params = requestObject.qs as IDataObject;
|
||||
}
|
||||
|
||||
if (requestObject.useQuerystring === true) {
|
||||
axiosConfig.paramsSerializer = (params) => {
|
||||
return stringify(params, { arrayFormat: 'repeat' });
|
||||
};
|
||||
}
|
||||
|
||||
if (requestObject.auth !== undefined) {
|
||||
// Check support for sendImmediately
|
||||
if ((requestObject.auth as IDataObject).bearer !== undefined) {
|
||||
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
Authorization: `Bearer ${(requestObject.auth as IDataObject).bearer}`,
|
||||
});
|
||||
} else {
|
||||
const authObj = requestObject.auth as IDataObject;
|
||||
// Request accepts both user/username and pass/password
|
||||
axiosConfig.auth = {
|
||||
username: (authObj.user || authObj.username) as string,
|
||||
password: (authObj.password || authObj.pass) as string,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Only set header if we have a body, otherwise it may fail
|
||||
if (requestObject.json === true) {
|
||||
// Add application/json headers - do not set charset as it breaks a lot of stuff
|
||||
// only add if no other accept headers was sent.
|
||||
const acceptHeaderExists =
|
||||
axiosConfig.headers === undefined
|
||||
? false
|
||||
: Object.keys(axiosConfig.headers)
|
||||
.map((headerKey) => headerKey.toLowerCase())
|
||||
.includes('accept');
|
||||
if (!acceptHeaderExists) {
|
||||
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
|
||||
Accept: 'application/json',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (requestObject.json === false) {
|
||||
// Prevent json parsing
|
||||
axiosConfig.transformResponse = (res) => res;
|
||||
}
|
||||
|
||||
// Axios will follow redirects by default, so we simply tell it otherwise if needed.
|
||||
if (
|
||||
requestObject.followRedirect === false &&
|
||||
((requestObject.method as string | undefined) || 'get').toLowerCase() === 'get'
|
||||
) {
|
||||
axiosConfig.maxRedirects = 0;
|
||||
}
|
||||
if (
|
||||
requestObject.followAllRedirect === false &&
|
||||
((requestObject.method as string | undefined) || 'get').toLowerCase() !== 'get'
|
||||
) {
|
||||
axiosConfig.maxRedirects = 0;
|
||||
}
|
||||
|
||||
if (requestObject.rejectUnauthorized === false) {
|
||||
axiosConfig.httpsAgent = new Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (requestObject.timeout !== undefined) {
|
||||
axiosConfig.timeout = requestObject.timeout as number;
|
||||
}
|
||||
|
||||
if (requestObject.proxy !== undefined) {
|
||||
axiosConfig.proxy = requestObject.proxy as AxiosProxyConfig;
|
||||
}
|
||||
|
||||
if (requestObject.encoding === null) {
|
||||
// When downloading files, return an arrayBuffer.
|
||||
axiosConfig.responseType = 'arraybuffer';
|
||||
}
|
||||
|
||||
// If we don't set an accept header
|
||||
// Axios forces "application/json, text/plan, */*"
|
||||
// Which causes some nodes like NextCloud to break
|
||||
// as the service returns XML unless requested otherwise.
|
||||
const allHeaders = axiosConfig.headers ? Object.keys(axiosConfig.headers) : [];
|
||||
if (!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'accept')) {
|
||||
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, { accept: '*/*' });
|
||||
}
|
||||
if (
|
||||
axiosConfig.data !== undefined &&
|
||||
!(axiosConfig.data instanceof Buffer) &&
|
||||
!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'content-type')
|
||||
) {
|
||||
// Use default header for application/json
|
||||
// If we don't specify this here, axios will add
|
||||
// application/json; charset=utf-8
|
||||
// and this breaks a lot of stuff
|
||||
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, {
|
||||
'content-type': 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing properties:
|
||||
* encoding (need testing)
|
||||
* gzip (ignored - default already works)
|
||||
* resolveWithFullResponse (implemented elsewhere)
|
||||
* simple (???)
|
||||
*/
|
||||
|
||||
return axiosConfig;
|
||||
}
|
||||
|
||||
async function proxyRequestToAxios(
|
||||
uriOrObject: string | IDataObject,
|
||||
options?: IDataObject,
|
||||
): Promise<any> {
|
||||
// tslint:disable-line:no-any
|
||||
|
||||
// Check if there's a better way of getting this config here
|
||||
if (process.env.N8N_USE_DEPRECATED_REQUEST_LIB) {
|
||||
// @ts-ignore
|
||||
return requestPromiseWithDefaults.call(null, uriOrObject, options);
|
||||
}
|
||||
|
||||
let axiosConfig: AxiosRequestConfig = {};
|
||||
|
||||
let configObject: IDataObject;
|
||||
if (uriOrObject !== undefined && typeof uriOrObject === 'string') {
|
||||
axiosConfig.url = uriOrObject;
|
||||
}
|
||||
if (uriOrObject !== undefined && typeof uriOrObject === 'object') {
|
||||
configObject = uriOrObject;
|
||||
} else {
|
||||
configObject = options || {};
|
||||
}
|
||||
|
||||
axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
axios(axiosConfig)
|
||||
.then((response) => {
|
||||
if (configObject.resolveWithFullResponse === true) {
|
||||
resolve({
|
||||
body: response.data,
|
||||
headers: response.headers,
|
||||
statusCode: response.status,
|
||||
statusMessage: response.statusText,
|
||||
request: response.request,
|
||||
});
|
||||
} else {
|
||||
resolve(response.data);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function searchForHeader(headers: IDataObject, headerName: string) {
|
||||
if (headers === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const headerNames = Object.keys(headers);
|
||||
headerName = headerName.toLowerCase();
|
||||
return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName);
|
||||
}
|
||||
|
||||
function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): AxiosRequestConfig {
|
||||
// Destructure properties with the same name first.
|
||||
const { headers, method, timeout, auth, proxy, url } = n8nRequest;
|
||||
|
||||
const axiosRequest = {
|
||||
headers: headers ?? {},
|
||||
method,
|
||||
timeout,
|
||||
auth,
|
||||
proxy,
|
||||
url,
|
||||
} as AxiosRequestConfig;
|
||||
|
||||
axiosRequest.params = n8nRequest.qs;
|
||||
|
||||
if (n8nRequest.disableFollowRedirect === true) {
|
||||
axiosRequest.maxRedirects = 0;
|
||||
}
|
||||
|
||||
if (n8nRequest.encoding !== undefined) {
|
||||
axiosRequest.responseType = n8nRequest.encoding;
|
||||
}
|
||||
|
||||
if (n8nRequest.skipSslCertificateValidation === true) {
|
||||
axiosRequest.httpsAgent = new Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (n8nRequest.arrayFormat !== undefined) {
|
||||
axiosRequest.paramsSerializer = (params) => {
|
||||
return stringify(params, { arrayFormat: n8nRequest.arrayFormat });
|
||||
};
|
||||
}
|
||||
|
||||
if (n8nRequest.body) {
|
||||
axiosRequest.data = n8nRequest.body;
|
||||
// Let's add some useful header standards here.
|
||||
const existingContentTypeHeaderKey = searchForHeader(axiosRequest.headers, 'content-type');
|
||||
if (existingContentTypeHeaderKey === undefined) {
|
||||
// We are only setting content type headers if the user did
|
||||
// not set it already manually. We're not overriding, even if it's wrong.
|
||||
if (axiosRequest.data instanceof FormData) {
|
||||
axiosRequest.headers = axiosRequest.headers || {};
|
||||
axiosRequest.headers['Content-Type'] = 'multipart/form-data';
|
||||
} else if (axiosRequest.data instanceof URLSearchParams) {
|
||||
axiosRequest.headers = axiosRequest.headers || {};
|
||||
axiosRequest.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (n8nRequest.json) {
|
||||
const key = searchForHeader(axiosRequest.headers, 'accept');
|
||||
// If key exists, then the user has set both accept
|
||||
// header and the json flag. Header should take precedence.
|
||||
if (!key) {
|
||||
axiosRequest.headers.Accept = 'application/json';
|
||||
}
|
||||
}
|
||||
|
||||
const userAgentHeader = searchForHeader(axiosRequest.headers, 'user-agent');
|
||||
// If key exists, then the user has set both accept
|
||||
// header and the json flag. Header should take precedence.
|
||||
if (!userAgentHeader) {
|
||||
axiosRequest.headers['User-Agent'] = 'n8n';
|
||||
}
|
||||
|
||||
return axiosRequest;
|
||||
}
|
||||
|
||||
async function httpRequest(
|
||||
requestParams: IHttpRequestOptions,
|
||||
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
|
||||
// tslint:disable-line:no-any
|
||||
const axiosRequest = convertN8nRequestToAxios(requestParams);
|
||||
const result = await axios(axiosRequest);
|
||||
if (requestParams.returnFullResponse) {
|
||||
return {
|
||||
body: result.data,
|
||||
headers: result.headers,
|
||||
statusCode: result.status,
|
||||
statusMessage: result.statusText,
|
||||
};
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns binary data buffer for given item index and property name.
|
||||
*
|
||||
|
@ -411,7 +837,7 @@ export async function getCredentials(
|
|||
itemIndex?: number,
|
||||
): Promise<ICredentialDataDecryptedObject | undefined> {
|
||||
// Get the NodeType as it has the information if the credentials are required
|
||||
const nodeType = workflow.nodeTypes.getByName(node.type);
|
||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
if (nodeType === undefined) {
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
|
@ -542,7 +968,7 @@ export function getNodeParameter(
|
|||
additionalKeys: IWorkflowDataProxyAdditionalKeys,
|
||||
fallbackValue?: any,
|
||||
): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object {
|
||||
const nodeType = workflow.nodeTypes.getByName(node.type);
|
||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
if (nodeType === undefined) {
|
||||
throw new Error(`Node type "${node.type}" is not known so can not return paramter value!`);
|
||||
}
|
||||
|
@ -668,7 +1094,7 @@ export function getWebhookDescription(
|
|||
workflow: Workflow,
|
||||
node: INode,
|
||||
): IWebhookDescription | undefined {
|
||||
const nodeType = workflow.nodeTypes.getByName(node.type) as INodeType;
|
||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion) as INodeType;
|
||||
|
||||
if (nodeType.description.webhooks === undefined) {
|
||||
// Node does not have any webhooks so return
|
||||
|
@ -775,8 +1201,9 @@ export function getExecutePollFunctions(
|
|||
return workflow.getStaticData(type, node);
|
||||
},
|
||||
helpers: {
|
||||
httpRequest,
|
||||
prepareBinaryData,
|
||||
request: requestPromiseWithDefaults,
|
||||
request: proxyRequestToAxios,
|
||||
async requestOAuth2(
|
||||
this: IAllExecuteFunctions,
|
||||
credentialsType: string,
|
||||
|
@ -880,8 +1307,10 @@ export function getExecuteTriggerFunctions(
|
|||
return workflow.getStaticData(type, node);
|
||||
},
|
||||
helpers: {
|
||||
httpRequest,
|
||||
prepareBinaryData,
|
||||
request: requestPromiseWithDefaults,
|
||||
|
||||
request: proxyRequestToAxios,
|
||||
async requestOAuth2(
|
||||
this: IAllExecuteFunctions,
|
||||
credentialsType: string,
|
||||
|
@ -1071,6 +1500,7 @@ export function getExecuteFunctions(
|
|||
}
|
||||
},
|
||||
helpers: {
|
||||
httpRequest,
|
||||
prepareBinaryData,
|
||||
async getBinaryDataBuffer(
|
||||
itemIndex: number,
|
||||
|
@ -1079,7 +1509,7 @@ export function getExecuteFunctions(
|
|||
): Promise<Buffer> {
|
||||
return getBinaryDataBuffer.call(this, inputData, itemIndex, propertyName, inputIndex);
|
||||
},
|
||||
request: requestPromiseWithDefaults,
|
||||
request: proxyRequestToAxios,
|
||||
async requestOAuth2(
|
||||
this: IAllExecuteFunctions,
|
||||
credentialsType: string,
|
||||
|
@ -1251,8 +1681,9 @@ export function getExecuteSingleFunctions(
|
|||
return workflow.getStaticData(type, node);
|
||||
},
|
||||
helpers: {
|
||||
httpRequest,
|
||||
prepareBinaryData,
|
||||
request: requestPromiseWithDefaults,
|
||||
request: proxyRequestToAxios,
|
||||
async requestOAuth2(
|
||||
this: IAllExecuteFunctions,
|
||||
credentialsType: string,
|
||||
|
@ -1280,6 +1711,14 @@ export function getExecuteSingleFunctions(
|
|||
})(workflow, runExecutionData, connectionInputData, inputData, node, itemIndex);
|
||||
}
|
||||
|
||||
export function getCredentialTestFunctions(): ICredentialTestFunctions {
|
||||
return {
|
||||
helpers: {
|
||||
request: requestPromiseWithDefaults,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the execute functions regular nodes have access to in load-options-function.
|
||||
*
|
||||
|
@ -1357,7 +1796,8 @@ export function getLoadOptionsFunctions(
|
|||
return additionalData.restApiUrl;
|
||||
},
|
||||
helpers: {
|
||||
request: requestPromiseWithDefaults,
|
||||
httpRequest,
|
||||
request: proxyRequestToAxios,
|
||||
async requestOAuth2(
|
||||
this: IAllExecuteFunctions,
|
||||
credentialsType: string,
|
||||
|
@ -1476,7 +1916,8 @@ export function getExecuteHookFunctions(
|
|||
return workflow.getStaticData(type, node);
|
||||
},
|
||||
helpers: {
|
||||
request: requestPromiseWithDefaults,
|
||||
httpRequest,
|
||||
request: proxyRequestToAxios,
|
||||
async requestOAuth2(
|
||||
this: IAllExecuteFunctions,
|
||||
credentialsType: string,
|
||||
|
@ -1621,8 +2062,9 @@ export function getExecuteWebhookFunctions(
|
|||
},
|
||||
prepareOutputData: NodeHelpers.prepareOutputData,
|
||||
helpers: {
|
||||
httpRequest,
|
||||
prepareBinaryData,
|
||||
request: requestPromiseWithDefaults,
|
||||
request: proxyRequestToAxios,
|
||||
async requestOAuth2(
|
||||
this: IAllExecuteFunctions,
|
||||
credentialsType: string,
|
||||
|
|
|
@ -27,6 +27,8 @@ import {
|
|||
IWaitingForExecution,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
LoggerProxy as Logger,
|
||||
NodeApiError,
|
||||
NodeOperationError,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
WorkflowOperationError,
|
||||
|
@ -624,9 +626,9 @@ export class WorkflowExecute {
|
|||
} catch (error) {
|
||||
// Set the error that it can be saved correctly
|
||||
executionError = {
|
||||
...error,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
...(error as NodeOperationError | NodeApiError),
|
||||
message: (error as NodeOperationError | NodeApiError).message,
|
||||
stack: (error as NodeOperationError | NodeApiError).stack,
|
||||
};
|
||||
|
||||
// Set the incoming data of the node that it can be saved correctly
|
||||
|
@ -837,9 +839,9 @@ export class WorkflowExecute {
|
|||
this.runExecutionData.resultData.lastNodeExecuted = executionData.node.name;
|
||||
|
||||
executionError = {
|
||||
...error,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
...(error as NodeOperationError | NodeApiError),
|
||||
message: (error as NodeOperationError | NodeApiError).message,
|
||||
stack: (error as NodeOperationError | NodeApiError).stack,
|
||||
};
|
||||
|
||||
Logger.debug(`Running node "${executionNode.name}" finished with error`, {
|
||||
|
@ -889,6 +891,22 @@ export class WorkflowExecute {
|
|||
}
|
||||
}
|
||||
|
||||
// Merge error information to default output for now
|
||||
// As the new nodes can report the errors in
|
||||
// the `error` property.
|
||||
for (const execution of nodeSuccessData!) {
|
||||
for (const lineResult of execution) {
|
||||
if (lineResult.json.$error !== undefined && lineResult.json.$json !== undefined) {
|
||||
lineResult.error = lineResult.json.$error as NodeApiError | NodeOperationError;
|
||||
lineResult.json = {
|
||||
error: (lineResult.json.$error as NodeApiError | NodeOperationError).message,
|
||||
};
|
||||
} else if (lineResult.error !== undefined) {
|
||||
lineResult.json = { error: lineResult.error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Node executed successfully. So add data and go on.
|
||||
taskData.data = {
|
||||
main: nodeSuccessData,
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
ITaskData,
|
||||
IWorkflowBase,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
NodeHelpers,
|
||||
NodeParameterValue,
|
||||
WorkflowHooks,
|
||||
} from 'n8n-workflow';
|
||||
|
@ -720,11 +721,15 @@ class NodeTypesClass implements INodeTypes {
|
|||
async init(nodeTypes: INodeTypeData): Promise<void> {}
|
||||
|
||||
getAll(): INodeType[] {
|
||||
return Object.values(this.nodeTypes).map((data) => data.type);
|
||||
return Object.values(this.nodeTypes).map((data) => NodeHelpers.getVersionedTypeNode(data.type));
|
||||
}
|
||||
|
||||
getByName(nodeType: string): INodeType {
|
||||
return this.nodeTypes[nodeType].type;
|
||||
return this.getByNameAndVersion(nodeType);
|
||||
}
|
||||
|
||||
getByNameAndVersion(nodeType: string, version?: number): INodeType {
|
||||
return NodeHelpers.getVersionedTypeNode(this.nodeTypes[nodeType].type, version);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-design-system",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"homepage": "https://n8n.io",
|
||||
"author": {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
:size="props.size"
|
||||
:loading="props.loading"
|
||||
:title="props.title || props.label"
|
||||
:class="$style[$options.getClass(props)]"
|
||||
:class="$options.getClass(props, $style)"
|
||||
:round="!props.circle && props.round"
|
||||
:circle="props.circle"
|
||||
:style="$options.styles(props)"
|
||||
|
@ -91,6 +91,10 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
transparentBackground: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
ElButton,
|
||||
|
@ -106,10 +110,16 @@ export default {
|
|||
...(props.fullWidth ? { width: '100%' } : {}),
|
||||
};
|
||||
},
|
||||
getClass(props: { type: string; theme?: string }): string {
|
||||
return props.type === 'text'
|
||||
getClass(props: { type: string; theme?: string, transparentBackground: boolean }, $style: any): string {
|
||||
const theme = props.type === 'text'
|
||||
? 'text'
|
||||
: `${props.type}-${props.theme || 'primary'}`;
|
||||
|
||||
if (props.transparentBackground) {
|
||||
return `${$style[theme]} ${$style['transparent']}`;
|
||||
}
|
||||
|
||||
return $style[theme];
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -289,6 +299,11 @@ $color-danger-shade: lightness(
|
|||
--button-active-border-color: transparent;
|
||||
}
|
||||
|
||||
.transparent {
|
||||
--button-background-color: transparent;
|
||||
--button-active-background-color: transparent;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import N8nInfoTip from './InfoTip.vue';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/InfoTip',
|
||||
component: N8nInfoTip,
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
N8nInfoTip,
|
||||
},
|
||||
template:
|
||||
'<n8n-info-tip>Need help doing something? <a href="/docs" target="_blank">Open docs</a></n8n-info-tip>',
|
||||
});
|
||||
|
||||
export const InputLabel = Template.bind({});
|
36
packages/design-system/src/components/N8nInfoTip/InfoTip.vue
Normal file
36
packages/design-system/src/components/N8nInfoTip/InfoTip.vue
Normal file
|
@ -0,0 +1,36 @@
|
|||
<template functional>
|
||||
<div :class="$style.infotip">
|
||||
<n8n-icon icon="info-circle" /> <span><slot></slot></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
import N8nIcon from '../N8nIcon';
|
||||
|
||||
Vue.component('N8nIcon', N8nIcon);
|
||||
|
||||
export default {
|
||||
name: 'n8n-info-tip',
|
||||
props: {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.infotip {
|
||||
color: var(--color-text-light);
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: var(--font-size-s);
|
||||
word-break: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
svg {
|
||||
font-size: var(--font-size-s);
|
||||
margin-right: var(--spacing-4xs);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,3 @@
|
|||
import InfoTip from './InfoTip.vue';
|
||||
|
||||
export default InfoTip;
|
|
@ -1,16 +1,18 @@
|
|||
<template functional>
|
||||
<div :class="$style.inputLabel">
|
||||
<label>
|
||||
<div :class="$style.label">
|
||||
<span>{{ props.label }}</span>
|
||||
<span v-if="props.tooltipText" :class="$style.infoIcon">
|
||||
<n8n-tooltip :content="props.tooltipText" placement="top">
|
||||
<n8n-icon icon="info-circle" />
|
||||
</n8n-tooltip>
|
||||
</span>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</label>
|
||||
<div :class="$style.label">
|
||||
<span>
|
||||
{{ $options.methods.addTargetBlank(props.label) }}
|
||||
<span v-if="props.required" :class="$style.required">*</span>
|
||||
</span>
|
||||
<span :class="$style.infoIcon" v-if="props.tooltipText">
|
||||
<n8n-tooltip placement="top" :popper-class="$style.tooltipPopper">
|
||||
<n8n-icon icon="question-circle" />
|
||||
<div slot="content" v-html="props.tooltipText"></div>
|
||||
</n8n-tooltip>
|
||||
</span>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -20,6 +22,8 @@ import Vue from 'vue';
|
|||
import N8nTooltip from '../N8nTooltip';
|
||||
import N8nIcon from '../N8nIcon';
|
||||
|
||||
import { addTargetBlank } from '../utils/helpers';
|
||||
|
||||
Vue.component('N8nIcon', N8nIcon);
|
||||
Vue.component('N8nTooltip', N8nTooltip);
|
||||
|
||||
|
@ -33,6 +37,12 @@ export default {
|
|||
tooltipText: {
|
||||
type: String,
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addTargetBlank,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -48,10 +58,22 @@ export default {
|
|||
font-weight: var(--font-weight-bold);
|
||||
font-size: var(--font-size-s);
|
||||
margin-bottom: var(--spacing-2xs);
|
||||
|
||||
* {
|
||||
margin-right: var(--spacing-4xs);
|
||||
}
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
color: var(--color-text-light);
|
||||
display: var(--info-icon-display, none);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tooltipPopper {
|
||||
max-width: 400px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
import N8nMenu from './Menu.vue';
|
||||
import N8nMenuItem from '../N8nMenuItem';
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
export default {
|
||||
title: 'Atoms/Menu',
|
||||
component: N8nMenu,
|
||||
argTypes: {
|
||||
type: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary'],
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
backgrounds: { default: '--color-background-xlight' },
|
||||
},
|
||||
};
|
||||
|
||||
const methods = {
|
||||
onSelect: action('select'),
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
N8nMenu,
|
||||
N8nMenuItem,
|
||||
},
|
||||
template:
|
||||
`<n8n-menu v-bind="$props" @select="onSelect">
|
||||
<n8n-menu-item index="1"> <span slot="title">Item 1</span> </n8n-menu-item>
|
||||
<n8n-menu-item index="2"> <span slot="title">Item 2</span> </n8n-menu-item>
|
||||
</n8n-menu>`,
|
||||
methods,
|
||||
});
|
||||
|
||||
export const Primary = Template.bind({});
|
||||
Primary.parameters = {
|
||||
backgrounds: { default: '--color-background-light' },
|
||||
};
|
||||
|
||||
export const Secondary = Template.bind({});
|
||||
Secondary.args = {
|
||||
type: 'secondary',
|
||||
};
|
70
packages/design-system/src/components/N8nMenu/Menu.vue
Normal file
70
packages/design-system/src/components/N8nMenu/Menu.vue
Normal file
|
@ -0,0 +1,70 @@
|
|||
<template functional>
|
||||
<component
|
||||
:is="$options.components.ElMenu"
|
||||
:defaultActive="props.defaultActive"
|
||||
:collapse="props.collapse"
|
||||
:class="$style[props.type + (props.light ? '-light' : '')]"
|
||||
@select="listeners.select"
|
||||
>
|
||||
<slot></slot>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import ElMenu from 'element-ui/lib/menu';
|
||||
|
||||
export default {
|
||||
name: 'n8n-menu',
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
validator: (value: string): boolean => ['primary', 'secondary'].includes(value),
|
||||
},
|
||||
defaultActive: {
|
||||
type: String,
|
||||
},
|
||||
collapse: {
|
||||
type: Boolean,
|
||||
},
|
||||
light: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
ElMenu,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.menu {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
composes: menu;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
composes: menu;
|
||||
--menu-font-color: var(--color-text-base);
|
||||
--menu-item-font-color: var(--font-weight-regular);
|
||||
--menu-background-color: transparent;
|
||||
--menu-item-hover-font-color: var(--color-primary);
|
||||
--menu-item-active-font-color: var(--color-text-dark);
|
||||
--menu-item-active-background-color: var(--color-foreground-base);
|
||||
--menu-item-border-radius: 4px;
|
||||
--menu-item-height: 38px;
|
||||
}
|
||||
|
||||
.secondary-light {
|
||||
composes: secondary;
|
||||
--menu-item-active-background-color: hsl(
|
||||
var(--color-foreground-base-h),
|
||||
var(--color-foreground-base-s),
|
||||
var(--color-foreground-base-l),
|
||||
0.7
|
||||
);
|
||||
}
|
||||
</style>
|
3
packages/design-system/src/components/N8nMenu/index.js
Normal file
3
packages/design-system/src/components/N8nMenu/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import N8nMenu from './Menu.vue';
|
||||
|
||||
export default N8nMenu;
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
import ElMenuItem from 'element-ui/lib/menu-item';
|
||||
|
||||
ElMenuItem.name = 'n8n-menu-item';
|
||||
|
||||
export default ElMenuItem;
|
||||
</script>
|
|
@ -0,0 +1,3 @@
|
|||
import N8nMenuItem from './MenuItem.vue';
|
||||
|
||||
export default N8nMenuItem;
|
|
@ -121,7 +121,7 @@ const LimitedWidthTemplate = (args, { argTypes }) => ({
|
|||
N8nSelect,
|
||||
N8nOption,
|
||||
},
|
||||
template: '<div style="width:100px;"><n8n-select v-bind="$props" v-model="val" @input="onInput" @change="onChange"><n8n-option value="1">op1</n8n-option><n8n-option value="2">op2 test very long ipsum lipsum test jskdfjsld kjfdslk jfdslkfj lksdjf</n8n-option></n8n-select></div>',
|
||||
template: '<div style="width:100px;"><n8n-select v-bind="$props" v-model="val" @input="onInput" @change="onChange"><n8n-option value="1" label="opt1 11 1111" /><n8n-option value="2" label="opt2 test very long ipsum"/></n8n-select></div>',
|
||||
data() {
|
||||
return {
|
||||
val: '',
|
||||
|
|
|
@ -2,22 +2,28 @@ import N8nButton from './N8nButton';
|
|||
import N8nIcon from './N8nIcon';
|
||||
import N8nIconButton from './N8nIconButton';
|
||||
import N8nInput from './N8nInput';
|
||||
import N8nInputLabel from './N8nInputLabel';
|
||||
import N8nInfoTip from './N8nInfoTip';
|
||||
import N8nInputNumber from './N8nInputNumber';
|
||||
import N8nOption from './N8nOption';
|
||||
import N8nInputLabel from './N8nInputLabel';
|
||||
import N8nMenu from './N8nMenu';
|
||||
import N8nMenuItem from './N8nMenuItem';
|
||||
import N8nSelect from './N8nSelect';
|
||||
import N8nSpinner from './N8nSpinner';
|
||||
import N8nTooltip from './N8nTooltip';
|
||||
import N8nOption from './N8nOption';
|
||||
|
||||
export {
|
||||
N8nButton,
|
||||
N8nIcon,
|
||||
N8nIconButton,
|
||||
N8nInfoTip,
|
||||
N8nInput,
|
||||
N8nInputLabel,
|
||||
N8nInputNumber,
|
||||
N8nOption,
|
||||
N8nMenu,
|
||||
N8nMenuItem,
|
||||
N8nSelect,
|
||||
N8nSpinner,
|
||||
N8nTooltip,
|
||||
N8nOption,
|
||||
};
|
||||
|
|
5
packages/design-system/src/components/utils/helpers.ts
Normal file
5
packages/design-system/src/components/utils/helpers.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export function addTargetBlank(html: string) {
|
||||
return html.includes('href=')
|
||||
? html.replace(/href=/g, 'target="_blank" href=')
|
||||
: html;
|
||||
}
|
|
@ -4,4 +4,6 @@ declare module 'element-ui/lib/tooltip';
|
|||
declare module 'element-ui/lib/input-number';
|
||||
declare module 'element-ui/lib/select';
|
||||
declare module 'element-ui/lib/option';
|
||||
declare module 'element-ui/lib/menu';
|
||||
declare module 'element-ui/lib/menu-item';
|
||||
|
||||
|
|
|
@ -53,7 +53,7 @@
|
|||
);
|
||||
|
||||
--color-success-h: 150.4;
|
||||
--color-success-s: 73.8%;
|
||||
--color-success-s: 60%;
|
||||
--color-success-l: 40.4%;
|
||||
--color-success: hsl(
|
||||
var(--color-success-h),
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
@include mixins.b(checkbox) {
|
||||
color: var.$checkbox-font-color;
|
||||
font-weight: var.$checkbox-font-weight;
|
||||
font-size: var.$font-size-base;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
@ -156,10 +155,6 @@
|
|||
transform: rotate(45deg) scaleY(1);
|
||||
}
|
||||
}
|
||||
|
||||
& + .el-checkbox__label {
|
||||
color: var.$checkbox-checked-font-color;
|
||||
}
|
||||
}
|
||||
@include mixins.when(focus) {
|
||||
/*focus时 视觉上区分*/
|
||||
|
@ -253,7 +248,6 @@
|
|||
@include mixins.e(inner) {
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
font-weight: var.$checkbox-font-weight;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
|
|
|
@ -12,16 +12,20 @@
|
|||
@keyframes v-modal-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(4px) opacity(0);
|
||||
}
|
||||
100% {
|
||||
backdrop-filter: blur(4px) opacity(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes v-modal-out {
|
||||
0% {
|
||||
backdrop-filter: blur(4px) opacity(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
backdrop-filter: blur(4px) opacity(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -31,8 +35,8 @@
|
|||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: var.$popup-modal-opacity;
|
||||
background: var.$popup-modal-background-color;
|
||||
background-color: var.$popup-modal-background-color;
|
||||
backdrop-filter: blur(4px) opacity(1);
|
||||
}
|
||||
|
||||
@include mixins.b(popup-parent) {
|
||||
|
|
|
@ -277,10 +277,8 @@ $icon-color-base: var(--color-info);
|
|||
-------------------------- */
|
||||
/// fontSize||Font|1
|
||||
$checkbox-font-size: 14px;
|
||||
/// fontWeight||Font|1
|
||||
$checkbox-font-weight: $font-weight-primary;
|
||||
/// color||Color|0
|
||||
$checkbox-font-color: var(--color-text-dark);
|
||||
$checkbox-font-color: var(--color-text-base);
|
||||
$checkbox-input-height: 14px;
|
||||
$checkbox-input-width: 14px;
|
||||
/// borderRadius||Border|2
|
||||
|
@ -761,7 +759,8 @@ $dialog-content-font-size: 14px;
|
|||
/// fontLineHeight||LineHeight|2
|
||||
$dialog-font-line-height: $font-line-height-primary;
|
||||
/// padding||Spacing|3
|
||||
$dialog-padding-primary: 20px;
|
||||
$dialog-padding-primary: var(--spacing-l);
|
||||
$dialog-close-top: var(--dialog-close-top, var(--spacing-l));
|
||||
|
||||
/* Table
|
||||
-------------------------- */
|
||||
|
@ -804,9 +803,9 @@ $pagination-hover-color: var(--color-primary);
|
|||
/* Popup
|
||||
-------------------------- */
|
||||
/// color||Color|0
|
||||
$popup-modal-background-color: $color-black;
|
||||
$popup-modal-background-color: hsla(247,14%, 70%, 0.75);
|
||||
/// opacity||Other|1
|
||||
$popup-modal-opacity: 0.5;
|
||||
$popup-modal-opacity: 0.65;
|
||||
|
||||
/* Popover
|
||||
-------------------------- */
|
||||
|
@ -852,9 +851,9 @@ $tag-warning-color: var(--color-warning);
|
|||
/// color||Color|0
|
||||
$tag-danger-color: var(--color-danger);
|
||||
/// fontSize||Font|1
|
||||
$tag-font-size: 12px;
|
||||
$tag-font-size: var(--font-size-s);
|
||||
$tag-border-radius: 4px;
|
||||
$tag-padding: 0 10px;
|
||||
$tag-padding: 16px;
|
||||
|
||||
/* Tree
|
||||
-------------------------- */
|
||||
|
@ -924,11 +923,17 @@ $steps-padding: 20px;
|
|||
--------------------------*/
|
||||
/// fontSize||Font|1
|
||||
$menu-item-font-size: $font-size-base;
|
||||
$menu-item-font-weight: var(--menu-item-font-color, 300);
|
||||
/// color||Color|0
|
||||
$menu-item-font-color: var(--color-text-dark);
|
||||
$menu-item-font-color: var(--menu-font-color, var(--color-text-dark));
|
||||
/// color||Color|0
|
||||
$menu-background-color: $color-white;
|
||||
$menu-item-hover-fill: $color-primary-light-9;
|
||||
$menu-background-color: var(--menu-background-color, var(--color-background-xlight));
|
||||
$menu-item-hover-fill: var(--menu-item-hover-fill, transparent);
|
||||
$menu-item-hover-font-color: var(--menu-item-hover-font-color, var(--color-text-dark));
|
||||
$menu-item-active-font-color: var(--menu-item-active-font-color, var(--color-primary));
|
||||
$menu-item-active-background-color: var(--menu-item-active-background-color, transparent);
|
||||
$menu-item-border-radius: var(--menu-item-border-radius, 0);
|
||||
$menu-item-height: var(--menu-item-height, 56px);
|
||||
|
||||
/* Rate
|
||||
--------------------------*/
|
||||
|
|
|
@ -32,12 +32,12 @@
|
|||
|
||||
@include mixins.e(header) {
|
||||
padding: var.$dialog-padding-primary;
|
||||
padding-bottom: 10px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
@include mixins.e(headerbtn) {
|
||||
position: absolute;
|
||||
top: var.$dialog-padding-primary;
|
||||
top: var.$dialog-close-top;
|
||||
right: var.$dialog-padding-primary;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
|
@ -65,8 +65,8 @@
|
|||
}
|
||||
|
||||
@include mixins.e(body) {
|
||||
padding: (var.$dialog-padding-primary + 10px) var.$dialog-padding-primary;
|
||||
color: var(--color-text-dark);
|
||||
padding: var.$dialog-padding-primary;
|
||||
color: var(--color-text-base);
|
||||
font-size: var.$dialog-content-font-size;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
|
|
@ -113,8 +113,7 @@ $directions: rtl, ltr, ttb, btt;
|
|||
background-color: var.$dialog-background-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 10px -5px rgba(0, 0, 0, 0.2),
|
||||
0 16px 24px 2px rgba(0, 0, 0, 0.14), 0 6px 30px 5px rgba(0, 0, 0, 0.12);
|
||||
box-shadow: 0px 6px 16px rgb(68 28 23 / 6%);
|
||||
overflow: hidden;
|
||||
outline: 0;
|
||||
|
||||
|
@ -183,10 +182,12 @@ $directions: rtl, ltr, ttb, btt;
|
|||
|
||||
&.ltr {
|
||||
left: 0;
|
||||
border-right: var(--border-base);
|
||||
}
|
||||
|
||||
&.rtl {
|
||||
right: 0;
|
||||
border-left: var(--border-base);
|
||||
}
|
||||
|
||||
&.ttb {
|
||||
|
|
|
@ -4,10 +4,11 @@
|
|||
@use "common/transition";
|
||||
|
||||
@mixin menu-item {
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
height: var.$menu-item-height;
|
||||
line-height: var.$menu-item-height;
|
||||
font-size: var.$menu-item-font-size;
|
||||
color: var.$menu-item-font-color;
|
||||
font-weight: var.$menu-item-font-weight;
|
||||
padding: 0 20px;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
|
@ -15,10 +16,7 @@
|
|||
transition: border-color 0.3s, background-color 0.3s, color 0.3s;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
|
||||
* {
|
||||
vertical-align: middle;
|
||||
}
|
||||
border-radius: var.$menu-item-border-radius;
|
||||
|
||||
i {
|
||||
color: var(--color-text-light);
|
||||
|
@ -28,6 +26,7 @@
|
|||
&:focus {
|
||||
outline: none;
|
||||
background-color: var.$menu-item-hover-fill;
|
||||
color: var.$menu-item-hover-font-color;
|
||||
}
|
||||
|
||||
@include mixins.when(disabled) {
|
||||
|
@ -38,7 +37,6 @@
|
|||
}
|
||||
|
||||
@include mixins.b(menu) {
|
||||
border-right: solid 1px #e6e6e6;
|
||||
list-style: none;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
|
@ -208,7 +206,9 @@
|
|||
vertical-align: middle;
|
||||
}
|
||||
@include mixins.when(active) {
|
||||
color: var(--color-primary);
|
||||
color: var.$menu-item-active-font-color;
|
||||
background-color: var.$menu-item-active-background-color;
|
||||
|
||||
i {
|
||||
color: inherit;
|
||||
}
|
||||
|
|
|
@ -226,7 +226,7 @@ hr {
|
|||
height: 1px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-foreground-light);
|
||||
margin: 1em 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,10 @@
|
|||
|
||||
.el-select__tags {
|
||||
overflow-x: scroll;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.el-select__tags > span {
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
background-color: var.$color-primary-lighter;
|
||||
border-color: var.$color-primary-light-5;
|
||||
color: var.$color-primary-light-1;
|
||||
font-weight: var(--font-weight-regular);
|
||||
|
||||
@include mixins.when(hit) {
|
||||
border-color: var.$tag-primary-color;
|
||||
|
@ -37,9 +38,9 @@
|
|||
}
|
||||
|
||||
&.el-tag--success {
|
||||
background-color: var.$color-success-lighter;
|
||||
border-color: var.$color-success-light-5;
|
||||
color: var.$color-success-light-3;
|
||||
background-color: var(--color-success-tint-2);
|
||||
border-color: var(--color-success-tint-1);
|
||||
color: var(--color-success);
|
||||
|
||||
@include mixins.when(hit) {
|
||||
border-color: var.$tag-success-color;
|
||||
|
@ -94,9 +95,7 @@
|
|||
@include mixins.b(tag) {
|
||||
@include genTheme();
|
||||
display: inline-block;
|
||||
height: 32px;
|
||||
padding: var.$tag-padding;
|
||||
line-height: 30px;
|
||||
font-size: var.$tag-font-size;
|
||||
color: var.$tag-primary-color;
|
||||
border-width: 1px;
|
||||
|
@ -132,8 +131,7 @@
|
|||
}
|
||||
|
||||
@include mixins.m(medium) {
|
||||
height: 28px;
|
||||
line-height: 26px;
|
||||
padding: 12px;
|
||||
|
||||
.el-icon-close {
|
||||
transform: scale(0.8);
|
||||
|
|
|
@ -7,10 +7,6 @@
|
|||
outline-width: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
@include mixins.e(popper) {
|
||||
position: absolute;
|
||||
border-radius: 4px;
|
||||
|
@ -20,6 +16,11 @@
|
|||
line-height: 1.2;
|
||||
min-width: 10px;
|
||||
word-wrap: break-word;
|
||||
font-weight: var(--font-weight-regular);
|
||||
|
||||
a {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.popper__arrow,
|
||||
.popper__arrow::after {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-editor-ui",
|
||||
"version": "0.104.0",
|
||||
"version": "0.107.1",
|
||||
"description": "Workflow Editor UI for n8n",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"homepage": "https://n8n.io",
|
||||
|
@ -25,13 +25,13 @@
|
|||
"test:unit": "vue-cli-service test:unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"n8n-design-system": "~0.1.0",
|
||||
"@fontsource/open-sans": "^4.5.0",
|
||||
"n8n-design-system": "~0.3.0",
|
||||
"timeago.js": "^4.0.2",
|
||||
"v-click-outside": "^3.1.2",
|
||||
"vue-fragment": "^1.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@beyonk/google-fonts-webpack-plugin": "^1.5.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.35",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.3",
|
||||
"@fortawesome/vue-fontawesome": "^2.0.2",
|
||||
|
@ -71,7 +71,7 @@
|
|||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.set": "^4.3.2",
|
||||
"n8n-workflow": "~0.66.0",
|
||||
"n8n-workflow": "~0.70.0",
|
||||
"sass": "^1.26.5",
|
||||
"normalize-wheel": "^1.0.1",
|
||||
"prismjs": "^1.17.1",
|
||||
|
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
@ -13,6 +13,7 @@ import {
|
|||
INodeParameters,
|
||||
INodePropertyOptions,
|
||||
INodeTypeDescription,
|
||||
INodeTypeNameVersion,
|
||||
IRunExecutionData,
|
||||
IRun,
|
||||
IRunData,
|
||||
|
@ -129,9 +130,9 @@ export interface IRestApi {
|
|||
stopCurrentExecution(executionId: string): Promise<IExecutionsStopData>;
|
||||
makeRestApiRequest(method: string, endpoint: string, data?: any): Promise<any>; // tslint:disable-line:no-any
|
||||
getSettings(): Promise<IN8nUISettings>;
|
||||
getNodeTypes(): Promise<INodeTypeDescription[]>;
|
||||
getNodesInformation(nodeList: string[]): Promise<INodeTypeDescription[]>;
|
||||
getNodeParameterOptions(nodeType: string, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise<INodePropertyOptions[]>;
|
||||
getNodeTypes(onlyLatest?: boolean): Promise<INodeTypeDescription[]>;
|
||||
getNodesInformation(nodeInfos: INodeTypeNameVersion[]): Promise<INodeTypeDescription[]>;
|
||||
getNodeParameterOptions(nodeTypeAndVersion: INodeTypeNameVersion, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise<INodePropertyOptions[]>;
|
||||
removeTestWebhook(workflowId: string): Promise<boolean>;
|
||||
runWorkflow(runData: IStartRunData): Promise<IExecutionPushResponse>;
|
||||
createNewWorkflow(sendData: IWorkflowDataUpdate): Promise<IWorkflowDb>;
|
||||
|
@ -140,19 +141,10 @@ export interface IRestApi {
|
|||
getWorkflow(id: string): Promise<IWorkflowDb>;
|
||||
getWorkflows(filter?: object): Promise<IWorkflowShortResponse[]>;
|
||||
getWorkflowFromUrl(url: string): Promise<IWorkflowDb>;
|
||||
createNewCredentials(sendData: ICredentialsDecrypted): Promise<ICredentialsResponse>;
|
||||
deleteCredentials(id: string): Promise<void>;
|
||||
updateCredentials(id: string, data: ICredentialsDecrypted): Promise<ICredentialsResponse>;
|
||||
getAllCredentials(filter?: object): Promise<ICredentialsResponse[]>;
|
||||
getCredentials(id: string, includeData?: boolean): Promise<ICredentialsDecryptedResponse | ICredentialsResponse | undefined>;
|
||||
getCredentialTypes(): Promise<ICredentialType[]>;
|
||||
getExecution(id: string): Promise<IExecutionResponse>;
|
||||
deleteExecutions(sendData: IExecutionDeleteFilter): Promise<void>;
|
||||
retryExecution(id: string, loadWorkflow?: boolean): Promise<boolean>;
|
||||
getTimezones(): Promise<IDataObject>;
|
||||
oAuth1CredentialAuthorize(sendData: ICredentialsResponse): Promise<string>;
|
||||
oAuth2CredentialAuthorize(sendData: ICredentialsResponse): Promise<string>;
|
||||
oAuth2Callback(code: string, state: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface IBinaryDisplayData {
|
||||
|
@ -163,13 +155,6 @@ export interface IBinaryDisplayData {
|
|||
runIndex: number;
|
||||
}
|
||||
|
||||
export interface ICredentialsCreatedEvent {
|
||||
data: ICredentialsDecryptedResponse;
|
||||
options: {
|
||||
closeDialog: boolean,
|
||||
};
|
||||
}
|
||||
|
||||
export interface IStartRunData {
|
||||
workflowData: IWorkflowData;
|
||||
startNodes?: string[];
|
||||
|
@ -585,8 +570,6 @@ export interface IRootState {
|
|||
activeActions: string[];
|
||||
activeNode: string | null;
|
||||
baseUrl: string;
|
||||
credentials: ICredentialsResponse[] | null;
|
||||
credentialTypes: ICredentialType[] | null;
|
||||
endpointWebhook: string;
|
||||
endpointWebhookTest: string;
|
||||
executionId: string | null;
|
||||
|
@ -595,7 +578,6 @@ export interface IRootState {
|
|||
pushConnectionActive: boolean;
|
||||
saveDataErrorExecution: string;
|
||||
saveDataSuccessExecution: string;
|
||||
saveManualExecutions: boolean;
|
||||
timezone: string;
|
||||
stateIsDirty: boolean;
|
||||
executionTimeout: number;
|
||||
|
@ -618,6 +600,19 @@ export interface IRootState {
|
|||
instanceId: string;
|
||||
}
|
||||
|
||||
export interface ICredentialTypeMap {
|
||||
[name: string]: ICredentialType;
|
||||
}
|
||||
|
||||
export interface ICredentialMap {
|
||||
[name: string]: ICredentialsResponse;
|
||||
}
|
||||
|
||||
export interface ICredentialsState {
|
||||
credentialTypes: ICredentialTypeMap;
|
||||
credentials: ICredentialMap;
|
||||
}
|
||||
|
||||
export interface ITagsState {
|
||||
tags: { [id: string]: ITag };
|
||||
isLoading: boolean;
|
||||
|
@ -627,6 +622,8 @@ export interface ITagsState {
|
|||
|
||||
export interface IModalState {
|
||||
open: boolean;
|
||||
mode?: string | null;
|
||||
activeId?: string | null;
|
||||
}
|
||||
|
||||
export interface IUiState {
|
||||
|
|
53
packages/editor-ui/src/api/credentials.ts
Normal file
53
packages/editor-ui/src/api/credentials.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { ICredentialsDecryptedResponse, ICredentialsResponse, IRestApiContext } from '@/Interface';
|
||||
import { makeRestApiRequest } from './helpers';
|
||||
import {
|
||||
ICredentialsDecrypted,
|
||||
ICredentialType,
|
||||
IDataObject,
|
||||
NodeCredentialTestRequest,
|
||||
NodeCredentialTestResult,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export async function getCredentialTypes(context: IRestApiContext): Promise<ICredentialType[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/credential-types');
|
||||
}
|
||||
|
||||
export async function getCredentialsNewName(context: IRestApiContext, name?: string): Promise<{name: string}> {
|
||||
return await makeRestApiRequest(context, 'GET', '/credentials/new', name ? { name } : {});
|
||||
}
|
||||
|
||||
export async function getAllCredentials(context: IRestApiContext): Promise<ICredentialType[]> {
|
||||
return await makeRestApiRequest(context, 'GET', '/credentials');
|
||||
}
|
||||
|
||||
export async function createNewCredential(context: IRestApiContext, data: ICredentialsDecrypted): Promise<ICredentialsResponse> {
|
||||
return makeRestApiRequest(context, 'POST', `/credentials`, data as unknown as IDataObject);
|
||||
}
|
||||
|
||||
export async function deleteCredential(context: IRestApiContext, id: string): Promise<boolean> {
|
||||
return makeRestApiRequest(context, 'DELETE', `/credentials/${id}`);
|
||||
}
|
||||
|
||||
export async function updateCredential(context: IRestApiContext, id: string, data: ICredentialsDecrypted): Promise<ICredentialsResponse> {
|
||||
return makeRestApiRequest(context, 'PATCH', `/credentials/${id}`, data as unknown as IDataObject);
|
||||
}
|
||||
|
||||
export async function getCredentialData(context: IRestApiContext, id: string): Promise<ICredentialsDecryptedResponse | ICredentialsResponse | undefined> {
|
||||
return makeRestApiRequest(context, 'GET', `/credentials/${id}`, {
|
||||
includeData: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Get OAuth1 Authorization URL using the stored credentials
|
||||
export async function oAuth1CredentialAuthorize(context: IRestApiContext, data: ICredentialsResponse): Promise<string> {
|
||||
return makeRestApiRequest(context, 'GET', `/oauth1-credential/auth`, data as unknown as IDataObject);
|
||||
}
|
||||
|
||||
// Get OAuth2 Authorization URL using the stored credentials
|
||||
export async function oAuth2CredentialAuthorize(context: IRestApiContext, data: ICredentialsResponse): Promise<string> {
|
||||
return makeRestApiRequest(context, 'GET', `/oauth2-credential/auth`, data as unknown as IDataObject);
|
||||
}
|
||||
|
||||
export async function testCredential(context: IRestApiContext, data: NodeCredentialTestRequest): Promise<NodeCredentialTestResult> {
|
||||
return makeRestApiRequest(context, 'POST', '/credentials-test', data as unknown as IDataObject);
|
||||
}
|
147
packages/editor-ui/src/components/Banner.vue
Normal file
147
packages/editor-ui/src/components/Banner.vue
Normal file
|
@ -0,0 +1,147 @@
|
|||
<template>
|
||||
<el-tag
|
||||
:type="theme"
|
||||
size="medium"
|
||||
:disable-transitions="true"
|
||||
:class="$style.container"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="theme === 'success' ? 'check-circle' : 'exclamation-triangle'"
|
||||
:class="theme === 'success' ? $style.icon : $style.dangerIcon"
|
||||
/>
|
||||
<div
|
||||
:class="$style.banner"
|
||||
>
|
||||
<div :class="$style.content">
|
||||
<div>
|
||||
<span
|
||||
:class="theme === 'success' ? $style.message : $style.dangerMessage"
|
||||
>
|
||||
{{ message }}
|
||||
</span>
|
||||
<a v-if="details && !expanded" :class="$style.expandButton" @click="expand">More details</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n8n-button
|
||||
v-if="buttonLabel"
|
||||
:label="buttonLoading && buttonLoadingLabel ? buttonLoadingLabel : buttonLabel"
|
||||
:title="buttonTitle"
|
||||
:theme="theme"
|
||||
:loading="buttonLoading"
|
||||
size="small"
|
||||
type="outline"
|
||||
:transparentBackground="true"
|
||||
@click.stop="onClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="expanded" :class="$style.details">
|
||||
{{details}}
|
||||
</div>
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'Banner',
|
||||
data() {
|
||||
return {
|
||||
expanded: false,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
theme: {
|
||||
type: String,
|
||||
validator: (value: string): boolean =>
|
||||
['success', 'danger'].indexOf(value) !== -1,
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
},
|
||||
buttonLoadingLabel: {
|
||||
type: String,
|
||||
},
|
||||
buttonTitle: {
|
||||
type: String,
|
||||
},
|
||||
details: {
|
||||
type: String,
|
||||
},
|
||||
buttonLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
expand() {
|
||||
this.expanded = true;
|
||||
},
|
||||
onClick() {
|
||||
this.expanded = false;
|
||||
this.$emit('click');
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 18px;
|
||||
}
|
||||
|
||||
.dangerIcon {
|
||||
composes: icon;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding-left: 40px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
white-space: normal;
|
||||
line-height: var(--font-line-height-regular);
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dangerMessage {
|
||||
composes: message;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-grow: 1;
|
||||
min-height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expandButton {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.details {
|
||||
composes: message;
|
||||
margin-top: var(--spacing-3xs);
|
||||
color: var(--color-text-base);
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
|
||||
</style>
|
96
packages/editor-ui/src/components/CopyInput.vue
Normal file
96
packages/editor-ui/src/components/CopyInput.vue
Normal file
|
@ -0,0 +1,96 @@
|
|||
<template>
|
||||
<div>
|
||||
<n8n-input-label :label="label">
|
||||
<div :class="$style.copyText" @click="copy">
|
||||
<span>{{ copyContent }}</span>
|
||||
<div :class="$style.copyButton"><span>{{ copyButtonText }}</span></div>
|
||||
</div>
|
||||
</n8n-input-label>
|
||||
<div :class="$style.subtitle">{{ subtitle }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import { copyPaste } from './mixins/copyPaste';
|
||||
import { showMessage } from './mixins/showMessage';
|
||||
|
||||
export default mixins(copyPaste, showMessage).extend({
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
},
|
||||
copyContent: {
|
||||
type: String,
|
||||
},
|
||||
copyButtonText: {
|
||||
type: String,
|
||||
},
|
||||
successMessage: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
copy(): void {
|
||||
this.copyToClipboard(this.$props.copyContent);
|
||||
|
||||
this.$showMessage({
|
||||
title: 'Copied',
|
||||
message: this.$props.successMessage,
|
||||
type: 'success',
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
||||
.copyText {
|
||||
span {
|
||||
font-family: Monaco, Consolas;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
padding: var(--spacing-xs);
|
||||
background-color: var(--color-background-light);
|
||||
border: var(--border-base);
|
||||
border-radius: var(--border-radius-base);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
font-weight: var(--font-weight-regular);
|
||||
|
||||
&:hover {
|
||||
--display-copy-button: flex;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
display: var(--display-copy-button, none);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: var(--spacing-xs);
|
||||
background-color: var(--color-background-light);
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
border-radius: var(--border-radius-base);
|
||||
|
||||
span {
|
||||
font-family: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: var(--spacing-2xs);
|
||||
font-size: var(--font-size-2xs);
|
||||
line-height: var(--font-line-height-loose);
|
||||
font-weight: var(--font-weight-regular);
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,189 @@
|
|||
<template>
|
||||
<div :class="$style.container">
|
||||
<banner
|
||||
v-show="showValidationWarning"
|
||||
theme="danger"
|
||||
message="Please check the errors below"
|
||||
/>
|
||||
|
||||
<banner
|
||||
v-if="authError && !showValidationWarning"
|
||||
theme="danger"
|
||||
message="Couldn’t connect with these settings"
|
||||
:details="authError"
|
||||
buttonLabel="Retry"
|
||||
buttonLoadingLabel="Retrying"
|
||||
buttonTitle="Retry credentials test"
|
||||
:buttonLoading="isRetesting"
|
||||
@click="$emit('retest')"
|
||||
/>
|
||||
|
||||
<banner
|
||||
v-show="showOAuthSuccessBanner && !showValidationWarning"
|
||||
theme="success"
|
||||
message="Account connected"
|
||||
buttonLabel="Reconnect"
|
||||
buttonTitle="Reconnect OAuth Credentials"
|
||||
@click="$emit('oauth')"
|
||||
/>
|
||||
|
||||
<banner
|
||||
v-show="testedSuccessfully && !showValidationWarning"
|
||||
theme="success"
|
||||
message="Connection tested successfully"
|
||||
buttonLabel="Retry"
|
||||
buttonLoadingLabel="Retrying"
|
||||
buttonTitle="Retry credentials test"
|
||||
:buttonLoading="isRetesting"
|
||||
@click="$emit('retest')"
|
||||
/>
|
||||
|
||||
<n8n-info-tip v-if="documentationUrl && credentialProperties.length">
|
||||
Need help filling out these fields?
|
||||
<a :href="documentationUrl" target="_blank">Open docs</a>
|
||||
</n8n-info-tip>
|
||||
|
||||
<CopyInput
|
||||
v-if="isOAuthType && credentialProperties.length"
|
||||
label="OAuth Redirect URL"
|
||||
:copyContent="oAuthCallbackUrl"
|
||||
copyButtonText="Click to copy"
|
||||
:subtitle="`In ${appName}, use the URL above when prompted to enter an OAuth callback or redirect URL`"
|
||||
successMessage="Redirect URL copied to clipboard"
|
||||
/>
|
||||
|
||||
<CredentialInputs
|
||||
v-if="credentialType"
|
||||
:credentialData="credentialData"
|
||||
:credentialProperties="credentialProperties"
|
||||
:documentationUrl="documentationUrl"
|
||||
:showValidationWarnings="showValidationWarning"
|
||||
@change="onDataChange"
|
||||
/>
|
||||
|
||||
<OauthButton
|
||||
v-if="isOAuthType && requiredPropertiesFilled && !isOAuthConnected"
|
||||
:isGoogleOAuthType="isGoogleOAuthType"
|
||||
@click="$emit('oauth')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ICredentialType } from 'n8n-workflow';
|
||||
import { getAppNameFromCredType } from '../helpers';
|
||||
|
||||
import Vue from 'vue';
|
||||
import Banner from '../Banner.vue';
|
||||
import CopyInput from '../CopyInput.vue';
|
||||
import CredentialInputs from './CredentialInputs.vue';
|
||||
import OauthButton from './OauthButton.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'CredentialConfig',
|
||||
components: {
|
||||
Banner,
|
||||
CopyInput,
|
||||
CredentialInputs,
|
||||
OauthButton,
|
||||
},
|
||||
props: {
|
||||
credentialType: {
|
||||
},
|
||||
credentialProperties: {
|
||||
type: Array,
|
||||
},
|
||||
parentTypes: {
|
||||
type: Array,
|
||||
},
|
||||
credentialData: {
|
||||
},
|
||||
showValidationWarning: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
authError: {
|
||||
type: String,
|
||||
},
|
||||
testedSuccessfully: {
|
||||
type: Boolean,
|
||||
},
|
||||
isOAuthType: {
|
||||
type: Boolean,
|
||||
},
|
||||
isOAuthConnected: {
|
||||
type: Boolean,
|
||||
},
|
||||
isRetesting: {
|
||||
type: Boolean,
|
||||
},
|
||||
requiredPropertiesFilled: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
appName(): string {
|
||||
if (!this.credentialType) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const appName = getAppNameFromCredType(
|
||||
(this.credentialType as ICredentialType).displayName,
|
||||
);
|
||||
|
||||
return appName || "the service you're connecting to";
|
||||
},
|
||||
credentialTypeName(): string {
|
||||
return (this.credentialType as ICredentialType).name;
|
||||
},
|
||||
documentationUrl(): string {
|
||||
const type = this.credentialType as ICredentialType;
|
||||
|
||||
if (!type || !type.documentationUrl) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (type.documentationUrl.startsWith('https://') || type.documentationUrl.startsWith('http://')) {
|
||||
return type.documentationUrl;
|
||||
}
|
||||
|
||||
return `https://docs.n8n.io/credentials/${type.documentationUrl}/?utm_source=n8n_app&utm_medium=left_nav_menu&utm_campaign=create_new_credentials_modal`;
|
||||
},
|
||||
isGoogleOAuthType(): boolean {
|
||||
return this.credentialTypeName === 'googleOAuth2Api' || this.parentTypes.includes('googleOAuth2Api');
|
||||
},
|
||||
oAuthCallbackUrl(): string {
|
||||
const oauthType =
|
||||
this.credentialTypeName === 'oAuth2Api' ||
|
||||
this.parentTypes.includes('oAuth2Api')
|
||||
? 'oauth2'
|
||||
: 'oauth1';
|
||||
return this.$store.getters.oauthCallbackUrls[oauthType];
|
||||
},
|
||||
showOAuthSuccessBanner(): boolean {
|
||||
return this.isOAuthType && this.requiredPropertiesFilled && this.isOAuthConnected && !this.authError;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onDataChange(event: { name: string; value: string | number | boolean | Date | null }): void {
|
||||
this.$emit('change', event);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
showOAuthSuccessBanner(newValue, oldValue) {
|
||||
if (newValue && !oldValue) {
|
||||
this.$emit('scrollToTop');
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
> * {
|
||||
margin-bottom: var(--spacing-l);
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,854 @@
|
|||
<template>
|
||||
<Modal
|
||||
:name="modalName"
|
||||
:customClass="$style.credentialModal"
|
||||
:eventBus="modalBus"
|
||||
:loading="loading"
|
||||
:beforeClose="beforeClose"
|
||||
width="70%"
|
||||
height="80%"
|
||||
>
|
||||
<template slot="header">
|
||||
<div v-if="credentialType" :class="$style.header">
|
||||
<div :class="$style.credInfo">
|
||||
<div :class="$style.credIcon">
|
||||
<CredentialIcon :credentialTypeName="credentialTypeName" />
|
||||
</div>
|
||||
<InlineNameEdit
|
||||
:name="credentialName"
|
||||
:subtitle="credentialType.displayName"
|
||||
type="Credential"
|
||||
@input="onNameEdit"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.credActions">
|
||||
<n8n-icon-button
|
||||
v-if="currentCredential"
|
||||
size="medium"
|
||||
title="Delete"
|
||||
icon="trash"
|
||||
type="text"
|
||||
:disabled="isSaving"
|
||||
:loading="isDeleting"
|
||||
@click="deleteCredential"
|
||||
/>
|
||||
<SaveButton
|
||||
v-if="hasUnsavedChanges || credentialId"
|
||||
:saved="!hasUnsavedChanges && !isTesting"
|
||||
:isSaving="isSaving || isTesting"
|
||||
:savingLabel="isTesting ? 'Testing' : 'Saving'"
|
||||
@click="saveCredential"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
</template>
|
||||
<template slot="content">
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.sidebar">
|
||||
<n8n-menu
|
||||
type="secondary"
|
||||
@select="onTabSelect"
|
||||
defaultActive="connection"
|
||||
:light="true"
|
||||
>
|
||||
<n8n-menu-item index="connection" :class="$style.credTab"
|
||||
><span slot="title">Connection</span></n8n-menu-item
|
||||
>
|
||||
<n8n-menu-item index="details" :class="$style.credTab"
|
||||
><span slot="title">Details</span></n8n-menu-item
|
||||
>
|
||||
</n8n-menu>
|
||||
</div>
|
||||
<div v-if="activeTab === 'connection'" :class="$style.mainContent" ref="content">
|
||||
<CredentialConfig
|
||||
:credentialType="credentialType"
|
||||
:credentialProperties="credentialProperties"
|
||||
:credentialData="credentialData"
|
||||
:showValidationWarning="showValidationWarning"
|
||||
:authError="authError"
|
||||
:testedSuccessfully="testedSuccessfully"
|
||||
:isOAuthType="isOAuthType"
|
||||
:isOAuthConnected="isOAuthConnected"
|
||||
:isRetesting="isRetesting"
|
||||
:parentTypes="parentTypes"
|
||||
:requiredPropertiesFilled="requiredPropertiesFilled"
|
||||
@change="onDataChange"
|
||||
@oauth="oAuthCredentialAuthorize"
|
||||
@retest="retestCredential"
|
||||
@scrollToTop="scrollToTop"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="activeTab === 'details'" :class="$style.mainContent">
|
||||
<CredentialInfo
|
||||
:nodeAccess="nodeAccess"
|
||||
:nodesWithAccess="nodesWithAccess"
|
||||
:currentCredential="currentCredential"
|
||||
@accessChange="onNodeAccessChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
import {
|
||||
ICredentialsDecryptedResponse,
|
||||
ICredentialsResponse,
|
||||
} from '@/Interface';
|
||||
|
||||
import {
|
||||
CredentialInformation,
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialNodeAccess,
|
||||
ICredentialsDecrypted,
|
||||
ICredentialType,
|
||||
INodeParameters,
|
||||
INodeProperties,
|
||||
INodeTypeDescription,
|
||||
NodeCredentialTestResult,
|
||||
NodeHelpers,
|
||||
} from 'n8n-workflow';
|
||||
import CredentialIcon from '../CredentialIcon.vue';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import { nodeHelpers } from '../mixins/nodeHelpers';
|
||||
import { showMessage } from '../mixins/showMessage';
|
||||
|
||||
import CredentialConfig from './CredentialConfig.vue';
|
||||
import CredentialInfo from './CredentialInfo.vue';
|
||||
import SaveButton from '../SaveButton.vue';
|
||||
import Modal from '../Modal.vue';
|
||||
import InlineNameEdit from '../InlineNameEdit.vue';
|
||||
|
||||
interface NodeAccessMap {
|
||||
[nodeType: string]: ICredentialNodeAccess | null;
|
||||
}
|
||||
|
||||
export default mixins(showMessage, nodeHelpers).extend({
|
||||
name: 'CredentialsDetail',
|
||||
components: {
|
||||
CredentialConfig,
|
||||
CredentialIcon,
|
||||
CredentialInfo,
|
||||
InlineNameEdit,
|
||||
Modal,
|
||||
SaveButton,
|
||||
},
|
||||
props: {
|
||||
modalName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
activeId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeTab: 'connection',
|
||||
authError: '',
|
||||
credentialId: '',
|
||||
credentialName: '',
|
||||
credentialData: {} as ICredentialDataDecryptedObject,
|
||||
modalBus: new Vue(),
|
||||
nodeAccess: {} as NodeAccessMap,
|
||||
isDeleting: false,
|
||||
isSaving: false,
|
||||
isTesting: false,
|
||||
hasUnsavedChanges: false,
|
||||
loading: true,
|
||||
showValidationWarning: false,
|
||||
testedSuccessfully: false,
|
||||
isRetesting: false,
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
this.nodeAccess = this.nodesWithAccess.reduce(
|
||||
(accu: NodeAccessMap, node: { name: string }) => {
|
||||
if (this.mode === 'new') {
|
||||
accu[node.name] = { nodeType: node.name }; // enable all nodes by default
|
||||
} else {
|
||||
accu[node.name] = null;
|
||||
}
|
||||
|
||||
return accu;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
if (this.mode === 'new') {
|
||||
this.credentialName = await this.$store.dispatch(
|
||||
'credentials/getNewCredentialName',
|
||||
{ credentialTypeName: this.credentialTypeName },
|
||||
);
|
||||
} else {
|
||||
await this.loadCurrentCredential();
|
||||
}
|
||||
|
||||
if (this.credentialType) {
|
||||
for (const property of this.credentialType.properties) {
|
||||
if (!this.credentialData.hasOwnProperty(property.name)) {
|
||||
this.credentialData[property.name] =
|
||||
property.default as CredentialInformation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.credentialId) {
|
||||
if (!this.requiredPropertiesFilled) {
|
||||
this.showValidationWarning = true;
|
||||
}
|
||||
else {
|
||||
this.retestCredential();
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
},
|
||||
computed: {
|
||||
currentCredential(): ICredentialsResponse | null {
|
||||
if (!this.credentialId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.$store.getters['credentials/getCredentialById'](
|
||||
this.credentialId,
|
||||
);
|
||||
},
|
||||
credentialTypeName(): string | null {
|
||||
if (this.mode === 'edit') {
|
||||
if (this.currentCredential) {
|
||||
return this.currentCredential.type;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.activeId;
|
||||
},
|
||||
credentialType(): ICredentialType | null {
|
||||
if (!this.credentialTypeName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = this.$store.getters['credentials/getCredentialTypeByName'](
|
||||
this.credentialTypeName,
|
||||
);
|
||||
|
||||
return {
|
||||
...type,
|
||||
properties: this.getCredentialProperties(this.credentialTypeName),
|
||||
};
|
||||
},
|
||||
isCredentialTestable (): boolean {
|
||||
if (this.isOAuthType || !this.requiredPropertiesFilled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasExpressions = Object.values(this.credentialData).reduce((accu: boolean, value: CredentialInformation) => accu || (typeof value === 'string' && value.startsWith('=')), false);
|
||||
if (hasExpressions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodesThatCanTest = this.nodesWithAccess.filter(node => {
|
||||
if (node.credentials) {
|
||||
// Returns a list of nodes that can test this credentials
|
||||
const eligibleTesters = node.credentials.filter(credential => {
|
||||
return credential.name === this.credentialTypeName && credential.testedBy;
|
||||
});
|
||||
// If we have any node that can test, return true.
|
||||
return !!eligibleTesters.length;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return !!nodesThatCanTest.length;
|
||||
},
|
||||
nodesWithAccess(): INodeTypeDescription[] {
|
||||
if (this.credentialTypeName) {
|
||||
return this.$store.getters['credentials/getNodesWithAccess'](
|
||||
this.credentialTypeName,
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
parentTypes(): string[] {
|
||||
if (this.credentialTypeName) {
|
||||
return this.getParentTypes(this.credentialTypeName);
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
isOAuthType(): boolean {
|
||||
return !!this.credentialTypeName && (
|
||||
['oAuth1Api', 'oAuth2Api'].includes(this.credentialTypeName) ||
|
||||
this.parentTypes.includes('oAuth1Api') ||
|
||||
this.parentTypes.includes('oAuth2Api')
|
||||
);
|
||||
},
|
||||
isOAuthConnected(): boolean {
|
||||
return this.isOAuthType && !!this.credentialData.oauthTokenData;
|
||||
},
|
||||
credentialProperties(): INodeProperties[] {
|
||||
if (!this.credentialType) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.credentialType.properties.filter(
|
||||
(propertyData: INodeProperties) => {
|
||||
if (!this.displayCredentialParameter(propertyData)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
!this.credentialType!.__overwrittenProperties ||
|
||||
!this.credentialType!.__overwrittenProperties.includes(
|
||||
propertyData.name,
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
requiredPropertiesFilled(): boolean {
|
||||
for (const property of this.credentialProperties) {
|
||||
if (property.required !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.credentialData[property.name]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async beforeClose(done: () => void) {
|
||||
let keepEditing = false;
|
||||
|
||||
if (this.hasUnsavedChanges) {
|
||||
const displayName = this.credentialType ? this.credentialType.displayName : '';
|
||||
keepEditing = await this.confirmMessage(
|
||||
`Are you sure you want to throw away the changes you made to the ${displayName} credential?`,
|
||||
'Close without saving?',
|
||||
null,
|
||||
'Keep editing',
|
||||
'Close',
|
||||
);
|
||||
}
|
||||
else if (this.isOAuthType && !this.isOAuthConnected) {
|
||||
keepEditing = await this.confirmMessage(
|
||||
`You need to connect your credential for it to work`,
|
||||
'Close without connecting?',
|
||||
null,
|
||||
'Keep editing',
|
||||
'Close',
|
||||
);
|
||||
}
|
||||
|
||||
if (!keepEditing) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
else if (!this.requiredPropertiesFilled) {
|
||||
this.showValidationWarning = true;
|
||||
this.scrollToTop();
|
||||
}
|
||||
else if (this.isOAuthType) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
},
|
||||
|
||||
displayCredentialParameter(parameter: INodeProperties): boolean {
|
||||
if (parameter.type === 'hidden') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parameter.displayOptions === undefined) {
|
||||
// If it is not defined no need to do a proper check
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.displayParameter(
|
||||
this.credentialData as INodeParameters,
|
||||
parameter,
|
||||
'',
|
||||
);
|
||||
},
|
||||
getCredentialProperties(name: string): INodeProperties[] {
|
||||
const credentialsData =
|
||||
this.$store.getters['credentials/getCredentialTypeByName'](name);
|
||||
|
||||
if (!credentialsData) {
|
||||
throw new Error(`Could not find credentials of type: ${name}`);
|
||||
}
|
||||
|
||||
if (credentialsData.extends === undefined) {
|
||||
return credentialsData.properties;
|
||||
}
|
||||
|
||||
const combineProperties = [] as INodeProperties[];
|
||||
for (const credentialsTypeName of credentialsData.extends) {
|
||||
const mergeCredentialProperties =
|
||||
this.getCredentialProperties(credentialsTypeName);
|
||||
NodeHelpers.mergeNodeProperties(
|
||||
combineProperties,
|
||||
mergeCredentialProperties,
|
||||
);
|
||||
}
|
||||
|
||||
// The properties defined on the parent credentials take presidence
|
||||
NodeHelpers.mergeNodeProperties(
|
||||
combineProperties,
|
||||
credentialsData.properties,
|
||||
);
|
||||
|
||||
return combineProperties;
|
||||
},
|
||||
|
||||
async loadCurrentCredential() {
|
||||
this.credentialId = this.activeId;
|
||||
|
||||
try {
|
||||
const currentCredentials: ICredentialsDecryptedResponse =
|
||||
await this.$store.dispatch('credentials/getCredentialData', {
|
||||
id: this.credentialId,
|
||||
});
|
||||
if (!currentCredentials) {
|
||||
throw new Error(
|
||||
`Could not find the credentials with the id: ${this.credentialId}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.credentialData = currentCredentials.data || {};
|
||||
this.credentialName = currentCredentials.name;
|
||||
currentCredentials.nodesAccess.forEach(
|
||||
(access: { nodeType: string }) => {
|
||||
// keep node access structure to keep dates when updating
|
||||
this.nodeAccess[access.nodeType] = access;
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
this.$showError(
|
||||
e,
|
||||
'Problem loading credentials',
|
||||
'There was a problem loading the credentials:',
|
||||
);
|
||||
this.closeDialog();
|
||||
|
||||
return;
|
||||
}
|
||||
},
|
||||
onTabSelect(tab: string) {
|
||||
this.activeTab = tab;
|
||||
},
|
||||
onNodeAccessChange({name, value}: {name: string, value: boolean}) {
|
||||
this.hasUnsavedChanges = true;
|
||||
|
||||
if (value) {
|
||||
this.nodeAccess = {
|
||||
...this.nodeAccess,
|
||||
[name]: {
|
||||
nodeType: name,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
this.nodeAccess = {
|
||||
...this.nodeAccess,
|
||||
[name]: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
onDataChange({ name, value }: { name: string; value: any }) { // tslint:disable-line:no-any
|
||||
this.hasUnsavedChanges = true;
|
||||
|
||||
const { oauthTokenData, ...credData } = this.credentialData;
|
||||
|
||||
this.credentialData = {
|
||||
...credData,
|
||||
[name]: value,
|
||||
};
|
||||
},
|
||||
closeDialog() {
|
||||
this.modalBus.$emit('close');
|
||||
},
|
||||
|
||||
getParentTypes(name: string): string[] {
|
||||
const credentialType =
|
||||
this.$store.getters['credentials/getCredentialTypeByName'](name);
|
||||
|
||||
if (
|
||||
credentialType === undefined ||
|
||||
credentialType.extends === undefined
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const types: string[] = [];
|
||||
for (const typeName of credentialType.extends) {
|
||||
types.push(typeName);
|
||||
types.push.apply(types, this.getParentTypes(typeName));
|
||||
}
|
||||
|
||||
return types;
|
||||
},
|
||||
|
||||
onNameEdit(text: string) {
|
||||
this.hasUnsavedChanges = true;
|
||||
this.credentialName = text;
|
||||
},
|
||||
|
||||
scrollToTop() {
|
||||
setTimeout(() => {
|
||||
const content = this.$refs.content as Element;
|
||||
if (content) {
|
||||
content.scrollTop = 0;
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
setTimeout(() => {
|
||||
const content = this.$refs.content as Element;
|
||||
if (content) {
|
||||
content.scrollTop = content.scrollHeight;
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
|
||||
async retestCredential() {
|
||||
if (!this.isCredentialTestable) {
|
||||
this.authError = '';
|
||||
this.testedSuccessfully = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const nodesAccess = Object.values(this.nodeAccess).filter(
|
||||
(access) => !!access,
|
||||
) as ICredentialNodeAccess[];
|
||||
|
||||
// Save only the none default data
|
||||
const data = NodeHelpers.getNodeParameters(
|
||||
this.credentialType!.properties,
|
||||
this.credentialData as INodeParameters,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
const details: ICredentialsDecrypted = {
|
||||
name: this.credentialName,
|
||||
type: this.credentialTypeName!,
|
||||
data: data as unknown as ICredentialDataDecryptedObject,
|
||||
nodesAccess,
|
||||
};
|
||||
|
||||
this.isRetesting = true;
|
||||
await this.testCredential(details);
|
||||
this.isRetesting = false;
|
||||
},
|
||||
|
||||
async testCredential(credentialDetails: ICredentialsDecrypted) {
|
||||
const result: NodeCredentialTestResult = await this.$store.dispatch('credentials/testCredential', credentialDetails);
|
||||
if (result.status === 'Error') {
|
||||
this.authError = result.message;
|
||||
this.testedSuccessfully = false;
|
||||
}
|
||||
else {
|
||||
this.authError = '';
|
||||
this.testedSuccessfully = true;
|
||||
}
|
||||
|
||||
this.scrollToTop();
|
||||
},
|
||||
|
||||
async saveCredential(): Promise<ICredentialsResponse | null> {
|
||||
if (!this.requiredPropertiesFilled) {
|
||||
this.showValidationWarning = true;
|
||||
this.scrollToTop();
|
||||
}
|
||||
else {
|
||||
this.showValidationWarning = false;
|
||||
}
|
||||
|
||||
this.isSaving = true;
|
||||
const nodesAccess = Object.values(this.nodeAccess).filter(
|
||||
(access) => !!access,
|
||||
) as ICredentialNodeAccess[];
|
||||
|
||||
// Save only the none default data
|
||||
const data = NodeHelpers.getNodeParameters(
|
||||
this.credentialType!.properties,
|
||||
this.credentialData as INodeParameters,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
|
||||
const credentialDetails: ICredentialsDecrypted = {
|
||||
name: this.credentialName,
|
||||
type: this.credentialTypeName!,
|
||||
data: data as unknown as ICredentialDataDecryptedObject,
|
||||
nodesAccess,
|
||||
};
|
||||
|
||||
let credential;
|
||||
|
||||
if (this.mode === 'new' && !this.credentialId) {
|
||||
credential = await this.createCredential(
|
||||
credentialDetails,
|
||||
);
|
||||
} else {
|
||||
credential = await this.updateCredential(
|
||||
credentialDetails,
|
||||
);
|
||||
}
|
||||
|
||||
this.isSaving = false;
|
||||
if (credential) {
|
||||
this.credentialId = credential.id as string;
|
||||
|
||||
if (this.isCredentialTestable) {
|
||||
this.isTesting = true;
|
||||
await this.testCredential(credentialDetails);
|
||||
this.isTesting = false;
|
||||
}
|
||||
else {
|
||||
this.authError = '';
|
||||
this.testedSuccessfully = false;
|
||||
}
|
||||
}
|
||||
|
||||
return credential;
|
||||
},
|
||||
|
||||
async createCredential(
|
||||
credentialDetails: ICredentialsDecrypted,
|
||||
): Promise<ICredentialsResponse | null> {
|
||||
let credential;
|
||||
|
||||
try {
|
||||
credential = (await this.$store.dispatch(
|
||||
'credentials/createNewCredential',
|
||||
credentialDetails,
|
||||
)) as ICredentialsResponse;
|
||||
this.hasUnsavedChanges = false;
|
||||
} catch (error) {
|
||||
this.$showError(
|
||||
error,
|
||||
'Problem creating credentials',
|
||||
'There was a problem creating the credentials:',
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
this.$externalHooks().run('credentials.create', {
|
||||
credentialTypeData: this.credentialData,
|
||||
});
|
||||
|
||||
return credential;
|
||||
},
|
||||
|
||||
async updateCredential(
|
||||
credentialDetails: ICredentialsDecrypted,
|
||||
): Promise<ICredentialsResponse | null> {
|
||||
let credential;
|
||||
try {
|
||||
credential = (await this.$store.dispatch(
|
||||
'credentials/updateCredential',
|
||||
{ id: this.credentialId, data: credentialDetails },
|
||||
)) as ICredentialsResponse;
|
||||
this.hasUnsavedChanges = false;
|
||||
} catch (error) {
|
||||
this.$showError(
|
||||
error,
|
||||
'Problem updating credentials',
|
||||
'There was a problem updating the credentials:',
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Now that the credentials changed check if any nodes use credentials
|
||||
// which have now a different name
|
||||
this.updateNodesCredentialsIssues();
|
||||
|
||||
return credential;
|
||||
},
|
||||
|
||||
async deleteCredential() {
|
||||
if (!this.currentCredential) {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedCredentialName = this.currentCredential.name;
|
||||
|
||||
const deleteConfirmed = await this.confirmMessage(
|
||||
`Are you sure you want to delete "${savedCredentialName}" credentials?`,
|
||||
'Delete Credentials?',
|
||||
null,
|
||||
'Yes, delete!',
|
||||
);
|
||||
|
||||
if (deleteConfirmed === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isDeleting = true;
|
||||
await this.$store.dispatch('credentials/deleteCredential', {
|
||||
id: this.credentialId,
|
||||
});
|
||||
this.hasUnsavedChanges = false;
|
||||
} catch (error) {
|
||||
this.$showError(
|
||||
error,
|
||||
'Problem deleting credentials',
|
||||
'There was a problem deleting the credentials:',
|
||||
);
|
||||
this.isDeleting = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.isDeleting = false;
|
||||
// Now that the credentials were removed check if any nodes used them
|
||||
this.updateNodesCredentialsIssues();
|
||||
|
||||
this.$showMessage({
|
||||
title: 'Credentials deleted',
|
||||
message: `The credential "${savedCredentialName}" was deleted!`,
|
||||
type: 'success',
|
||||
});
|
||||
this.closeDialog();
|
||||
},
|
||||
|
||||
async oAuthCredentialAuthorize() {
|
||||
let url;
|
||||
|
||||
const credential = await this.saveCredential();
|
||||
if (!credential) {
|
||||
return;
|
||||
}
|
||||
|
||||
const types = this.parentTypes;
|
||||
|
||||
try {
|
||||
if (
|
||||
this.credentialTypeName === 'oAuth2Api' ||
|
||||
types.includes('oAuth2Api')
|
||||
) {
|
||||
url = (await this.$store.dispatch('credentials/oAuth2Authorize', {
|
||||
...this.credentialData,
|
||||
id: credential.id,
|
||||
})) as string;
|
||||
} else if (
|
||||
this.credentialTypeName === 'oAuth1Api' ||
|
||||
types.includes('oAuth1Api')
|
||||
) {
|
||||
url = (await this.$store.dispatch('credentials/oAuth1Authorize', {
|
||||
...this.credentialData,
|
||||
id: credential.id,
|
||||
})) as string;
|
||||
}
|
||||
} catch (error) {
|
||||
this.$showError(
|
||||
error,
|
||||
'OAuth Authorization Error',
|
||||
'Error generating authorization URL:',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const params = `scrollbars=no,resizable=yes,status=no,titlebar=noe,location=no,toolbar=no,menubar=no,width=500,height=700`;
|
||||
const oauthPopup = window.open(url, 'OAuth2 Authorization', params);
|
||||
Vue.set(this.credentialData, 'oauthTokenData', null);
|
||||
|
||||
const receiveMessage = (event: MessageEvent) => {
|
||||
// // TODO: Add check that it came from n8n
|
||||
// if (event.origin !== 'http://example.org:8080') {
|
||||
// return;
|
||||
// }
|
||||
if (event.data === 'success') {
|
||||
window.removeEventListener('message', receiveMessage, false);
|
||||
|
||||
// Set some kind of data that status changes.
|
||||
// As data does not get displayed directly it does not matter what data.
|
||||
Vue.set(this.credentialData, 'oauthTokenData', {});
|
||||
this.$store.commit('credentials/enableOAuthCredential', credential);
|
||||
|
||||
// Close the window
|
||||
if (oauthPopup) {
|
||||
oauthPopup.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', receiveMessage, false);
|
||||
},
|
||||
},
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.credentialModal {
|
||||
max-width: 900px;
|
||||
--dialog-close-top: 28px;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
flex-grow: 1;
|
||||
overflow: auto;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
max-width: 170px;
|
||||
min-width: 170px;
|
||||
margin-right: var(--spacing-l);
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.credInfo {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.credTab {
|
||||
padding-left: 12px !important;
|
||||
}
|
||||
|
||||
.credActions {
|
||||
margin-right: var(--spacing-xl);
|
||||
> * {
|
||||
margin-left: var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.credIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,91 @@
|
|||
<template>
|
||||
<div :class="$style.container">
|
||||
<el-row>
|
||||
<el-col :span="8" :class="$style.accessLabel">
|
||||
<span>Allow use by</span>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<div
|
||||
v-for="node in nodesWithAccess"
|
||||
:key="node.name"
|
||||
:class="$style.valueLabel"
|
||||
>
|
||||
<el-checkbox
|
||||
:label="node.displayName"
|
||||
:value="!!nodeAccess[node.name]"
|
||||
@change="(val) => onNodeAccessChange(node.name, val)"
|
||||
/>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row v-if="currentCredential">
|
||||
<el-col :span="8" :class="$style.label">
|
||||
<span>Created</span>
|
||||
</el-col>
|
||||
<el-col :span="16" :class="$style.valueLabel">
|
||||
<TimeAgo :date="currentCredential.createdAt" :capitalize="true" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row v-if="currentCredential">
|
||||
<el-col :span="8" :class="$style.label">
|
||||
<span>Last modified</span>
|
||||
</el-col>
|
||||
<el-col :span="16" :class="$style.valueLabel">
|
||||
<TimeAgo :date="currentCredential.updatedAt" :capitalize="true" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row v-if="currentCredential">
|
||||
<el-col :span="8" :class="$style.label">
|
||||
<span>ID</span>
|
||||
</el-col>
|
||||
<el-col :span="16" :class="$style.valueLabel">
|
||||
<span>{{currentCredential.id}}</span>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
import TimeAgo from '../TimeAgo.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'CredentialInfo',
|
||||
props: ['nodesWithAccess', 'nodeAccess', 'currentCredential'],
|
||||
components: {
|
||||
TimeAgo,
|
||||
},
|
||||
methods: {
|
||||
onNodeAccessChange(name: string, value: string) {
|
||||
this.$emit('accessChange', {
|
||||
name,
|
||||
value,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
> * {
|
||||
margin-bottom: var(--spacing-l);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: var(--font-weight-bold);
|
||||
max-width: 230px;
|
||||
}
|
||||
|
||||
.accessLabel {
|
||||
composes: label;
|
||||
margin-top: var(--spacing-5xs);
|
||||
}
|
||||
|
||||
.valueLabel {
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,52 @@
|
|||
<template>
|
||||
<div @keydown.stop :class="$style.container" v-if="credentialProperties.length">
|
||||
<div v-for="parameter in credentialProperties" :key="parameter.name">
|
||||
<ParameterInputExpanded
|
||||
:parameter="parameter"
|
||||
:value="credentialData[parameter.name]"
|
||||
:documentationUrl="documentationUrl"
|
||||
:showValidationWarnings="showValidationWarnings"
|
||||
@change="valueChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
import { IUpdateInformation } from '../../Interface';
|
||||
|
||||
import ParameterInputExpanded from '../ParameterInputExpanded.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'CredentialsInput',
|
||||
props: [
|
||||
'credentialProperties',
|
||||
'credentialData', // ICredentialsDecryptedResponse
|
||||
'documentationUrl',
|
||||
'showValidationWarnings',
|
||||
],
|
||||
components: {
|
||||
ParameterInputExpanded,
|
||||
},
|
||||
methods: {
|
||||
valueChanged(parameterData: IUpdateInformation) {
|
||||
const name = parameterData.name.split('.').pop();
|
||||
|
||||
this.$emit('change', {
|
||||
name,
|
||||
value: parameterData.value,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
> * {
|
||||
margin-bottom: var(--spacing-l);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<span>
|
||||
<img
|
||||
v-if="isGoogleOAuthType"
|
||||
:src="basePath + 'google-signin-light.png'"
|
||||
:class="$style.googleIcon"
|
||||
alt="Sign in with Google"
|
||||
@click.stop="$emit('click')"
|
||||
/>
|
||||
<n8n-button
|
||||
v-else
|
||||
label="Connect my account"
|
||||
size="large"
|
||||
@click.stop="$emit('click')"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
isGoogleOAuthType: {
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
basePath(): string {
|
||||
return this.$store.getters.getBaseUrl;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.googleIcon {
|
||||
width: 191px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
74
packages/editor-ui/src/components/CredentialIcon.vue
Normal file
74
packages/editor-ui/src/components/CredentialIcon.vue
Normal file
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<div>
|
||||
<img v-if="filePath" :class="$style.credIcon" :src="filePath" />
|
||||
<NodeIcon v-else-if="relevantNode" :nodeType="relevantNode" :size="28" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
props: {
|
||||
credentialTypeName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
credentialWithIcon(): ICredentialType | null {
|
||||
return this.getCredentialWithIcon(this.credentialTypeName);
|
||||
},
|
||||
|
||||
filePath(): string | null {
|
||||
if (!this.credentialWithIcon || !this.credentialWithIcon.icon || !this.credentialWithIcon.icon.startsWith('file:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const restUrl = this.$store.getters.getRestUrl;
|
||||
|
||||
return `${restUrl}/credential-icon/${this.credentialWithIcon.name}`;
|
||||
},
|
||||
relevantNode(): INodeTypeDescription | null {
|
||||
if (this.credentialWithIcon && this.credentialWithIcon.icon && this.credentialWithIcon.icon.startsWith('node:')) {
|
||||
const nodeType = this.credentialWithIcon.icon.replace('node:', '');
|
||||
|
||||
return this.$store.getters.nodeType(nodeType);
|
||||
}
|
||||
|
||||
const nodesWithAccess = this.$store.getters['credentials/getNodesWithAccess'](this.credentialTypeName);
|
||||
|
||||
if (nodesWithAccess.length) {
|
||||
return nodesWithAccess[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getCredentialWithIcon(name: string): ICredentialType | null {
|
||||
const type = this.$store.getters['credentials/getCredentialTypeByName'](name);
|
||||
if (type.icon) {
|
||||
return type;
|
||||
}
|
||||
|
||||
if (type.extends) {
|
||||
return type.extends.reduce((accu: string | null, type: string) => {
|
||||
return accu || this.getCredentialWithIcon(type);
|
||||
}, null);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
||||
.credIcon {
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
</style>
|
|
@ -1,388 +0,0 @@
|
|||
<template>
|
||||
<div v-if="dialogVisible" @keydown.stop>
|
||||
<el-dialog :visible="dialogVisible" append-to-body width="75%" class="credentials-edit-wrapper" :title="title" :nodeType="nodeType" :before-close="closeDialog">
|
||||
<div name="title" class="title-container" slot="title">
|
||||
<div class="title-left">{{title}}</div>
|
||||
<div class="title-right">
|
||||
<div v-if="credentialType && documentationUrl" class="docs-container">
|
||||
<svg class="help-logo" target="_blank" width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Node Documentation</title>
|
||||
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
|
||||
<g transform="translate(1117.000000, 825.000000)">
|
||||
<g transform="translate(10.000000, 11.000000)">
|
||||
<g transform="translate(2.250000, 2.250000)" fill="#FF6150">
|
||||
<path d="M6,11.25 L7.5,11.25 L7.5,9.75 L6,9.75 L6,11.25 M6.75,2.25 C5.09314575,2.25 3.75,3.59314575 3.75,5.25 L5.25,5.25 C5.25,4.42157288 5.92157288,3.75 6.75,3.75 C7.57842712,3.75 8.25,4.42157288 8.25,5.25 C8.25,6.75 6,6.5625 6,9 L7.5,9 C7.5,7.3125 9.75,7.125 9.75,5.25 C9.75,3.59314575 8.40685425,2.25 6.75,2.25 M1.5,0 L12,0 C12.8284271,0 13.5,0.671572875 13.5,1.5 L13.5,12 C13.5,12.8284271 12.8284271,13.5 12,13.5 L1.5,13.5 C0.671572875,13.5 0,12.8284271 0,12 L0,1.5 C0,0.671572875 0.671572875,0 1.5,0 Z"></path>
|
||||
</g>
|
||||
<rect x="0" y="0" width="18" height="18"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<span class="doc-link-text">Need help? <a class="doc-hyperlink" :href="documentationUrl" target="_blank">Open credential docs</a></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-type-item">
|
||||
<el-row v-if="!setCredentialType">
|
||||
<el-col :span="6">
|
||||
Credential type:
|
||||
</el-col>
|
||||
<el-col :span="18">
|
||||
<n8n-select v-model="credentialType" filterable placeholder="Select Type" size="medium" ref="credentialsDropdown">
|
||||
<n8n-option
|
||||
v-for="item in credentialTypes"
|
||||
:key="item.name"
|
||||
:label="item.displayName"
|
||||
:value="item.name">
|
||||
</n8n-option>
|
||||
</n8n-select>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<credentials-input v-if="credentialType" @credentialsCreated="credentialsCreated" @credentialsUpdated="credentialsUpdated" :credentialTypeData="getCredentialTypeData(credentialType)" :credentialData="credentialData" :nodesInit="nodesInit"></credentials-input>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
import { externalHooks } from '@/components/mixins/externalHooks';
|
||||
import { restApi } from '@/components/mixins/restApi';
|
||||
import { showMessage } from '@/components/mixins/showMessage';
|
||||
import CredentialsInput from '@/components/CredentialsInput.vue';
|
||||
import {
|
||||
ICredentialsCreatedEvent,
|
||||
ICredentialsDecryptedResponse,
|
||||
} from '@/Interface';
|
||||
|
||||
import {
|
||||
NodeHelpers,
|
||||
ICredentialType,
|
||||
INodeProperties,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import { INodeUi } from '../Interface';
|
||||
|
||||
export default mixins(
|
||||
restApi,
|
||||
showMessage,
|
||||
externalHooks,
|
||||
).extend({
|
||||
name: 'CredentialsEdit',
|
||||
props: [
|
||||
'dialogVisible', // Boolean
|
||||
'editCredentials',
|
||||
'setCredentialType', // String
|
||||
'nodesInit', // Array
|
||||
],
|
||||
components: {
|
||||
CredentialsInput,
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
credentialData: null as ICredentialsDecryptedResponse | null,
|
||||
credentialType: null as string | null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
credentialTypes (): ICredentialType[] {
|
||||
const credentialTypes = this.$store.getters.allCredentialTypes;
|
||||
if (credentialTypes === null) {
|
||||
return [];
|
||||
}
|
||||
return credentialTypes;
|
||||
},
|
||||
title (): string {
|
||||
if (this.editCredentials) {
|
||||
const credentialType = this.$store.getters.credentialType(this.editCredentials.type);
|
||||
return `Edit Credentials: "${credentialType.displayName}"`;
|
||||
} else {
|
||||
if (this.credentialType) {
|
||||
const credentialType = this.$store.getters.credentialType(this.credentialType);
|
||||
return `Create New Credentials: "${credentialType.displayName}"`;
|
||||
} else {
|
||||
return `Create New Credentials`;
|
||||
}
|
||||
}
|
||||
},
|
||||
documentationUrl (): string | undefined {
|
||||
let credentialTypeName = '';
|
||||
if (this.editCredentials) {
|
||||
credentialTypeName = this.editCredentials.type as string;
|
||||
} else {
|
||||
credentialTypeName = this.credentialType as string;
|
||||
}
|
||||
|
||||
const credentialType = this.$store.getters.credentialType(credentialTypeName);
|
||||
if (credentialType.documentationUrl !== undefined) {
|
||||
if (credentialType.documentationUrl.startsWith('http')) {
|
||||
return credentialType.documentationUrl;
|
||||
} else {
|
||||
return 'https://docs.n8n.io/credentials/' + credentialType.documentationUrl + '/?utm_source=n8n_app&utm_medium=left_nav_menu&utm_campaign=create_new_credentials_modal';
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
node (): INodeUi {
|
||||
return this.$store.getters.activeNode;
|
||||
},
|
||||
nodeType (): INodeTypeDescription | null {
|
||||
const activeNode = this.node;
|
||||
if (this.node) {
|
||||
return this.$store.getters.nodeType(this.node.type);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
async dialogVisible (newValue, oldValue): Promise<void> {
|
||||
if (newValue) {
|
||||
if (this.editCredentials) {
|
||||
// Credentials which should be edited are given
|
||||
const credentialType = this.$store.getters.credentialType(this.editCredentials.type);
|
||||
|
||||
if (credentialType === null) {
|
||||
this.$showMessage({
|
||||
title: 'Credential type not known',
|
||||
message: `Credentials of type "${this.editCredentials.type}" are not known.`,
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
});
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.editCredentials.id === undefined) {
|
||||
this.$showMessage({
|
||||
title: 'Credential ID missing',
|
||||
message: 'The ID of the credentials which should be edited is missing!',
|
||||
type: 'error',
|
||||
});
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
let currentCredentials: ICredentialsDecryptedResponse | undefined;
|
||||
try {
|
||||
currentCredentials = await this.restApi().getCredentials(this.editCredentials.id as string, true) as ICredentialsDecryptedResponse | undefined;
|
||||
} catch (error) {
|
||||
this.$showError(error, 'Problem loading credentials', 'There was a problem loading the credentials:');
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentCredentials === undefined) {
|
||||
this.$showMessage({
|
||||
title: 'Credentials not found',
|
||||
message: `Could not find the credentials with the id: ${this.editCredentials.id}`,
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
});
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentCredentials === undefined) {
|
||||
this.$showMessage({
|
||||
title: 'Problem loading credentials',
|
||||
message: 'No credentials could be loaded!',
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.credentialData = currentCredentials;
|
||||
} else {
|
||||
Vue.nextTick(() => {
|
||||
(this.$refs.credentialsDropdown as HTMLDivElement).focus();
|
||||
});
|
||||
if (this.credentialType || this.setCredentialType) {
|
||||
const credentialType = this.$store.getters.credentialType(this.credentialType || this.setCredentialType);
|
||||
if (credentialType === null) {
|
||||
this.$showMessage({
|
||||
title: 'Credential type not known',
|
||||
message: `Credentials of type "${this.credentialType || this.setCredentialType}" are not known.`,
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
});
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.credentialData = null;
|
||||
}
|
||||
|
||||
if (this.setCredentialType || (this.credentialData && this.credentialData.type)) {
|
||||
this.credentialType = this.setCredentialType || (this.credentialData && this.credentialData.type);
|
||||
}
|
||||
} else {
|
||||
// Make sure that it gets always reset else it uses by default
|
||||
// again the last selection from when it was open the previous time.
|
||||
this.credentialType = null;
|
||||
}
|
||||
},
|
||||
async credentialType (newValue, oldValue) {
|
||||
this.$externalHooks().run('credentialsEdit.credentialTypeChanged', { newValue, oldValue, editCredentials: !!this.editCredentials, credentialType: this.credentialType, setCredentialType: this.setCredentialType });
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getCredentialProperties (name: string): INodeProperties[] {
|
||||
const credentialsData = this.$store.getters.credentialType(name);
|
||||
|
||||
if (credentialsData === null) {
|
||||
throw new Error(`Could not find credentials of type: ${name}`);
|
||||
}
|
||||
|
||||
if (credentialsData.extends === undefined) {
|
||||
return credentialsData.properties;
|
||||
}
|
||||
|
||||
const combineProperties = [] as INodeProperties[];
|
||||
for (const credentialsTypeName of credentialsData.extends) {
|
||||
const mergeCredentialProperties = this.getCredentialProperties(credentialsTypeName);
|
||||
NodeHelpers.mergeNodeProperties(combineProperties, mergeCredentialProperties);
|
||||
}
|
||||
|
||||
// The properties defined on the parent credentials take presidence
|
||||
NodeHelpers.mergeNodeProperties(combineProperties, credentialsData.properties);
|
||||
|
||||
return combineProperties;
|
||||
},
|
||||
getCredentialTypeData (name: string): ICredentialType | null {
|
||||
let credentialData = this.$store.getters.credentialType(name);
|
||||
|
||||
if (credentialData === null || credentialData.extends === undefined) {
|
||||
return credentialData;
|
||||
}
|
||||
|
||||
// Credentials extends another one. So get the properties of the one it
|
||||
// extends and add them.
|
||||
credentialData = JSON.parse(JSON.stringify(credentialData));
|
||||
credentialData.properties = this.getCredentialProperties(credentialData.name);
|
||||
|
||||
return credentialData;
|
||||
},
|
||||
credentialsCreated (eventData: ICredentialsCreatedEvent): void {
|
||||
this.$emit('credentialsCreated', eventData);
|
||||
|
||||
this.$showMessage({
|
||||
title: 'Credentials created',
|
||||
message: `"${eventData.data.name}" credentials were successfully created!`,
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
if (eventData.options.closeDialog === true) {
|
||||
this.closeDialog();
|
||||
}
|
||||
},
|
||||
credentialsUpdated (eventData: ICredentialsCreatedEvent): void {
|
||||
this.$emit('credentialsUpdated', eventData);
|
||||
|
||||
this.$showMessage({
|
||||
title: 'Credentials updated',
|
||||
message: `"${eventData.data.name}" credentials were successfully updated!`,
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
if (eventData.options.closeDialog === true) {
|
||||
this.closeDialog();
|
||||
}
|
||||
},
|
||||
closeDialog (): void {
|
||||
// Handle the close externally as the visible parameter is an external prop
|
||||
// and is so not allowed to be changed here.
|
||||
this.$emit('closeDialog');
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.credentials-edit-wrapper {
|
||||
.credential-type-item {
|
||||
> .el-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px){
|
||||
.title-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: 100%;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.docs-container {
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1199px){
|
||||
.title-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 100%;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.docs-container {
|
||||
margin-top: 10px;
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.title-left {
|
||||
flex: 7;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #7a7a7a;
|
||||
vertical-align:middle;
|
||||
}
|
||||
|
||||
.title-right {
|
||||
vertical-align: middle;
|
||||
flex: 3;
|
||||
font-family: "Open Sans";
|
||||
color: #666666;
|
||||
font-size: 12px;
|
||||
font-weight: 510;
|
||||
letter-spacing: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-width: 40%;
|
||||
}
|
||||
|
||||
.help-logo {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.doc-link-text {
|
||||
margin-left: 2px;
|
||||
float: right;
|
||||
word-break: break-word;
|
||||
flex: 9;
|
||||
}
|
||||
|
||||
.doc-hyperlink,
|
||||
.doc-hyperlink:visited,
|
||||
.doc-hyperlink:focus,
|
||||
.doc-hyperlink:active {
|
||||
text-decoration: none;
|
||||
color: #FF6150;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -1,618 +0,0 @@
|
|||
<template>
|
||||
<div @keydown.stop class="credentials-input-wrapper">
|
||||
<el-row class="credential-name-wrapper">
|
||||
<el-col :span="6" class="headline-regular">
|
||||
Credentials Name:
|
||||
<n8n-tooltip class="credentials-info" placement="top" >
|
||||
<div slot="content" v-html="helpTexts.credentialsName"></div>
|
||||
<font-awesome-icon icon="question-circle" />
|
||||
</n8n-tooltip>
|
||||
</el-col>
|
||||
<el-col :span="18">
|
||||
<n8n-input v-model="name" type="text" size="medium"></n8n-input>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<br />
|
||||
<div class="headline" v-if="credentialProperties.length">
|
||||
Credential Data:
|
||||
<n8n-tooltip class="credentials-info" placement="top" >
|
||||
<div slot="content" v-html="helpTexts.credentialsData"></div>
|
||||
<font-awesome-icon icon="question-circle" />
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
<div v-for="parameter in credentialProperties" :key="parameter.name">
|
||||
<el-row class="parameter-wrapper">
|
||||
<el-col :span="6" class="parameter-name">
|
||||
{{parameter.displayName}}:
|
||||
<n8n-tooltip placement="top" class="parameter-info" v-if="parameter.description" >
|
||||
<div slot="content" v-html="parameter.description"></div>
|
||||
<font-awesome-icon icon="question-circle"/>
|
||||
</n8n-tooltip>
|
||||
</el-col>
|
||||
<el-col :span="18">
|
||||
<parameter-input :parameter="parameter" :value="propertyValue[parameter.name]" :path="parameter.name" :isCredential="true" :displayOptions="true" @valueChanged="valueChanged" inputSize="medium" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<el-row v-if="isOAuthType" class="oauth-information">
|
||||
<el-col :span="6" class="headline">
|
||||
OAuth
|
||||
</el-col>
|
||||
<el-col :span="18">
|
||||
<span v-if="requiredPropertiesFilled === false">
|
||||
<n8n-icon-button title="Connect OAuth Credentials" icon="redo" :disabled="true" size="large" />
|
||||
Enter all required properties
|
||||
</span>
|
||||
<span v-else-if="isOAuthConnected === true">
|
||||
<n8n-icon-button title="Reconnect OAuth Credentials" @click.stop="oAuthCredentialAuthorize()" icon="redo" size="large" />
|
||||
Connected
|
||||
</span>
|
||||
<span v-else>
|
||||
<span v-if="isGoogleOAuthType">
|
||||
<img :src="basePath + 'google-signin.png'" class="google-icon clickable" alt="Sign in with Google" @click.stop="oAuthCredentialAuthorize()" />
|
||||
</span>
|
||||
<span v-else>
|
||||
<n8n-icon-button title="Connect OAuth Credentials" @click.stop="oAuthCredentialAuthorize()" icon="sign-in-alt" size="large" />
|
||||
Not connected
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<div v-if="credentialProperties.length">
|
||||
<div class="clickable oauth-callback-headline" :class="{expanded: !isMinimized}" @click="isMinimized=!isMinimized" :title="isMinimized ? 'Click to display Webhook URLs' : 'Click to hide Webhook URLs'">
|
||||
<font-awesome-icon icon="angle-up" class="minimize-button minimize-icon" />
|
||||
OAuth Callback URL
|
||||
</div>
|
||||
<n8n-tooltip v-if="!isMinimized" class="item" content="Click to copy Callback URL" placement="right">
|
||||
<div class="callback-url left-ellipsis clickable" @click="copyCallbackUrl">
|
||||
{{oAuthCallbackUrl}}
|
||||
</div>
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row class="nodes-access-wrapper">
|
||||
<el-col :span="6" class="headline">
|
||||
Nodes with access:
|
||||
<n8n-tooltip class="credentials-info" placement="top" >
|
||||
<div slot="content" v-html="helpTexts.nodesWithAccess"></div>
|
||||
<font-awesome-icon icon="question-circle" />
|
||||
</n8n-tooltip>
|
||||
</el-col>
|
||||
<el-col :span="18">
|
||||
<el-transfer
|
||||
:titles="['No Access', 'Access ']"
|
||||
v-model="nodesAccess"
|
||||
:data="allNodesRequestingAccess">
|
||||
</el-transfer>
|
||||
|
||||
<div v-if="nodesAccess.length === 0" class="no-nodes-access">
|
||||
<strong>
|
||||
Important
|
||||
</strong><br />
|
||||
Add at least one node which has access to the credentials!
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="action-buttons">
|
||||
<n8n-button type="success" @click="updateCredentials(true)" label="Save" size="large" v-if="credentialDataDynamic" />
|
||||
<n8n-button @click="createCredentials(true)" label="Create" size="large" v-else />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
import { copyPaste } from '@/components/mixins/copyPaste';
|
||||
import { externalHooks } from '@/components/mixins/externalHooks';
|
||||
import { restApi } from '@/components/mixins/restApi';
|
||||
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
|
||||
import { showMessage } from '@/components/mixins/showMessage';
|
||||
|
||||
import {
|
||||
ICredentialsDecryptedResponse,
|
||||
ICredentialsResponse,
|
||||
IUpdateInformation,
|
||||
} from '@/Interface';
|
||||
import {
|
||||
CredentialInformation,
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsDecrypted,
|
||||
ICredentialType,
|
||||
ICredentialNodeAccess,
|
||||
INodeCredentialDescription,
|
||||
INodeParameters,
|
||||
INodeProperties,
|
||||
INodeTypeDescription,
|
||||
NodeHelpers,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import ParameterInput from '@/components/ParameterInput.vue';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
import { addTargetBlank } from './helpers';
|
||||
|
||||
export default mixins(
|
||||
copyPaste,
|
||||
externalHooks,
|
||||
nodeHelpers,
|
||||
restApi,
|
||||
showMessage,
|
||||
).extend({
|
||||
name: 'CredentialsInput',
|
||||
props: [
|
||||
'credentialTypeData', // ICredentialType
|
||||
'credentialData', // ICredentialsDecryptedResponse
|
||||
'nodesInit', // {
|
||||
// type: Array,
|
||||
// default: () => { [] },
|
||||
// }
|
||||
],
|
||||
components: {
|
||||
ParameterInput,
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
basePath: this.$store.getters.getBaseUrl,
|
||||
isMinimized: true,
|
||||
helpTexts: {
|
||||
credentialsData: 'The credentials to set.',
|
||||
credentialsName: 'A recognizable label for the credentials. Descriptive names work <br />best here, so you can easily select it from a list later.',
|
||||
nodesWithAccess: 'Nodes with access to these credentials.',
|
||||
},
|
||||
credentialDataTemp: null as ICredentialsDecryptedResponse | null,
|
||||
nodesAccess: [] as string[],
|
||||
name: '',
|
||||
propertyValue: {} as ICredentialDataDecryptedObject,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
allNodesRequestingAccess (): Array<{key: string, label: string}> {
|
||||
const returnNodeTypes: string[] = [];
|
||||
|
||||
const nodeTypes: INodeTypeDescription[] = this.$store.getters.allNodeTypes;
|
||||
|
||||
let nodeType: INodeTypeDescription;
|
||||
let credentialTypeDescription: INodeCredentialDescription;
|
||||
|
||||
// Find the node types which need the credentials
|
||||
for (nodeType of nodeTypes) {
|
||||
if (!nodeType.credentials) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (credentialTypeDescription of nodeType.credentials) {
|
||||
if (credentialTypeDescription.name === (this.credentialTypeData as ICredentialType).name && !returnNodeTypes.includes(credentialTypeDescription.name)) {
|
||||
returnNodeTypes.push(nodeType.name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the data in the correct format el-transfer expects
|
||||
return returnNodeTypes.map((nodeTypeName: string) => {
|
||||
return {
|
||||
key: nodeTypeName,
|
||||
label: this.$store.getters.nodeType(nodeTypeName).displayName as string,
|
||||
};
|
||||
});
|
||||
},
|
||||
credentialProperties (): INodeProperties[] {
|
||||
return this.credentialTypeData.properties.filter((propertyData: INodeProperties) => {
|
||||
if (!this.displayCredentialParameter(propertyData)) {
|
||||
return false;
|
||||
}
|
||||
return !this.credentialTypeData.__overwrittenProperties || !this.credentialTypeData.__overwrittenProperties.includes(propertyData.name);
|
||||
});
|
||||
},
|
||||
credentialDataDynamic (): ICredentialsDecryptedResponse | null {
|
||||
if (this.credentialData) {
|
||||
return this.credentialData;
|
||||
}
|
||||
|
||||
return this.credentialDataTemp;
|
||||
},
|
||||
isGoogleOAuthType (): boolean {
|
||||
if (this.credentialTypeData.name === 'googleOAuth2Api') {
|
||||
return true;
|
||||
}
|
||||
const types = this.parentTypes(this.credentialTypeData.name);
|
||||
return types.includes('googleOAuth2Api');
|
||||
},
|
||||
isOAuthType (): boolean {
|
||||
if (['oAuth1Api', 'oAuth2Api'].includes(this.credentialTypeData.name)) {
|
||||
return true;
|
||||
}
|
||||
const types = this.parentTypes(this.credentialTypeData.name);
|
||||
return types.includes('oAuth1Api') || types.includes('oAuth2Api');
|
||||
},
|
||||
isOAuthConnected (): boolean {
|
||||
if (this.isOAuthType === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.credentialDataDynamic !== null && !!this.credentialDataDynamic.data!.oauthTokenData;
|
||||
},
|
||||
oAuthCallbackUrl (): string {
|
||||
const types = this.parentTypes(this.credentialTypeData.name);
|
||||
const oauthType = (this.credentialTypeData.name === 'oAuth2Api' || types.includes('oAuth2Api')) ? 'oauth2' : 'oauth1';
|
||||
return this.$store.getters.oauthCallbackUrls[oauthType];
|
||||
},
|
||||
requiredPropertiesFilled (): boolean {
|
||||
for (const property of this.credentialProperties) {
|
||||
if (property.required !== true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.propertyValue[property.name]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addTargetBlank,
|
||||
copyCallbackUrl (): void {
|
||||
this.copyToClipboard(this.oAuthCallbackUrl);
|
||||
|
||||
this.$showMessage({
|
||||
title: 'Copied',
|
||||
message: `Callback URL was successfully copied!`,
|
||||
type: 'success',
|
||||
});
|
||||
},
|
||||
parentTypes (name: string): string[] {
|
||||
const credentialType = this.$store.getters.credentialType(name);
|
||||
|
||||
if (credentialType === undefined || credentialType.extends === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const types: string[] = [];
|
||||
for (const typeName of credentialType.extends) {
|
||||
types.push(typeName);
|
||||
types.push.apply(types, this.parentTypes(typeName));
|
||||
}
|
||||
|
||||
return types;
|
||||
},
|
||||
valueChanged (parameterData: IUpdateInformation) {
|
||||
const name = parameterData.name.split('.').pop() as string;
|
||||
// For a currently for me unknown reason can In not simply just
|
||||
// set the value and it has to be this way.
|
||||
const tempValue = JSON.parse(JSON.stringify(this.propertyValue));
|
||||
tempValue[name] = parameterData.value;
|
||||
Vue.set(this, 'propertyValue', tempValue);
|
||||
},
|
||||
displayCredentialParameter (parameter: INodeProperties): boolean {
|
||||
if (parameter.type === 'hidden') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parameter.displayOptions === undefined) {
|
||||
// If it is not defined no need to do a proper check
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.displayParameter(this.propertyValue as INodeParameters, parameter, '');
|
||||
},
|
||||
async createCredentials (closeDialog: boolean): Promise<ICredentialsResponse | null> {
|
||||
const nodesAccess = this.nodesAccess.map((nodeType) => {
|
||||
return {
|
||||
nodeType,
|
||||
};
|
||||
});
|
||||
|
||||
const newCredentials = {
|
||||
name: this.name,
|
||||
type: (this.credentialTypeData as ICredentialType).name,
|
||||
nodesAccess,
|
||||
// Save only the none default data
|
||||
data: NodeHelpers.getNodeParameters(this.credentialTypeData.properties as INodeProperties[], this.propertyValue as INodeParameters, false, false),
|
||||
} as ICredentialsDecrypted;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await this.restApi().createNewCredentials(newCredentials);
|
||||
} catch (error) {
|
||||
this.$showError(error, 'Problem Creating Credentials', 'There was a problem creating the credentials:');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add also to local store
|
||||
this.$store.commit('addCredentials', result);
|
||||
|
||||
this.$emit('credentialsCreated', {data: result, options: { closeDialog }});
|
||||
|
||||
this.$externalHooks().run('credentials.create', { credentialTypeData: this.credentialTypeData });
|
||||
|
||||
return result;
|
||||
},
|
||||
async oAuthCredentialAuthorize () {
|
||||
let url;
|
||||
|
||||
let credentialData = this.credentialDataDynamic;
|
||||
let newCredentials = false;
|
||||
if (!credentialData) {
|
||||
// Credentials did not get created yet. So create first before
|
||||
// doing oauth authorize
|
||||
credentialData = await this.createCredentials(false) as ICredentialsDecryptedResponse;
|
||||
newCredentials = true;
|
||||
if (credentialData === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the internal data directly so that even if it fails it displays a "Save" instead
|
||||
// of the "Create" button. If that would not be done, people could not retry after a
|
||||
// connect issue as it woult try to create credentials again which would fail as they
|
||||
// exist already.
|
||||
Vue.set(this, 'credentialDataTemp', credentialData);
|
||||
} else {
|
||||
// Exists already but got maybe changed. So save first
|
||||
credentialData = await this.updateCredentials(false) as ICredentialsDecryptedResponse;
|
||||
if (credentialData === null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const types = this.parentTypes(this.credentialTypeData.name);
|
||||
|
||||
try {
|
||||
if (this.credentialTypeData.name === 'oAuth2Api' || types.includes('oAuth2Api')) {
|
||||
url = await this.restApi().oAuth2CredentialAuthorize(credentialData as ICredentialsResponse) as string;
|
||||
} else if (this.credentialTypeData.name === 'oAuth1Api' || types.includes('oAuth1Api')) {
|
||||
url = await this.restApi().oAuth1CredentialAuthorize(credentialData as ICredentialsResponse) as string;
|
||||
}
|
||||
} catch (error) {
|
||||
this.$showError(error, 'OAuth Authorization Error', 'Error generating authorization URL:');
|
||||
return;
|
||||
}
|
||||
|
||||
const params = `scrollbars=no,resizable=yes,status=no,titlebar=noe,location=no,toolbar=no,menubar=no,width=500,height=700`;
|
||||
const oauthPopup = window.open(url, 'OAuth2 Authorization', params);
|
||||
|
||||
const receiveMessage = (event: MessageEvent) => {
|
||||
// // TODO: Add check that it came from n8n
|
||||
// if (event.origin !== 'http://example.org:8080') {
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (event.data === 'success') {
|
||||
|
||||
// Set some kind of data that status changes.
|
||||
// As data does not get displayed directly it does not matter what data.
|
||||
if (this.credentialData === null) {
|
||||
// Are new credentials so did not get send via "credentialData"
|
||||
Vue.set(this, 'credentialDataTemp', credentialData);
|
||||
Vue.set(this.credentialDataTemp!.data!, 'oauthTokenData', {});
|
||||
} else {
|
||||
// Credentials did already exist so can be set directly
|
||||
Vue.set(this.credentialData.data, 'oauthTokenData', {});
|
||||
}
|
||||
|
||||
// Save that OAuth got authorized locally
|
||||
this.$store.commit('updateCredentials', this.credentialDataDynamic);
|
||||
|
||||
// Close the window
|
||||
if (oauthPopup) {
|
||||
oauthPopup.close();
|
||||
}
|
||||
|
||||
if (newCredentials === true) {
|
||||
this.$emit('credentialsCreated', {data: credentialData, options: { closeDialog: false }});
|
||||
}
|
||||
|
||||
this.$showMessage({
|
||||
title: 'Connected',
|
||||
message: 'Connected successfully!',
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
// Make sure that the event gets removed again
|
||||
window.removeEventListener('message', receiveMessage, false);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
window.addEventListener('message', receiveMessage, false);
|
||||
},
|
||||
async updateCredentials (closeDialog: boolean): Promise<ICredentialsResponse | null> {
|
||||
const nodesAccess: ICredentialNodeAccess[] = [];
|
||||
const addedNodeTypes: string[] = [];
|
||||
|
||||
// Add Node-type which already had access to keep the original added date
|
||||
let nodeAccessData: ICredentialNodeAccess;
|
||||
for (nodeAccessData of (this.credentialDataDynamic as ICredentialsDecryptedResponse).nodesAccess) {
|
||||
if (this.nodesAccess.includes((nodeAccessData.nodeType))) {
|
||||
nodesAccess.push(nodeAccessData);
|
||||
addedNodeTypes.push(nodeAccessData.nodeType);
|
||||
}
|
||||
}
|
||||
|
||||
// Add Node-type which did not have access before
|
||||
for (const nodeType of this.nodesAccess) {
|
||||
if (!addedNodeTypes.includes(nodeType)) {
|
||||
nodesAccess.push({
|
||||
nodeType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newCredentials = {
|
||||
name: this.name,
|
||||
type: (this.credentialTypeData as ICredentialType).name,
|
||||
nodesAccess,
|
||||
// Save only the none default data
|
||||
data: NodeHelpers.getNodeParameters(this.credentialTypeData.properties as INodeProperties[], this.propertyValue as INodeParameters, false, false),
|
||||
} as ICredentialsDecrypted;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await this.restApi().updateCredentials((this.credentialDataDynamic as ICredentialsDecryptedResponse).id as string, newCredentials);
|
||||
} catch (error) {
|
||||
this.$showError(error, 'Problem Updating Credentials', 'There was a problem updating the credentials:');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update also in local store
|
||||
this.$store.commit('updateCredentials', result);
|
||||
|
||||
// Now that the credentials changed check if any nodes use credentials
|
||||
// which have now a different name
|
||||
this.updateNodesCredentialsIssues();
|
||||
|
||||
this.$emit('credentialsUpdated', {data: result, options: { closeDialog }});
|
||||
|
||||
return result;
|
||||
},
|
||||
init () {
|
||||
if (this.credentialData) {
|
||||
// Initialize with the given data
|
||||
this.name = (this.credentialData as ICredentialsDecryptedResponse).name;
|
||||
this.propertyValue = (this.credentialData as ICredentialsDecryptedResponse).data as ICredentialDataDecryptedObject;
|
||||
const nodesAccess = (this.credentialData as ICredentialsDecryptedResponse).nodesAccess.map((nodeAccess) => {
|
||||
return nodeAccess.nodeType;
|
||||
});
|
||||
|
||||
Vue.set(this, 'nodesAccess', nodesAccess);
|
||||
} else {
|
||||
// No data supplied so init empty
|
||||
this.name = '';
|
||||
this.propertyValue = {} as ICredentialDataDecryptedObject;
|
||||
const nodesAccess = [] as string[];
|
||||
nodesAccess.push.apply(nodesAccess, this.nodesInit);
|
||||
|
||||
Vue.set(this, 'nodesAccess', nodesAccess);
|
||||
}
|
||||
|
||||
// Set default values
|
||||
for (const property of (this.credentialTypeData as ICredentialType).properties) {
|
||||
if (!this.propertyValue.hasOwnProperty(property.name)) {
|
||||
this.propertyValue[property.name] = property.default as CredentialInformation;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
credentialData () {
|
||||
this.init();
|
||||
},
|
||||
credentialTypeData () {
|
||||
this.init();
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
this.init();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
.credentials-input-wrapper {
|
||||
.credential-name-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
margin-top: 2em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-weight: 600;
|
||||
color: $--color-primary;
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.headline-regular {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.nodes-access-wrapper {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.no-nodes-access {
|
||||
margin: 1em 0;
|
||||
color: $--color-primary;
|
||||
line-height: 1.75em;
|
||||
}
|
||||
|
||||
.oauth-information {
|
||||
line-height: 2.5em;
|
||||
margin: 2em 0;
|
||||
|
||||
.google-icon {
|
||||
width: 191px;
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
|
||||
.parameter-name {
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
.parameter-info {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.parameter-info {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.credentials-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.callback-url {
|
||||
position: relative;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
font-size: 0.9em;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: initial;
|
||||
color: #404040;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.headline:hover,
|
||||
.headline-regular:hover {
|
||||
.credentials-info {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.expanded .minimize-button {
|
||||
-webkit-transform: rotate(180deg);
|
||||
-moz-transform: rotate(180deg);
|
||||
-o-transform: rotate(180deg);
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.oauth-callback-headline {
|
||||
padding-top: 1em;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -1,7 +1,5 @@
|
|||
<template>
|
||||
<div v-if="dialogVisible">
|
||||
<credentials-edit :dialogVisible="credentialEditDialogVisible" @closeDialog="closeCredentialEditDialog" @credentialsUpdated="reloadCredentialList" @credentialsCreated="reloadCredentialList" :setCredentialType="editCredentials && editCredentials.type" :editCredentials="editCredentials"></credentials-edit>
|
||||
|
||||
<el-dialog :visible="dialogVisible" append-to-body width="80%" title="Credentials" :before-close="closeDialog">
|
||||
<div class="text-very-light">
|
||||
Your saved credentials:
|
||||
|
@ -17,13 +15,9 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<el-table :data="credentials" :default-sort = "{prop: 'name', order: 'ascending'}" stripe @row-click="editCredential" max-height="450" v-loading="isDataLoading">
|
||||
<el-table :data="credentialsToDisplay" :default-sort = "{prop: 'name', order: 'ascending'}" stripe max-height="450" @row-click="editCredential">
|
||||
<el-table-column property="name" label="Name" class-name="clickable" sortable></el-table-column>
|
||||
<el-table-column property="type" label="Type" class-name="clickable" sortable>
|
||||
<template slot-scope="scope">
|
||||
{{credentialTypeDisplayNames[scope.row.type]}}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column property="type" label="Type" class-name="clickable" sortable></el-table-column>
|
||||
<el-table-column property="createdAt" label="Created" class-name="clickable" sortable></el-table-column>
|
||||
<el-table-column property="updatedAt" label="Updated" class-name="clickable" sortable></el-table-column>
|
||||
<el-table-column
|
||||
|
@ -43,136 +37,89 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { externalHooks } from '@/components/mixins/externalHooks';
|
||||
import { restApi } from '@/components/mixins/restApi';
|
||||
import { ICredentialsResponse } from '@/Interface';
|
||||
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
|
||||
import { showMessage } from '@/components/mixins/showMessage';
|
||||
import CredentialsEdit from '@/components/CredentialsEdit.vue';
|
||||
import { genericHelpers } from '@/components/mixins/genericHelpers';
|
||||
|
||||
import { mapGetters } from "vuex";
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import { convertToDisplayDate } from './helpers';
|
||||
|
||||
export default mixins(
|
||||
externalHooks,
|
||||
genericHelpers,
|
||||
nodeHelpers,
|
||||
restApi,
|
||||
showMessage,
|
||||
).extend({
|
||||
name: 'CredentialsList',
|
||||
props: [
|
||||
'dialogVisible',
|
||||
],
|
||||
components: {
|
||||
CredentialsEdit,
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
credentialEditDialogVisible: false,
|
||||
credentialTypeDisplayNames: {} as { [key: string]: string; },
|
||||
credentials: [] as ICredentialsResponse[],
|
||||
displayAddCredentials: false,
|
||||
editCredentials: null as ICredentialsResponse | null,
|
||||
isDataLoading: false,
|
||||
};
|
||||
computed: {
|
||||
...mapGetters('credentials', ['allCredentials']),
|
||||
credentialsToDisplay() {
|
||||
return this.allCredentials.reduce((accu: ICredentialsResponse[], cred: ICredentialsResponse) => {
|
||||
const type = this.$store.getters['credentials/getCredentialTypeByName'](cred.type);
|
||||
|
||||
if (type) {
|
||||
accu.push({
|
||||
...cred,
|
||||
type: type.displayName,
|
||||
createdAt: convertToDisplayDate(cred.createdAt as number),
|
||||
updatedAt: convertToDisplayDate(cred.updatedAt as number),
|
||||
});
|
||||
}
|
||||
|
||||
return accu;
|
||||
}, []);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
dialogVisible (newValue) {
|
||||
if (newValue) {
|
||||
this.loadCredentials();
|
||||
this.loadCredentialTypes();
|
||||
}
|
||||
this.$externalHooks().run('credentialsList.dialogVisibleChanged', { dialogVisible: newValue });
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
closeCredentialEditDialog () {
|
||||
this.credentialEditDialogVisible = false;
|
||||
},
|
||||
closeDialog () {
|
||||
// Handle the close externally as the visible parameter is an external prop
|
||||
// and is so not allowed to be changed here.
|
||||
this.$emit('closeDialog');
|
||||
return false;
|
||||
},
|
||||
|
||||
createCredential () {
|
||||
this.editCredentials = null;
|
||||
this.credentialEditDialogVisible = true;
|
||||
this.$store.dispatch('ui/openCredentialsSelectModal');
|
||||
},
|
||||
|
||||
editCredential (credential: ICredentialsResponse) {
|
||||
const editCredentials = {
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
type: credential.type,
|
||||
} as ICredentialsResponse;
|
||||
|
||||
this.editCredentials = editCredentials;
|
||||
this.credentialEditDialogVisible = true;
|
||||
},
|
||||
reloadCredentialList () {
|
||||
this.loadCredentials();
|
||||
},
|
||||
loadCredentialTypes () {
|
||||
if (Object.keys(this.credentialTypeDisplayNames).length !== 0) {
|
||||
// Data is already loaded
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.$store.getters.allCredentialTypes === null) {
|
||||
// Data is not ready yet to be loaded
|
||||
return;
|
||||
}
|
||||
|
||||
for (const credentialType of this.$store.getters.allCredentialTypes) {
|
||||
this.credentialTypeDisplayNames[credentialType.name] = credentialType.displayName;
|
||||
}
|
||||
},
|
||||
loadCredentials () {
|
||||
this.isDataLoading = true;
|
||||
try {
|
||||
this.credentials = JSON.parse(JSON.stringify(this.$store.getters.allCredentials));
|
||||
} catch (error) {
|
||||
this.$showError(error, 'Problem loading credentials', 'There was a problem loading the credentials:');
|
||||
this.isDataLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.credentials.forEach((credentialData: ICredentialsResponse) => {
|
||||
credentialData.createdAt = this.convertToDisplayDate(credentialData.createdAt as number);
|
||||
credentialData.updatedAt = this.convertToDisplayDate(credentialData.updatedAt as number);
|
||||
});
|
||||
|
||||
this.isDataLoading = false;
|
||||
this.$store.dispatch('ui/openExisitngCredential', { id: credential.id});
|
||||
},
|
||||
|
||||
async deleteCredential (credential: ICredentialsResponse) {
|
||||
const deleteConfirmed = await this.confirmMessage(`Are you sure you want to delete "${credential.name}" credentials?`, 'Delete Credentials?', 'warning', 'Yes, delete!');
|
||||
const deleteConfirmed = await this.confirmMessage(`Are you sure you want to delete "${credential.name}" credentials?`, 'Delete Credentials?', null, 'Yes, delete!');
|
||||
|
||||
if (deleteConfirmed === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.restApi().deleteCredentials(credential.id!);
|
||||
await this.$store.dispatch('credentials/deleteCredential', {id: credential.id});
|
||||
} catch (error) {
|
||||
this.$showError(error, 'Problem deleting credentials', 'There was a problem deleting the credentials:');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove also from local store
|
||||
this.$store.commit('removeCredentials', credential);
|
||||
|
||||
// Now that the credentials got removed check if any nodes used them
|
||||
this.updateNodesCredentialsIssues();
|
||||
|
||||
this.$showMessage({
|
||||
title: 'Credentials deleted',
|
||||
message: `The credential "${credential.name}" got deleted!`,
|
||||
message: `The credential "${credential.name}" was deleted!`,
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
// Refresh list
|
||||
this.loadCredentials();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
109
packages/editor-ui/src/components/CredentialsSelectModal.vue
Normal file
109
packages/editor-ui/src/components/CredentialsSelectModal.vue
Normal file
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<Modal
|
||||
:name="modalName"
|
||||
:eventBus="modalBus"
|
||||
width="50%"
|
||||
:center="true"
|
||||
maxWidth="460px"
|
||||
>
|
||||
<template slot="header">
|
||||
<h2 :class="$style.title">Add new credential</h2>
|
||||
</template>
|
||||
<template slot="content">
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.subtitle">Select an app or service to connect to</div>
|
||||
<n8n-select
|
||||
filterable
|
||||
defaultFirstOption
|
||||
placeholder="Search for app..."
|
||||
size="xlarge"
|
||||
ref="select"
|
||||
:value="selected"
|
||||
@change="onSelect"
|
||||
>
|
||||
<font-awesome-icon icon="search" slot="prefix" />
|
||||
<n8n-option
|
||||
v-for="credential in allCredentialTypes"
|
||||
:value="credential.name"
|
||||
:key="credential.name"
|
||||
:label="credential.displayName"
|
||||
filterable
|
||||
/>
|
||||
</n8n-select>
|
||||
</div>
|
||||
</template>
|
||||
<template slot="footer">
|
||||
<div :class="$style.footer">
|
||||
<n8n-button
|
||||
label="Continue"
|
||||
float="right"
|
||||
size="large"
|
||||
:disabled="!selected"
|
||||
@click="openCredentialType"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { mapGetters } from "vuex";
|
||||
|
||||
import Modal from './Modal.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'CredentialsSelectModal',
|
||||
components: {
|
||||
Modal,
|
||||
},
|
||||
mounted() {
|
||||
setTimeout(() => {
|
||||
const element = this.$refs.select as HTMLSelectElement;
|
||||
if (element) {
|
||||
element.focus();
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
modalBus: new Vue(),
|
||||
selected: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('credentials', ['allCredentialTypes']),
|
||||
},
|
||||
props: {
|
||||
modalName: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onSelect(type: string) {
|
||||
this.selected = type;
|
||||
},
|
||||
openCredentialType () {
|
||||
this.modalBus.$emit('close');
|
||||
this.$store.dispatch('ui/openNewCredential', { type: this.selected });
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
margin-bottom: var(--spacing-l);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-xl);
|
||||
line-height: var(--font-line-height-regular);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-bottom: var(--spacing-s);
|
||||
font-size: var(--font-size-m);
|
||||
line-height: var(--font-line-height-xloose);
|
||||
}
|
||||
</style>
|
|
@ -1,39 +1,41 @@
|
|||
<template>
|
||||
<transition name="el-fade-in" @after-enter="showDocumentHelp = true">
|
||||
<div class="data-display-wrapper close-on-click" v-show="node" @click="close">
|
||||
<div class="data-display" >
|
||||
<NodeSettings @valueChanged="valueChanged" />
|
||||
<RunData />
|
||||
<div class="close-button clickable close-on-click" title="Close">
|
||||
<i class="el-icon-close close-on-click"></i>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="showDocumentHelp && nodeType" class="doc-help-wrapper">
|
||||
<svg id="help-logo" v-if="showDocumentHelp && nodeType" :href="documentationUrl" target="_blank" width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Node Documentation</title>
|
||||
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
|
||||
<g transform="translate(1117.000000, 825.000000)">
|
||||
<g transform="translate(10.000000, 11.000000)">
|
||||
<g transform="translate(2.250000, 2.250000)" fill="#FF6150">
|
||||
<path d="M6,11.25 L7.5,11.25 L7.5,9.75 L6,9.75 L6,11.25 M6.75,2.25 C5.09314575,2.25 3.75,3.59314575 3.75,5.25 L5.25,5.25 C5.25,4.42157288 5.92157288,3.75 6.75,3.75 C7.57842712,3.75 8.25,4.42157288 8.25,5.25 C8.25,6.75 6,6.5625 6,9 L7.5,9 C7.5,7.3125 9.75,7.125 9.75,5.25 C9.75,3.59314575 8.40685425,2.25 6.75,2.25 M1.5,0 L12,0 C12.8284271,0 13.5,0.671572875 13.5,1.5 L13.5,12 C13.5,12.8284271 12.8284271,13.5 12,13.5 L1.5,13.5 C0.671572875,13.5 0,12.8284271 0,12 L0,1.5 C0,0.671572875 0.671572875,0 1.5,0 Z"></path>
|
||||
</g>
|
||||
<rect x="0" y="0" width="18" height="18"></rect>
|
||||
</g>
|
||||
<el-dialog
|
||||
:visible="!!node"
|
||||
:before-close="close"
|
||||
:custom-class="`classic data-display-wrapper`"
|
||||
width="80%"
|
||||
append-to-body
|
||||
@opened="showDocumentHelp = true"
|
||||
>
|
||||
<div class="data-display" >
|
||||
<NodeSettings @valueChanged="valueChanged" />
|
||||
<RunData />
|
||||
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="nodeType && showDocumentHelp" class="doc-help-wrapper">
|
||||
<svg id="help-logo" :href="documentationUrl" target="_blank" width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Node Documentation</title>
|
||||
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
|
||||
<g transform="translate(1117.000000, 825.000000)">
|
||||
<g transform="translate(10.000000, 11.000000)">
|
||||
<g transform="translate(2.250000, 2.250000)" fill="#FF6150">
|
||||
<path d="M6,11.25 L7.5,11.25 L7.5,9.75 L6,9.75 L6,11.25 M6.75,2.25 C5.09314575,2.25 3.75,3.59314575 3.75,5.25 L5.25,5.25 C5.25,4.42157288 5.92157288,3.75 6.75,3.75 C7.57842712,3.75 8.25,4.42157288 8.25,5.25 C8.25,6.75 6,6.5625 6,9 L7.5,9 C7.5,7.3125 9.75,7.125 9.75,5.25 C9.75,3.59314575 8.40685425,2.25 6.75,2.25 M1.5,0 L12,0 C12.8284271,0 13.5,0.671572875 13.5,1.5 L13.5,12 C13.5,12.8284271 12.8284271,13.5 12,13.5 L1.5,13.5 C0.671572875,13.5 0,12.8284271 0,12 L0,1.5 C0,0.671572875 0.671572875,0 1.5,0 Z"></path>
|
||||
</g>
|
||||
<rect x="0" y="0" width="18" height="18"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div v-if="showDocumentHelp && nodeType" class="text">
|
||||
Need help? <a id="doc-hyperlink" v-if="showDocumentHelp && nodeType" :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">Open {{nodeType.displayName}} documentation</a>
|
||||
</div>
|
||||
<div class="text">
|
||||
Need help? <a id="doc-hyperlink" :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">Open {{nodeType.displayName}} documentation</a>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
</transition>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
@ -102,13 +104,10 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
|
|||
nodeTypeSelected (nodeTypeName: string) {
|
||||
this.$emit('nodeTypeSelected', nodeTypeName);
|
||||
},
|
||||
close (e: MouseEvent) {
|
||||
// @ts-ignore
|
||||
if (e.target.className && e.target.className.includes && e.target.className.includes('close-on-click')) {
|
||||
this.$externalHooks().run('dataDisplay.nodeEditingFinished');
|
||||
this.showDocumentHelp = false;
|
||||
this.$store.commit('setActiveNode', null);
|
||||
}
|
||||
close () {
|
||||
this.$externalHooks().run('dataDisplay.nodeEditingFinished');
|
||||
this.showDocumentHelp = false;
|
||||
this.$store.commit('setActiveNode', null);
|
||||
},
|
||||
onDocumentationUrlClick () {
|
||||
this.$externalHooks().run('dataDisplay.onDocumentationUrlClick', { nodeType: this.nodeType, documentationUrl: this.documentationUrl });
|
||||
|
@ -119,105 +118,70 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers).extend({
|
|||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
|
||||
.data-display-wrapper {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 20;
|
||||
background-color: #9d8d9dd8;
|
||||
height: 85%;
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -50px;
|
||||
color: #fff;
|
||||
background-color: $--custom-header-background;
|
||||
border-radius: 0 18px 18px 0;
|
||||
z-index: 110;
|
||||
font-size: 1.7em;
|
||||
text-align: center;
|
||||
line-height: 50px;
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
|
||||
.close-on-click {
|
||||
color: #fff;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.close-on-click:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
.el-dialog__header {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.data-display {
|
||||
position: relative;
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
margin: 3em auto;
|
||||
background-color: #fff;
|
||||
border-radius: 2px;
|
||||
@media (max-height: 720px) {
|
||||
margin: 1em auto;
|
||||
height: 95%;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-enter-to, .fade-leave-active {
|
||||
transition: all .75s ease;
|
||||
}
|
||||
|
||||
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.doc-help-wrapper {
|
||||
transition-delay: 2s;
|
||||
background-color: #fff;
|
||||
margin-top: 1%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #DCDFE6;
|
||||
border-radius: 4px;
|
||||
background-color: #FFFFFF;
|
||||
box-shadow: 0 2px 7px 0 rgba(0,0,0,0.15);
|
||||
min-width: 319px;
|
||||
height: 40px;
|
||||
float: right;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding-top: 10px;
|
||||
padding-right: 12px;
|
||||
|
||||
#help-logo {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-left: 5px;
|
||||
flex: 9;
|
||||
font-family: "Open Sans";
|
||||
color: #666666;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
line-height: 17px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#doc-hyperlink, #doc-hyperlink:visited, #doc-hyperlink:focus, #doc-hyperlink:active {
|
||||
text-decoration: none;
|
||||
color: #FF6150;
|
||||
}
|
||||
}
|
||||
.el-dialog__body {
|
||||
padding: 0 !important;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
.data-display {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.doc-help-wrapper {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
transition-delay: 2s;
|
||||
background-color: #fff;
|
||||
margin-top: 1%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #DCDFE6;
|
||||
border-radius: 4px;
|
||||
background-color: #FFFFFF;
|
||||
box-shadow: 0 2px 7px 0 rgba(0,0,0,0.15);
|
||||
min-width: 319px;
|
||||
height: 40px;
|
||||
float: right;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding-top: 10px;
|
||||
padding-right: 12px;
|
||||
|
||||
#help-logo {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-left: 5px;
|
||||
flex: 9;
|
||||
font-family: "Open Sans";
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 17px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-enter-to, .fade-leave-active {
|
||||
transition: all .75s ease;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -3,30 +3,34 @@
|
|||
:name="modalName"
|
||||
:eventBus="modalBus"
|
||||
@enter="save"
|
||||
size="sm"
|
||||
title="Duplicate Workflow"
|
||||
:center="true"
|
||||
minWidth="420px"
|
||||
maxWidth="420px"
|
||||
>
|
||||
<template v-slot:content>
|
||||
<el-row>
|
||||
<n8n-input
|
||||
v-model="name"
|
||||
ref="nameInput"
|
||||
placeholder="Enter workflow name"
|
||||
:maxlength="MAX_WORKFLOW_NAME_LENGTH"
|
||||
/>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<TagsDropdown
|
||||
:createEnabled="true"
|
||||
:currentTagIds="currentTagIds"
|
||||
:eventBus="dropdownBus"
|
||||
@blur="onTagsBlur"
|
||||
@esc="onTagsEsc"
|
||||
@update="onTagsUpdate"
|
||||
placeholder="Choose or create a tag"
|
||||
ref="dropdown"
|
||||
/>
|
||||
</el-row>
|
||||
<div :class="$style.content">
|
||||
<el-row>
|
||||
<n8n-input
|
||||
v-model="name"
|
||||
ref="nameInput"
|
||||
placeholder="Enter workflow name"
|
||||
:maxlength="MAX_WORKFLOW_NAME_LENGTH"
|
||||
/>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<TagsDropdown
|
||||
:createEnabled="true"
|
||||
:currentTagIds="currentTagIds"
|
||||
:eventBus="dropdownBus"
|
||||
@blur="onTagsBlur"
|
||||
@esc="onTagsEsc"
|
||||
@update="onTagsUpdate"
|
||||
placeholder="Choose or create a tag"
|
||||
ref="dropdown"
|
||||
/>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:footer="{ close }">
|
||||
<div :class="$style.footer">
|
||||
|
@ -127,6 +131,12 @@ export default mixins(showMessage, workflowHelpers).extend({
|
|||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.content {
|
||||
> div {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
> * {
|
||||
margin-left: var(--spacing-3xs);
|
||||
|
|
|
@ -43,10 +43,10 @@
|
|||
<el-table-column label="" width="30">
|
||||
<!-- eslint-disable-next-line vue/no-unused-vars -->
|
||||
<template slot="header" slot-scope="scope">
|
||||
<el-checkbox :indeterminate="isIndeterminate" v-model="checkAll" @change="handleCheckAllChange">Check all</el-checkbox>
|
||||
<el-checkbox :indeterminate="isIndeterminate" v-model="checkAll" @change="handleCheckAllChange" label=" "></el-checkbox>
|
||||
</template>
|
||||
<template slot-scope="scope">
|
||||
<el-checkbox v-if="scope.row.stoppedAt !== undefined && scope.row.id" :value="selectedItems[scope.row.id.toString()] || checkAll" @change="handleCheckboxChanged(scope.row.id)" >Check all</el-checkbox>
|
||||
<el-checkbox v-if="scope.row.stoppedAt !== undefined && scope.row.id" :value="selectedItems[scope.row.id.toString()] || checkAll" @change="handleCheckboxChanged(scope.row.id)" label=" "></el-checkbox>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column property="startedAt" label="Started At / ID" width="205">
|
||||
|
@ -173,6 +173,10 @@ import {
|
|||
IWorkflowShortResponse,
|
||||
} from '@/Interface';
|
||||
|
||||
import {
|
||||
convertToDisplayDate,
|
||||
} from './helpers';
|
||||
|
||||
import {
|
||||
IDataObject,
|
||||
} from 'n8n-workflow';
|
||||
|
@ -319,6 +323,7 @@ export default mixins(
|
|||
}
|
||||
return false;
|
||||
},
|
||||
convertToDisplayDate,
|
||||
displayExecution (execution: IExecutionShortResponse) {
|
||||
this.$router.push({
|
||||
name: 'ExecutionById',
|
||||
|
@ -380,7 +385,7 @@ export default mixins(
|
|||
|
||||
this.$showMessage({
|
||||
title: 'Execution deleted',
|
||||
message: 'The executions got deleted!',
|
||||
message: 'The executions were deleted!',
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div v-if="dialogVisible" @keydown.stop>
|
||||
<el-dialog :visible="dialogVisible" custom-class="expression-dialog" append-to-body width="80%" title="Edit Expression" :before-close="closeDialog">
|
||||
<el-dialog :visible="dialogVisible" custom-class="expression-dialog classic" append-to-body width="80%" title="Edit Expression" :before-close="closeDialog">
|
||||
<el-row>
|
||||
<el-col :span="8">
|
||||
<div class="header-side-menu">
|
||||
|
@ -145,11 +145,14 @@ export default mixins(
|
|||
|
||||
.right-side {
|
||||
background-color: #f9f9f9;
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-side-menu {
|
||||
padding: 1em 0 0.5em 1.8em;
|
||||
border-top-left-radius: 8px;
|
||||
|
||||
background-color: $--custom-window-sidebar-top;
|
||||
color: #555;
|
||||
|
|
|
@ -5,7 +5,8 @@
|
|||
</div>
|
||||
|
||||
<div v-for="property in getProperties" :key="property.name" class="fixed-collection-parameter-property">
|
||||
<div class="parameter-name" :title="property.displayName">{{property.displayName}}:</div>
|
||||
<div v-if="property.displayName === '' || parameter.options.length === 1"></div>
|
||||
<div v-else class="parameter-name" :title="property.displayName">{{property.displayName}}:</div>
|
||||
|
||||
<div v-if="multipleValues === true">
|
||||
<div v-for="(value, index) in values[property.name]" :key="property.name + index" class="parameter-item">
|
||||
|
|
128
packages/editor-ui/src/components/InlineNameEdit.vue
Normal file
128
packages/editor-ui/src/components/InlineNameEdit.vue
Normal file
|
@ -0,0 +1,128 @@
|
|||
<template>
|
||||
<div :class="$style.container">
|
||||
<div
|
||||
:class="$style.headline"
|
||||
@keydown.stop
|
||||
@click="enableNameEdit"
|
||||
v-click-outside="disableNameEdit"
|
||||
>
|
||||
<div v-if="!isNameEdit">
|
||||
<span>{{ name }}</span>
|
||||
<i><font-awesome-icon icon="pen" /></i>
|
||||
</div>
|
||||
<div v-else :class="$style.nameInput">
|
||||
<n8n-input
|
||||
:value="name"
|
||||
size="xlarge"
|
||||
ref="nameInput"
|
||||
@input="onNameEdit"
|
||||
@change="disableNameEdit"
|
||||
:maxlength="64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.subtitle" v-if="!isNameEdit">{{ subtitle }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import { showMessage } from './mixins/showMessage';
|
||||
|
||||
export default mixins(showMessage).extend({
|
||||
name: 'InlineNameEdit',
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isNameEdit: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onNameEdit(value: string) {
|
||||
this.$emit('input', value);
|
||||
},
|
||||
enableNameEdit() {
|
||||
this.isNameEdit = true;
|
||||
|
||||
setTimeout(() => {
|
||||
const input = this.$refs.nameInput as HTMLInputElement;
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
disableNameEdit() {
|
||||
if (!this.name) {
|
||||
this.$emit('input', `Untitled ${this.type}`);
|
||||
|
||||
this.$showToast({
|
||||
title: 'Error',
|
||||
message: `${this.type} name cannot be empty`,
|
||||
type: 'warning',
|
||||
});
|
||||
}
|
||||
|
||||
this.isNameEdit = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-size: var(--font-size-m);
|
||||
line-height: 1.4;
|
||||
margin-bottom: var(--spacing-5xs);
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
padding: 0 var(--spacing-4xs);
|
||||
border-radius: var(--border-radius-base);
|
||||
position: relative;
|
||||
min-height: 22px;
|
||||
max-height: 22px;
|
||||
font-weight: 400;
|
||||
|
||||
i {
|
||||
display: var(--headline-icon-display, none);
|
||||
font-size: 0.75em;
|
||||
margin-left: 8px;
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-base);
|
||||
--headline-icon-display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.nameInput {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
top: -13px;
|
||||
left: -9px;
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--color-text-light);
|
||||
margin-left: 4px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
</style>
|
|
@ -46,24 +46,6 @@ export default mixins(
|
|||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.el-menu--horizontal>.el-menu-item,
|
||||
.el-menu--horizontal>.el-submenu .el-submenu__title,
|
||||
.el-menu-item {
|
||||
height: 65px;
|
||||
line-height: 65px;
|
||||
}
|
||||
|
||||
.el-submenu .el-submenu__title,
|
||||
.el-menu--horizontal>.el-menu-item,
|
||||
.el-menu.el-menu--horizontal {
|
||||
border: none !important;
|
||||
}
|
||||
.el-menu--popup-bottom-start {
|
||||
margin-top: 0px;
|
||||
border-top: 1px solid #464646;
|
||||
border-radius: 0 0 2px 2px;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
|
|
@ -65,7 +65,11 @@
|
|||
<span>Active:</span>
|
||||
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" :disabled="!currentWorkflowId"/>
|
||||
</span>
|
||||
<SaveWorkflowButton />
|
||||
<SaveButton
|
||||
:saved="!this.isDirty && !this.isNewWorkflow"
|
||||
:disabled="isWorkflowSaving"
|
||||
@click="saveCurrentWorkflow"
|
||||
/>
|
||||
</template>
|
||||
</PushConnectionTracker>
|
||||
</div>
|
||||
|
@ -82,7 +86,7 @@ import TagsContainer from "@/components/TagsContainer.vue";
|
|||
import PushConnectionTracker from "@/components/PushConnectionTracker.vue";
|
||||
import WorkflowActivator from "@/components/WorkflowActivator.vue";
|
||||
import { workflowHelpers } from "@/components/mixins/workflowHelpers";
|
||||
import SaveWorkflowButton from "@/components/SaveWorkflowButton.vue";
|
||||
import SaveButton from "@/components/SaveButton.vue";
|
||||
import TagsDropdown from "@/components/TagsDropdown.vue";
|
||||
import InlineTextEdit from "@/components/InlineTextEdit.vue";
|
||||
import BreakpointsObserver from "@/components/BreakpointsObserver.vue";
|
||||
|
@ -103,7 +107,7 @@ export default mixins(workflowHelpers).extend({
|
|||
PushConnectionTracker,
|
||||
WorkflowNameShort,
|
||||
WorkflowActivator,
|
||||
SaveWorkflowButton,
|
||||
SaveButton,
|
||||
TagsDropdown,
|
||||
InlineTextEdit,
|
||||
BreakpointsObserver,
|
||||
|
@ -125,6 +129,9 @@ export default mixins(workflowHelpers).extend({
|
|||
isDirty: "getStateIsDirty",
|
||||
currentWorkflowTagIds: "workflowTags",
|
||||
}),
|
||||
isNewWorkflow(): boolean {
|
||||
return !this.$route.params.name;
|
||||
},
|
||||
isWorkflowSaving(): boolean {
|
||||
return this.$store.getters.isActionActive("workflowSaving");
|
||||
},
|
||||
|
|
|
@ -3,113 +3,111 @@
|
|||
<about :dialogVisible="aboutDialogVisible" @closeDialog="closeAboutDialog"></about>
|
||||
<executions-list :dialogVisible="executionsListDialogVisible" @closeDialog="closeExecutionsListOpenDialog"></executions-list>
|
||||
<credentials-list :dialogVisible="credentialOpenDialogVisible" @closeDialog="closeCredentialOpenDialog"></credentials-list>
|
||||
<credentials-edit :dialogVisible="credentialNewDialogVisible" @closeDialog="closeCredentialNewDialog"></credentials-edit>
|
||||
<workflow-settings :dialogVisible="workflowSettingsDialogVisible" @closeDialog="closeWorkflowSettingsDialog"></workflow-settings>
|
||||
<input type="file" ref="importFile" style="display: none" v-on:change="handleFileImport()">
|
||||
|
||||
<div class="side-menu-wrapper" :class="{expanded: !isCollapsed}">
|
||||
<div id="collapse-change-button" class="clickable" @click="toggleCollapse">
|
||||
<font-awesome-icon icon="angle-right" class="icon" />
|
||||
</div>
|
||||
<el-menu default-active="workflow" @select="handleSelect" :collapse="isCollapsed">
|
||||
<n8n-menu default-active="workflow" @select="handleSelect" :collapse="isCollapsed">
|
||||
|
||||
<el-menu-item index="logo" class="logo-item">
|
||||
<n8n-menu-item index="logo" class="logo-item">
|
||||
<a href="https://n8n.io" target="_blank" class="logo">
|
||||
<img :src="basePath + 'n8n-icon-small.png'" class="icon" alt="n8n.io"/>
|
||||
<span class="logo-text" slot="title">n8n.io</span>
|
||||
</a>
|
||||
</el-menu-item>
|
||||
</n8n-menu-item>
|
||||
|
||||
<MenuItemsIterator :items="sidebarMenuTopItems" :root="true"/>
|
||||
|
||||
<el-submenu index="workflow" title="Workflow">
|
||||
<el-submenu index="workflow" title="Workflow" popperClass="sidebar-popper">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="network-wired"/>
|
||||
<span slot="title" class="item-title-root">Workflows</span>
|
||||
</template>
|
||||
|
||||
<el-menu-item index="workflow-new">
|
||||
<n8n-menu-item index="workflow-new">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="file"/>
|
||||
<span slot="title" class="item-title">New</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="workflow-open">
|
||||
</n8n-menu-item>
|
||||
<n8n-menu-item index="workflow-open">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="folder-open"/>
|
||||
<span slot="title" class="item-title">Open</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="workflow-save">
|
||||
</n8n-menu-item>
|
||||
<n8n-menu-item index="workflow-save">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="save"/>
|
||||
<span slot="title" class="item-title">Save</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="workflow-duplicate" :disabled="!currentWorkflow">
|
||||
</n8n-menu-item>
|
||||
<n8n-menu-item index="workflow-duplicate" :disabled="!currentWorkflow">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="copy"/>
|
||||
<span slot="title" class="item-title">Duplicate</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="workflow-delete" :disabled="!currentWorkflow">
|
||||
</n8n-menu-item>
|
||||
<n8n-menu-item index="workflow-delete" :disabled="!currentWorkflow">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="trash"/>
|
||||
<span slot="title" class="item-title">Delete</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="workflow-download">
|
||||
</n8n-menu-item>
|
||||
<n8n-menu-item index="workflow-download">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="file-download"/>
|
||||
<span slot="title" class="item-title">Download</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="workflow-import-url">
|
||||
</n8n-menu-item>
|
||||
<n8n-menu-item index="workflow-import-url">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="cloud"/>
|
||||
<span slot="title" class="item-title">Import from URL</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="workflow-import-file">
|
||||
</n8n-menu-item>
|
||||
<n8n-menu-item index="workflow-import-file">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="hdd"/>
|
||||
<span slot="title" class="item-title">Import from File</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="workflow-settings" :disabled="!currentWorkflow">
|
||||
</n8n-menu-item>
|
||||
<n8n-menu-item index="workflow-settings" :disabled="!currentWorkflow">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="cog"/>
|
||||
<span slot="title" class="item-title">Settings</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</n8n-menu-item>
|
||||
</el-submenu>
|
||||
|
||||
<el-submenu index="credentials" title="Credentials">
|
||||
<el-submenu index="credentials" title="Credentials" popperClass="sidebar-popper">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="key"/>
|
||||
<span slot="title" class="item-title-root">Credentials</span>
|
||||
</template>
|
||||
|
||||
<el-menu-item index="credentials-new">
|
||||
<n8n-menu-item index="credentials-new">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="file"/>
|
||||
<span slot="title" class="item-title">New</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="credentials-open">
|
||||
</n8n-menu-item>
|
||||
<n8n-menu-item index="credentials-open">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="folder-open"/>
|
||||
<span slot="title" class="item-title">Open</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</n8n-menu-item>
|
||||
</el-submenu>
|
||||
|
||||
<el-menu-item index="executions">
|
||||
<n8n-menu-item index="executions">
|
||||
<font-awesome-icon icon="tasks"/>
|
||||
<span slot="title" class="item-title-root">Executions</span>
|
||||
</el-menu-item>
|
||||
</n8n-menu-item>
|
||||
|
||||
<el-submenu index="help" class="help-menu" title="Help">
|
||||
<el-submenu index="help" class="help-menu" title="Help" popperClass="sidebar-popper">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="question"/>
|
||||
<span slot="title" class="item-title-root">Help</span>
|
||||
|
@ -117,25 +115,25 @@
|
|||
|
||||
<MenuItemsIterator :items="helpMenuItems" />
|
||||
|
||||
<el-menu-item index="help-about">
|
||||
<n8n-menu-item index="help-about">
|
||||
<template slot="title">
|
||||
<font-awesome-icon class="about-icon" icon="info"/>
|
||||
<span slot="title" class="item-title">About n8n</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</n8n-menu-item>
|
||||
</el-submenu>
|
||||
|
||||
<MenuItemsIterator :items="sidebarMenuBottomItems" :root="true"/>
|
||||
|
||||
<div class="footer-menu-items">
|
||||
<el-menu-item index="updates" class="updates" v-if="hasVersionUpdates" @click="openUpdatesPanel">
|
||||
<n8n-menu-item index="updates" class="updates" v-if="hasVersionUpdates" @click="openUpdatesPanel">
|
||||
<div class="gift-container">
|
||||
<GiftNotificationIcon />
|
||||
</div>
|
||||
<span slot="title" class="item-title-root">{{nextVersions.length > 99 ? '99+' : nextVersions.length}} update{{nextVersions.length > 1 ? 's' : ''}} available</span>
|
||||
</el-menu-item>
|
||||
</n8n-menu-item>
|
||||
</div>
|
||||
</el-menu>
|
||||
</n8n-menu>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
@ -153,7 +151,6 @@ import {
|
|||
} from '../Interface';
|
||||
|
||||
import About from '@/components/About.vue';
|
||||
import CredentialsEdit from '@/components/CredentialsEdit.vue';
|
||||
import CredentialsList from '@/components/CredentialsList.vue';
|
||||
import ExecutionsList from '@/components/ExecutionsList.vue';
|
||||
import GiftNotificationIcon from './GiftNotificationIcon.vue';
|
||||
|
@ -217,7 +214,6 @@ export default mixins(
|
|||
name: 'MainHeader',
|
||||
components: {
|
||||
About,
|
||||
CredentialsEdit,
|
||||
CredentialsList,
|
||||
ExecutionsList,
|
||||
GiftNotificationIcon,
|
||||
|
@ -229,11 +225,9 @@ export default mixins(
|
|||
aboutDialogVisible: false,
|
||||
// @ts-ignore
|
||||
basePath: this.$store.getters.getBaseUrl,
|
||||
credentialNewDialogVisible: false,
|
||||
credentialOpenDialogVisible: false,
|
||||
executionsListDialogVisible: false,
|
||||
stopExecutionInProgress: false,
|
||||
workflowSettingsDialogVisible: false,
|
||||
helpMenuItems,
|
||||
};
|
||||
},
|
||||
|
@ -309,18 +303,12 @@ export default mixins(
|
|||
closeAboutDialog () {
|
||||
this.aboutDialogVisible = false;
|
||||
},
|
||||
closeWorkflowSettingsDialog () {
|
||||
this.workflowSettingsDialogVisible = false;
|
||||
},
|
||||
closeExecutionsListOpenDialog () {
|
||||
this.executionsListDialogVisible = false;
|
||||
},
|
||||
closeCredentialOpenDialog () {
|
||||
this.credentialOpenDialogVisible = false;
|
||||
},
|
||||
closeCredentialNewDialog () {
|
||||
this.credentialNewDialogVisible = false;
|
||||
},
|
||||
openTagManager() {
|
||||
this.$store.dispatch('ui/openTagsManagerModal');
|
||||
},
|
||||
|
@ -414,8 +402,8 @@ export default mixins(
|
|||
// Reset tab title since workflow is deleted.
|
||||
this.$titleReset();
|
||||
this.$showMessage({
|
||||
title: 'Workflow got deleted',
|
||||
message: `The workflow "${this.workflowName}" got deleted!`,
|
||||
title: 'Workflow was deleted',
|
||||
message: `The workflow "${this.workflowName}" was deleted!`,
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
|
@ -443,7 +431,7 @@ export default mixins(
|
|||
} else if (key === 'help-about') {
|
||||
this.aboutDialogVisible = true;
|
||||
} else if (key === 'workflow-settings') {
|
||||
this.workflowSettingsDialogVisible = true;
|
||||
this.$store.dispatch('ui/openWorkflowSettingsModal');
|
||||
} else if (key === 'workflow-new') {
|
||||
const result = this.$store.getters.getStateIsDirty;
|
||||
if(result) {
|
||||
|
@ -477,7 +465,7 @@ export default mixins(
|
|||
} else if (key === 'credentials-open') {
|
||||
this.credentialOpenDialogVisible = true;
|
||||
} else if (key === 'credentials-new') {
|
||||
this.credentialNewDialogVisible = true;
|
||||
this.$store.dispatch('ui/openCredentialsSelectModal');
|
||||
} else if (key === 'execution-open-workflow') {
|
||||
if (this.workflowExecution !== null) {
|
||||
this.openWorkflow(this.workflowExecution.workflowId as string);
|
||||
|
@ -491,6 +479,103 @@ export default mixins(
|
|||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.sidebar-popper{
|
||||
.el-menu-item {
|
||||
font-size: 0.9em;
|
||||
height: 35px;
|
||||
line-height: 35px;
|
||||
color: $--custom-dialog-text-color;
|
||||
--menu-item-hover-fill: #fff0ef;
|
||||
|
||||
.item-title {
|
||||
position: absolute;
|
||||
left: 55px;
|
||||
}
|
||||
|
||||
.svg-inline--fa {
|
||||
position: relative;
|
||||
right: -3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#side-menu {
|
||||
// Menu
|
||||
.el-menu--vertical,
|
||||
.el-menu {
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
--menu-item-hover-fill: #fff0ef;
|
||||
|
||||
.el-menu--collapse {
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
.el-menu--popup,
|
||||
.el-menu--inline {
|
||||
font-size: 0.9em;
|
||||
li.el-menu-item {
|
||||
height: 35px;
|
||||
line-height: 35px;
|
||||
color: $--custom-dialog-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu-item,
|
||||
.el-submenu__title {
|
||||
color: $--color-primary;
|
||||
font-size: 1.2em;
|
||||
.el-submenu__icon-arrow {
|
||||
color: $--color-primary;
|
||||
font-weight: 800;
|
||||
font-size: 1em;
|
||||
}
|
||||
.svg-inline--fa {
|
||||
position: relative;
|
||||
right: -3px;
|
||||
}
|
||||
.item-title {
|
||||
position: absolute;
|
||||
left: 73px;
|
||||
}
|
||||
.item-title-root {
|
||||
position: absolute;
|
||||
left: 60px;
|
||||
top: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.el-menu-item {
|
||||
a {
|
||||
color: #666;
|
||||
|
||||
&.primary-item {
|
||||
color: $--color-primary;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
}
|
||||
|
||||
&.logo-item {
|
||||
background-color: $--color-primary !important;
|
||||
height: $--header-height;
|
||||
line-height: $--header-height;
|
||||
* {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
|
||||
.icon {
|
||||
position: relative;
|
||||
height: 23px;
|
||||
left: -10px;
|
||||
top: -2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.about-icon {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
@ -530,29 +615,6 @@ export default mixins(
|
|||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.el-menu-item {
|
||||
a {
|
||||
color: #666;
|
||||
|
||||
&.primary-item {
|
||||
color: $--color-primary;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
}
|
||||
|
||||
&.logo-item {
|
||||
background-color: $--color-primary !important;
|
||||
height: $--header-height;
|
||||
|
||||
.icon {
|
||||
position: relative;
|
||||
height: 23px;
|
||||
left: -10px;
|
||||
top: -2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.logo {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
@ -605,7 +667,7 @@ a.logo {
|
|||
}
|
||||
|
||||
.el-menu-item.updates {
|
||||
color: $--sidebar-inactive-color;
|
||||
color: $--sidebar-inactive-color !important;
|
||||
.item-title-root {
|
||||
font-size: 13px;
|
||||
top: 0 !important;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div>
|
||||
<el-menu-item
|
||||
<n8n-menu-item
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:index="item.id"
|
||||
|
@ -8,7 +8,7 @@
|
|||
>
|
||||
<font-awesome-icon :icon="item.properties.icon" />
|
||||
<span slot="title" :class="{'item-title-root': root, 'item-title': !root}">{{ item.properties.title }}</span>
|
||||
</el-menu-item>
|
||||
</n8n-menu-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -41,4 +41,4 @@ export default Vue.extend({
|
|||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
@ -1,57 +1,84 @@
|
|||
<template>
|
||||
<div>
|
||||
<el-drawer
|
||||
v-if="drawer"
|
||||
:direction="drawerDirection"
|
||||
:visible="visible && visibleDrawer"
|
||||
:size="drawerWidth"
|
||||
:before-close="closeDrawer"
|
||||
>
|
||||
<template v-slot:title>
|
||||
<slot name="header" />
|
||||
</template>
|
||||
<template>
|
||||
<slot name="content"/>
|
||||
</template>
|
||||
</el-drawer>
|
||||
<el-dialog
|
||||
v-else
|
||||
:visible="dialogVisible"
|
||||
:before-close="closeDialog"
|
||||
:title="title"
|
||||
:class="{ 'dialog-wrapper': true, [size]: true }"
|
||||
:width="width"
|
||||
append-to-body
|
||||
>
|
||||
<template v-slot:title>
|
||||
<slot name="header" />
|
||||
</template>
|
||||
<div class="modal-content" @keydown.stop @keydown.enter="handleEnter" @keydown.esc="closeDialog">
|
||||
<slot name="content"/>
|
||||
<el-dialog
|
||||
:visible="visible"
|
||||
:before-close="closeDialog"
|
||||
:title="title"
|
||||
:class="{'dialog-wrapper': true, 'center': center, 'scrollable': scrollable}"
|
||||
:width="width"
|
||||
:show-close="showClose"
|
||||
:custom-class="getCustomClass()"
|
||||
:style="styles"
|
||||
append-to-body
|
||||
>
|
||||
<template v-slot:title>
|
||||
<slot name="header" v-if="!loading" />
|
||||
</template>
|
||||
<div class="modal-content" @keydown.stop @keydown.enter="handleEnter" @keydown.esc="closeDialog">
|
||||
<slot v-if="!loading" name="content"/>
|
||||
<div class="loader" v-else>
|
||||
<n8n-spinner />
|
||||
</div>
|
||||
<el-row class="modal-footer">
|
||||
<slot name="footer" :close="closeDialog" />
|
||||
</el-row>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
<el-row v-if="!loading" class="modal-footer">
|
||||
<slot name="footer" :close="closeDialog" />
|
||||
</el-row>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
|
||||
const sizeMap: {[size: string]: string} = {
|
||||
xl: '80%',
|
||||
m: '50%',
|
||||
default: '50%',
|
||||
};
|
||||
|
||||
export default Vue.extend({
|
||||
name: "Modal",
|
||||
props: ['name', 'title', 'eventBus', 'size', 'drawer', 'drawerDirection', 'drawerWidth', 'visible'],
|
||||
data() {
|
||||
return {
|
||||
visibleDrawer: this.drawer,
|
||||
};
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
},
|
||||
eventBus: {
|
||||
type: Vue,
|
||||
},
|
||||
showClose: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
},
|
||||
classic: {
|
||||
type: Boolean,
|
||||
},
|
||||
beforeClose: {
|
||||
type: Function,
|
||||
},
|
||||
customClass: {
|
||||
type: String,
|
||||
},
|
||||
center: {
|
||||
type: Boolean,
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '50%',
|
||||
},
|
||||
minWidth: {
|
||||
type: String,
|
||||
},
|
||||
maxWidth: {
|
||||
type: String,
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
},
|
||||
maxHeight: {
|
||||
type: String,
|
||||
},
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('keydown', this.onWindowKeydown);
|
||||
|
@ -86,64 +113,99 @@ export default Vue.extend({
|
|||
}
|
||||
},
|
||||
closeDialog(callback?: () => void) {
|
||||
if (this.beforeClose) {
|
||||
this.beforeClose(() => {
|
||||
this.$store.commit('ui/closeTopModal');
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.$store.commit('ui/closeTopModal');
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
closeDrawer() {
|
||||
this.visibleDrawer = false;
|
||||
setTimeout(() =>{
|
||||
this.$store.commit('ui/closeTopModal');
|
||||
this.visibleDrawer = true;
|
||||
}, 300); // delayed for closing animation to take effect
|
||||
getCustomClass() {
|
||||
let classes = this.$props.customClass || '';
|
||||
|
||||
if (this.$props.classic) {
|
||||
classes = `${classes} classic`;
|
||||
}
|
||||
|
||||
return classes;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
width(): string {
|
||||
return this.$props.size ? sizeMap[this.$props.size] : sizeMap.default;
|
||||
},
|
||||
isActive(): boolean {
|
||||
return this.$store.getters['ui/isModalActive'](this.$props.name);
|
||||
},
|
||||
dialogVisible(): boolean {
|
||||
visible(): boolean {
|
||||
return this.$store.getters['ui/isModalOpen'](this.$props.name);
|
||||
},
|
||||
styles() {
|
||||
const styles: {[prop: string]: string} = {};
|
||||
if (this.height) {
|
||||
styles['--dialog-height'] = this.height;
|
||||
}
|
||||
if (this.maxHeight) {
|
||||
styles['--dialog-max-height'] = this.maxHeight;
|
||||
}
|
||||
if (this.maxWidth) {
|
||||
styles['--dialog-max-width'] = this.maxWidth;
|
||||
}
|
||||
if (this.minWidth) {
|
||||
styles['--dialog-min-width'] = this.minWidth;
|
||||
}
|
||||
return styles;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.el-drawer__header {
|
||||
margin: 0;
|
||||
padding: 30px 30px 0 30px;
|
||||
}
|
||||
|
||||
.el-drawer__body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-wrapper {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
.el-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: var(--dialog-max-width, 80%);
|
||||
min-width: var(--dialog-min-width, 420px);
|
||||
height: var(--dialog-height);
|
||||
max-height: var(--dialog-max-height);
|
||||
}
|
||||
|
||||
&.xl > div, &.md > div {
|
||||
min-width: 620px;
|
||||
.el-dialog__body {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&.sm {
|
||||
.modal-content {
|
||||
overflow: hidden;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollable .modal-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
> div {
|
||||
max-width: 420px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content > .el-row {
|
||||
margin-bottom: 15px;
|
||||
.loader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-primary-tint-1);
|
||||
font-size: 30px;
|
||||
height: 80%;
|
||||
}
|
||||
</style>
|
||||
|
|
92
packages/editor-ui/src/components/ModalDrawer.vue
Normal file
92
packages/editor-ui/src/components/ModalDrawer.vue
Normal file
|
@ -0,0 +1,92 @@
|
|||
<template>
|
||||
<el-drawer
|
||||
:direction="direction"
|
||||
:visible="visible"
|
||||
:size="width"
|
||||
:before-close="close"
|
||||
>
|
||||
<template v-slot:title>
|
||||
<slot name="header" />
|
||||
</template>
|
||||
<template>
|
||||
<slot name="content"/>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
|
||||
export default Vue.extend({
|
||||
name: "ModalDrawer",
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
},
|
||||
eventBus: {
|
||||
type: Vue,
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('keydown', this.onWindowKeydown);
|
||||
|
||||
if (this.$props.eventBus) {
|
||||
this.$props.eventBus.$on('close', () => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('keydown', this.onWindowKeydown);
|
||||
},
|
||||
methods: {
|
||||
onWindowKeydown(event: KeyboardEvent) {
|
||||
if (!this.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event && event.keyCode === 13) {
|
||||
this.handleEnter();
|
||||
}
|
||||
},
|
||||
handleEnter() {
|
||||
if (this.isActive) {
|
||||
this.$emit('enter');
|
||||
}
|
||||
},
|
||||
close() {
|
||||
this.$store.commit('ui/closeTopModal');
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isActive(): boolean {
|
||||
return this.$store.getters['ui/isModalActive'](this.$props.name);
|
||||
},
|
||||
visible(): boolean {
|
||||
return this.$store.getters['ui/isModalOpen'](this.$props.name);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.el-drawer__header {
|
||||
margin: 0;
|
||||
padding: 30px 30px 0 30px;
|
||||
}
|
||||
|
||||
.el-drawer__body {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
|
@ -2,7 +2,13 @@
|
|||
<div
|
||||
v-if="isOpen(name) || keepAlive"
|
||||
>
|
||||
<slot :modalName="name" :active="isActive(name)" :open="isOpen(name)"></slot>
|
||||
<slot
|
||||
:modalName="name"
|
||||
:active="isActive(name)"
|
||||
:open="isOpen(name)"
|
||||
:activeId="getActiveId(name)"
|
||||
:mode="getMode(name)"
|
||||
></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -19,6 +25,12 @@ export default Vue.extend({
|
|||
isOpen(name: string) {
|
||||
return this.$store.getters['ui/isModalOpen'](name);
|
||||
},
|
||||
getMode(name: string) {
|
||||
return this.$store.getters['ui/getModalMode'](name);
|
||||
},
|
||||
getActiveId(name: string) {
|
||||
return this.$store.getters['ui/getModalActiveId'](name);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -24,11 +24,36 @@
|
|||
/>
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="VERSIONS_MODAL_KEY" :keepAlive="true">
|
||||
<template v-slot="{ modalName, open }">
|
||||
<template v-slot="{ modalName }">
|
||||
<UpdatesPanel
|
||||
:modalName="modalName"
|
||||
:visible="open"
|
||||
/>
|
||||
</template>
|
||||
</ModalRoot>
|
||||
<ModalRoot :name="WORKFLOW_SETTINGS_MODAL_KEY">
|
||||
<template v-slot="{ modalName }">
|
||||
<WorkflowSettings
|
||||
:modalName="modalName"
|
||||
/>
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="CREDENTIAL_EDIT_MODAL_KEY">
|
||||
<template v-slot="{ modalName, activeId, mode }">
|
||||
<CredentialEdit
|
||||
:modalName="modalName"
|
||||
:mode="mode"
|
||||
:activeId="activeId"
|
||||
/>
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="CREDENTIAL_SELECT_MODAL_KEY">
|
||||
<template v-slot="{ modalName }">
|
||||
<CredentialsSelectModal
|
||||
:modalName="modalName"
|
||||
/>
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
@ -37,28 +62,37 @@
|
|||
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY, VERSIONS_MODAL_KEY } from '@/constants';
|
||||
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY, VERSIONS_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
|
||||
|
||||
import TagsManager from "@/components/TagsManager/TagsManager.vue";
|
||||
import CredentialEdit from "./CredentialEdit/CredentialEdit.vue";
|
||||
import DuplicateWorkflowDialog from "@/components/DuplicateWorkflowDialog.vue";
|
||||
import WorkflowOpen from "@/components/WorkflowOpen.vue";
|
||||
import ModalRoot from "./ModalRoot.vue";
|
||||
import CredentialsSelectModal from "./CredentialsSelectModal.vue";
|
||||
import UpdatesPanel from "./UpdatesPanel.vue";
|
||||
import WorkflowSettings from "./WorkflowSettings.vue";
|
||||
import TagsManager from "@/components/TagsManager/TagsManager.vue";
|
||||
|
||||
export default Vue.extend({
|
||||
name: "Modals",
|
||||
components: {
|
||||
TagsManager,
|
||||
CredentialEdit,
|
||||
DuplicateWorkflowDialog,
|
||||
WorkflowOpen,
|
||||
ModalRoot,
|
||||
CredentialsSelectModal,
|
||||
UpdatesPanel,
|
||||
WorkflowSettings,
|
||||
TagsManager,
|
||||
WorkflowOpen,
|
||||
},
|
||||
data: () => ({
|
||||
DUPLICATE_MODAL_KEY,
|
||||
TAGS_MANAGER_MODAL_KEY,
|
||||
WORKLOW_OPEN_MODAL_KEY,
|
||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||
VERSIONS_MODAL_KEY,
|
||||
CREDENTIAL_EDIT_MODAL_KEY,
|
||||
CREDENTIAL_SELECT_MODAL_KEY,
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<div class="parameter-name">
|
||||
{{parameter.displayName}}:
|
||||
<n8n-tooltip v-if="parameter.description" class="parameter-info" placement="top" >
|
||||
<div slot="content" v-html="parameter.description"></div>
|
||||
<div slot="content" v-html="addTargetBlank(parameter.description)"></div>
|
||||
<font-awesome-icon icon="question-circle" />
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
|
@ -21,7 +21,7 @@
|
|||
<collection-parameter :parameter="parameter" :values="value" :nodeValues="nodeValues" :path="getPath(index)" :hideDelete="hideDelete" @valueChanged="valueChanged" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<parameter-input class="duplicate-parameter-input-item" :parameter="parameter" :value="value" :displayOptions="true" :path="getPath(index)" @valueChanged="valueChanged" inputSize="small" />
|
||||
<parameter-input class="duplicate-parameter-input-item" :parameter="parameter" :value="value" :displayOptions="true" :path="getPath(index)" @valueChanged="valueChanged" inputSize="small" :isReadOnly="isReadOnly" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -75,6 +75,7 @@ export default mixins(genericHelpers)
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
addTargetBlank,
|
||||
addItem () {
|
||||
const name = this.getPath();
|
||||
let currentValue = get(this.nodeValues, name);
|
||||
|
@ -92,7 +93,6 @@ export default mixins(genericHelpers)
|
|||
|
||||
this.$emit('valueChanged', parameterData);
|
||||
},
|
||||
addTargetBlank,
|
||||
deleteItem (index: number) {
|
||||
const parameterData = {
|
||||
name: this.getPath(index),
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<NodeIcon class="node-icon" :nodeType="nodeType" size="60" :shrink="true" :disabled="this.data.disabled"/>
|
||||
<NodeIcon class="node-icon" :nodeType="nodeType" size="60" :circle="true" :shrink="true" :disabled="this.data.disabled"/>
|
||||
</div>
|
||||
<div class="node-description">
|
||||
<div class="node-name" :title="data.name">
|
||||
|
|
|
@ -88,7 +88,6 @@ export default mixins(externalHooks).extend({
|
|||
filteredNodeTypes(): INodeCreateElement[] {
|
||||
const nodeTypes: INodeCreateElement[] = this.searchItems;
|
||||
const filter = this.searchFilter;
|
||||
|
||||
const returnData = nodeTypes.filter((el: INodeCreateElement) => {
|
||||
const nodeType = (el.properties as INodeItemProps).nodeType;
|
||||
return filter && matchesSelectType(el, this.selectedType) && matchesNodeType(el, filter);
|
||||
|
|
|
@ -42,7 +42,19 @@ export default Vue.extend({
|
|||
return this.allNodeTypes
|
||||
.filter((nodeType: INodeTypeDescription) => {
|
||||
return !HIDDEN_NODES.includes(nodeType.name);
|
||||
});
|
||||
}).reduce((accumulator: INodeTypeDescription[], currentValue: INodeTypeDescription) => {
|
||||
// keep only latest version of the nodes
|
||||
// accumulator starts as an empty array.
|
||||
const exists = accumulator.findIndex(nodes => nodes.name === currentValue.name);
|
||||
if (exists >= 0 && accumulator[exists].version < currentValue.version) {
|
||||
// This must be a versioned node and we've found a newer version.
|
||||
// Replace the previous one with this one.
|
||||
accumulator[exists] = currentValue;
|
||||
} else {
|
||||
accumulator.push(currentValue);
|
||||
}
|
||||
return accumulator;
|
||||
}, []);
|
||||
},
|
||||
categoriesWithNodes(): ICategoriesWithNodes {
|
||||
return getCategoriesWithNodes(this.visibleNodeTypes);
|
||||
|
|
|
@ -1,27 +1,30 @@
|
|||
<template>
|
||||
<div v-if="credentialTypesNodeDescriptionDisplayed.length" class="node-credentials">
|
||||
<credentials-edit :dialogVisible="credentialNewDialogVisible" :editCredentials="editCredentials" :setCredentialType="addType" :nodesInit="nodesInit" :node="node" @closeDialog="closeCredentialNewDialog" @credentialsCreated="credentialsCreated" @credentialsUpdated="credentialsUpdated"></credentials-edit>
|
||||
|
||||
<div class="headline">
|
||||
Credentials
|
||||
</div>
|
||||
|
||||
<div v-for="credentialTypeDescription in credentialTypesNodeDescriptionDisplayed" :key="credentialTypeDescription.name" class="credential-data">
|
||||
<el-row v-if="displayCredentials(credentialTypeDescription)" class="credential-parameter-wrapper">
|
||||
|
||||
<el-row class="credential-parameter-wrapper">
|
||||
<el-col :span="10" class="parameter-name">
|
||||
{{credentialTypeNames[credentialTypeDescription.name]}}:
|
||||
</el-col>
|
||||
<el-col :span="12" class="parameter-value" :class="getIssues(credentialTypeDescription.name).length?'has-issues':''">
|
||||
|
||||
<div :style="credentialInputWrapperStyle(credentialTypeDescription.name)">
|
||||
<n8n-select v-model="credentials[credentialTypeDescription.name]" :disabled="isReadOnly" @change="credentialSelected(credentialTypeDescription.name)" placeholder="Select Credential" size="small">
|
||||
<n8n-select :value="selected[credentialTypeDescription.name]" :disabled="isReadOnly" @change="(value) => credentialSelected(credentialTypeDescription.name, value)" placeholder="Select Credential" size="small">
|
||||
<n8n-option
|
||||
v-for="(item, index) in credentialOptions[credentialTypeDescription.name]"
|
||||
:key="item.name + '_' + index"
|
||||
v-for="(item) in credentialOptions[credentialTypeDescription.name]"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.name">
|
||||
</n8n-option>
|
||||
<n8n-option
|
||||
:key="NEW_CREDENTIALS_TEXT"
|
||||
:value="NEW_CREDENTIALS_TEXT"
|
||||
:label="NEW_CREDENTIALS_TEXT"
|
||||
>
|
||||
</n8n-option>
|
||||
</n8n-select>
|
||||
</div>
|
||||
|
||||
|
@ -34,7 +37,7 @@
|
|||
|
||||
</el-col>
|
||||
<el-col :span="2" class="parameter-value credential-action">
|
||||
<font-awesome-icon v-if="credentials[credentialTypeDescription.name]" icon="pen" @click="updateCredentials(credentialTypeDescription.name)" class="update-credentials clickable" title="Update Credentials" />
|
||||
<font-awesome-icon v-if="selected[credentialTypeDescription.name] && isCredentialValid(credentialTypeDescription.name)" icon="pen" @click="editCredential(credentialTypeDescription.name)" class="update-credentials clickable" title="Update Credentials" />
|
||||
</el-col>
|
||||
|
||||
</el-row>
|
||||
|
@ -44,12 +47,8 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
import { restApi } from '@/components/mixins/restApi';
|
||||
import {
|
||||
ICredentialsCreatedEvent,
|
||||
ICredentialsResponse,
|
||||
INodeUi,
|
||||
INodeUpdatePropertiesInformation,
|
||||
} from '@/Interface';
|
||||
|
@ -59,15 +58,16 @@ import {
|
|||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import CredentialsEdit from '@/components/CredentialsEdit.vue';
|
||||
import ParameterInput from '@/components/ParameterInput.vue';
|
||||
|
||||
import { genericHelpers } from '@/components/mixins/genericHelpers';
|
||||
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
|
||||
import { showMessage } from '@/components/mixins/showMessage';
|
||||
|
||||
import { mapGetters } from "vuex";
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
const NEW_CREDENTIALS_TEXT = '- Create New -';
|
||||
|
||||
export default mixins(
|
||||
genericHelpers,
|
||||
nodeHelpers,
|
||||
|
@ -78,11 +78,16 @@ export default mixins(
|
|||
props: [
|
||||
'node', // INodeUi
|
||||
],
|
||||
components: {
|
||||
CredentialsEdit,
|
||||
ParameterInput,
|
||||
data () {
|
||||
return {
|
||||
NEW_CREDENTIALS_TEXT,
|
||||
newCredentialUnsubscribe: null as null | (() => void),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('credentials', {
|
||||
credentialOptions: 'allCredentialsByType',
|
||||
}),
|
||||
credentialTypesNode (): string[] {
|
||||
return this.credentialTypesNodeDescription
|
||||
.map((credentialTypeDescription) => credentialTypeDescription.name);
|
||||
|
@ -109,52 +114,16 @@ export default mixins(
|
|||
} = {};
|
||||
let credentialType: ICredentialType | null;
|
||||
for (const credentialTypeName of this.credentialTypesNode) {
|
||||
credentialType = this.$store.getters.credentialType(credentialTypeName);
|
||||
credentialType = this.$store.getters['credentials/getCredentialTypeByName'](credentialTypeName);
|
||||
returnData[credentialTypeName] = credentialType !== null ? credentialType.displayName : credentialTypeName;
|
||||
}
|
||||
return returnData;
|
||||
},
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
addType: undefined as string | undefined,
|
||||
credentialNewDialogVisible: false,
|
||||
credentialOptions: {} as { [key: string]: ICredentialsResponse[]; },
|
||||
credentials: {} as {
|
||||
[key: string]: string | undefined
|
||||
},
|
||||
editCredentials: null as object | null, // Credentials filter
|
||||
newCredentialText: '- Create New -',
|
||||
nodesInit: undefined as string[] | undefined,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
node () {
|
||||
this.init();
|
||||
selected(): {[type: string]: string} {
|
||||
return this.node.credentials || {};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
closeCredentialNewDialog () {
|
||||
this.credentialNewDialogVisible = false;
|
||||
},
|
||||
async credentialsCreated (eventData: ICredentialsCreatedEvent) {
|
||||
await this.credentialsUpdated(eventData);
|
||||
},
|
||||
credentialsUpdated (eventData: ICredentialsCreatedEvent) {
|
||||
if (!this.credentialTypesNode.includes(eventData.data.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.init();
|
||||
Vue.set(this.credentials, eventData.data.type, eventData.data.name);
|
||||
|
||||
// Makes sure that it does also get set correctly on the node not just the UI
|
||||
this.credentialSelected(eventData.data.type);
|
||||
|
||||
if (eventData.options.closeDialog === true) {
|
||||
this.closeCredentialNewDialog();
|
||||
}
|
||||
},
|
||||
credentialInputWrapperStyle (credentialType: string) {
|
||||
let deductWidth = 0;
|
||||
const styles = {
|
||||
|
@ -170,29 +139,54 @@ export default mixins(
|
|||
|
||||
return styles;
|
||||
},
|
||||
credentialSelected (credentialType: string) {
|
||||
const credential = this.credentials[credentialType];
|
||||
if (credential === this.newCredentialText) {
|
||||
// New credentials should be created
|
||||
this.addType = credentialType;
|
||||
this.editCredentials = null;
|
||||
this.nodesInit = [ (this.node as INodeUi).type ];
|
||||
this.credentialNewDialogVisible = true;
|
||||
|
||||
this.credentials[credentialType] = undefined;
|
||||
listenForNewCredentials(credentialType: string) {
|
||||
this.stopListeningForNewCredentials();
|
||||
|
||||
this.newCredentialUnsubscribe = this.$store.subscribe((mutation, state) => {
|
||||
if (mutation.type === 'credentials/upsertCredential' || mutation.type === 'credentials/enableOAuthCredential'){
|
||||
this.credentialSelected(credentialType, mutation.payload.name);
|
||||
}
|
||||
if (mutation.type === 'credentials/deleteCredential') {
|
||||
this.credentialSelected(credentialType, mutation.payload.name);
|
||||
this.stopListeningForNewCredentials();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
stopListeningForNewCredentials() {
|
||||
if (this.newCredentialUnsubscribe) {
|
||||
this.newCredentialUnsubscribe();
|
||||
}
|
||||
},
|
||||
|
||||
credentialSelected (credentialType: string, credentialName: string) {
|
||||
let selected = undefined;
|
||||
if (credentialName === NEW_CREDENTIALS_TEXT) {
|
||||
this.listenForNewCredentials(credentialType);
|
||||
this.$store.dispatch('ui/openNewCredential', { type: credentialType });
|
||||
}
|
||||
else {
|
||||
selected = credentialName;
|
||||
}
|
||||
|
||||
const node = this.node as INodeUi;
|
||||
const node: INodeUi = this.node;
|
||||
|
||||
const credentials = {
|
||||
...(node.credentials || {}),
|
||||
[credentialType]: selected,
|
||||
};
|
||||
|
||||
const updateInformation: INodeUpdatePropertiesInformation = {
|
||||
name: node.name,
|
||||
name: this.node.name,
|
||||
properties: {
|
||||
credentials: JSON.parse(JSON.stringify(this.credentials)),
|
||||
credentials,
|
||||
},
|
||||
};
|
||||
|
||||
this.$emit('credentialSelected', updateInformation);
|
||||
},
|
||||
|
||||
displayCredentials (credentialTypeDescription: INodeCredentialDescription): boolean {
|
||||
if (credentialTypeDescription.displayOptions === undefined) {
|
||||
// If it is not defined no need to do a proper check
|
||||
|
@ -200,6 +194,7 @@ export default mixins(
|
|||
}
|
||||
return this.displayParameter(this.node.parameters, credentialTypeDescription, '');
|
||||
},
|
||||
|
||||
getIssues (credentialTypeName: string): string[] {
|
||||
const node = this.node as INodeUi;
|
||||
|
||||
|
@ -213,56 +208,25 @@ export default mixins(
|
|||
|
||||
return node.issues.credentials[credentialTypeName];
|
||||
},
|
||||
updateCredentials (credentialType: string): void {
|
||||
const name = this.credentials[credentialType];
|
||||
const credentialData = this.credentialOptions[credentialType].find((optionData: ICredentialsResponse) => optionData.name === name);
|
||||
if (credentialData === undefined) {
|
||||
this.$showMessage({
|
||||
title: 'Credentials not found',
|
||||
message: `The credentials named "${name}" of type "${credentialType}" could not be found!`,
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const editCredentials = {
|
||||
id: credentialData.id,
|
||||
name,
|
||||
type: credentialType,
|
||||
};
|
||||
isCredentialValid(credentialType: string): boolean {
|
||||
const name = this.node.credentials[credentialType];
|
||||
const options = this.credentialOptions[credentialType];
|
||||
|
||||
this.editCredentials = editCredentials;
|
||||
this.addType = credentialType;
|
||||
this.credentialNewDialogVisible = true;
|
||||
return options.find((option: ICredentialType) => option.name === name);
|
||||
},
|
||||
|
||||
init () {
|
||||
const node = this.node as INodeUi;
|
||||
editCredential(credentialType: string): void {
|
||||
const name = this.node.credentials[credentialType];
|
||||
const options = this.credentialOptions[credentialType];
|
||||
const selected = options.find((option: ICredentialType) => option.name === name);
|
||||
this.$store.dispatch('ui/openExisitngCredential', { id: selected.id });
|
||||
|
||||
const newOption = {
|
||||
name: this.newCredentialText,
|
||||
};
|
||||
|
||||
let options = [];
|
||||
|
||||
// Get the available credentials for each type
|
||||
for (const credentialType of this.credentialTypesNode) {
|
||||
options = this.$store.getters.credentialsByType(credentialType);
|
||||
options.push(newOption as ICredentialsResponse);
|
||||
Vue.set(this.credentialOptions, credentialType, options);
|
||||
}
|
||||
|
||||
// Set the current node credentials
|
||||
if (node.credentials) {
|
||||
Vue.set(this, 'credentials', JSON.parse(JSON.stringify(node.credentials)));
|
||||
} else {
|
||||
Vue.set(this, 'credentials', {});
|
||||
}
|
||||
this.listenForNewCredentials(credentialType);
|
||||
},
|
||||
|
||||
},
|
||||
mounted () {
|
||||
this.init();
|
||||
beforeDestroy () {
|
||||
this.stopListeningForNewCredentials();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
@ -317,6 +281,7 @@ export default mixins(
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ export default Vue.extend({
|
|||
'size',
|
||||
'shrink',
|
||||
'disabled',
|
||||
'circle',
|
||||
],
|
||||
computed: {
|
||||
iconStyleData (): object {
|
||||
|
@ -43,7 +44,7 @@ export default Vue.extend({
|
|||
height: size + 'px',
|
||||
'font-size': Math.floor(parseInt(this.size, 10) * 0.6) + 'px',
|
||||
'line-height': size + 'px',
|
||||
'border-radius': Math.ceil(size / 2) + 'px',
|
||||
'border-radius': this.circle ? '50%': '4px',
|
||||
};
|
||||
},
|
||||
isSvgIcon (): boolean {
|
||||
|
|
|
@ -80,7 +80,7 @@ export default mixins(
|
|||
computed: {
|
||||
nodeType (): INodeTypeDescription | null {
|
||||
if (this.node) {
|
||||
return this.$store.getters.nodeType(this.node.type);
|
||||
return this.$store.getters.nodeType(this.node.type, this.node.typeVersion);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
@ -511,24 +511,17 @@ export default mixins(
|
|||
<style lang="scss">
|
||||
|
||||
.node-settings {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 350px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
z-index: 200;
|
||||
font-size: 0.8em;
|
||||
color: #555;
|
||||
border-radius: 2px 0 0 2px;
|
||||
overflow: hidden;
|
||||
min-width: 350px;
|
||||
max-width: 350px;
|
||||
font-size: var(--font-size-s);
|
||||
|
||||
.header-side-menu {
|
||||
padding: 1em 0 1em 1.8em;
|
||||
font-size: 1.35em;
|
||||
font-size: var(--font-size-l);
|
||||
background-color: $--custom-window-sidebar-top;
|
||||
color: #555;
|
||||
|
||||
.node-info {
|
||||
color: #555;
|
||||
display: none;
|
||||
padding-left: 0.5em;
|
||||
font-size: 0.8em;
|
||||
|
@ -546,18 +539,19 @@ export default mixins(
|
|||
}
|
||||
|
||||
.node-parameters-wrapper {
|
||||
height: calc(100% - 110px);
|
||||
height: 100%;
|
||||
font-size: .9em;
|
||||
|
||||
.el-tabs__header {
|
||||
background-color: #fff5f2;
|
||||
line-height: 2em;
|
||||
}
|
||||
|
||||
.el-tabs {
|
||||
height: 100%;
|
||||
.el-tabs__content {
|
||||
height: calc(100% - 17px);
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
padding-bottom: 180px;
|
||||
|
||||
.el-tab-pane {
|
||||
margin: 0 1em;
|
||||
|
|
|
@ -29,9 +29,11 @@
|
|||
:rows="getArgument('rows')"
|
||||
:value="displayValue"
|
||||
:disabled="isReadOnly"
|
||||
@input="onTextInputChange"
|
||||
@change="valueChanged"
|
||||
@keydown.stop
|
||||
@focus="setFocus"
|
||||
@blur="onBlur"
|
||||
:title="displayTitle"
|
||||
:placeholder="isValueExpression?'':parameter.placeholder"
|
||||
>
|
||||
|
@ -48,6 +50,7 @@
|
|||
:value="displayValue"
|
||||
:disabled="isReadOnly"
|
||||
@focus="setFocus"
|
||||
@blur="onBlur"
|
||||
@change="valueChanged"
|
||||
:title="displayTitle"
|
||||
:show-alpha="getArgument('showAlpha')"
|
||||
|
@ -61,6 +64,7 @@
|
|||
@change="valueChanged"
|
||||
@keydown.stop
|
||||
@focus="setFocus"
|
||||
@blur="onBlur"
|
||||
:title="displayTitle"
|
||||
/>
|
||||
</div>
|
||||
|
@ -78,6 +82,7 @@
|
|||
:picker-options="dateTimePickerOptions"
|
||||
@change="valueChanged"
|
||||
@focus="setFocus"
|
||||
@blur="onBlur"
|
||||
@keydown.stop
|
||||
/>
|
||||
|
||||
|
@ -92,7 +97,9 @@
|
|||
:step="getArgument('numberStepSize')"
|
||||
:disabled="isReadOnly"
|
||||
@change="valueChanged"
|
||||
@input="onTextInputChange"
|
||||
@focus="setFocus"
|
||||
@blur="onBlur"
|
||||
@keydown.stop
|
||||
:title="displayTitle"
|
||||
:placeholder="parameter.placeholder"
|
||||
|
@ -107,9 +114,11 @@
|
|||
:loading="remoteParameterOptionsLoading"
|
||||
:disabled="isReadOnly || remoteParameterOptionsLoading"
|
||||
:title="displayTitle"
|
||||
:popper-append-to-body="true"
|
||||
@change="valueChanged"
|
||||
@keydown.stop
|
||||
@focus="setFocus"
|
||||
@blur="onBlur"
|
||||
>
|
||||
<n8n-option
|
||||
v-for="option in parameterOptions"
|
||||
|
@ -136,6 +145,7 @@
|
|||
@change="valueChanged"
|
||||
@keydown.stop
|
||||
@focus="setFocus"
|
||||
@blur="onBlur"
|
||||
:title="displayTitle"
|
||||
>
|
||||
<n8n-option v-for="option in parameterOptions" :value="option.value" :key="option.value" :label="option.name" >
|
||||
|
@ -177,7 +187,6 @@
|
|||
</el-dropdown-menu>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -201,7 +210,6 @@ import ExpressionEdit from '@/components/ExpressionEdit.vue';
|
|||
import PrismEditor from 'vue-prism-editor';
|
||||
import TextEdit from '@/components/TextEdit.vue';
|
||||
import { externalHooks } from '@/components/mixins/externalHooks';
|
||||
import { genericHelpers } from '@/components/mixins/genericHelpers';
|
||||
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
|
||||
import { showMessage } from '@/components/mixins/showMessage';
|
||||
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
|
||||
|
@ -210,7 +218,6 @@ import mixins from 'vue-typed-mixins';
|
|||
|
||||
export default mixins(
|
||||
externalHooks,
|
||||
genericHelpers,
|
||||
nodeHelpers,
|
||||
showMessage,
|
||||
workflowHelpers,
|
||||
|
@ -225,11 +232,14 @@ export default mixins(
|
|||
},
|
||||
props: [
|
||||
'displayOptions', // boolean
|
||||
'inputSize',
|
||||
'isReadOnly',
|
||||
'documentationUrl',
|
||||
'parameter', // NodeProperties
|
||||
'path', // string
|
||||
'value',
|
||||
'isCredential', // boolean
|
||||
'inputSize',
|
||||
'hideIssues', // boolean
|
||||
'errorHighlight',
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
|
@ -431,7 +441,7 @@ export default mixins(
|
|||
return 'text';
|
||||
},
|
||||
getIssues (): string[] {
|
||||
if (this.isCredential === true || this.node === null) {
|
||||
if (this.hideIssues === true || this.node === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
@ -512,7 +522,7 @@ export default mixins(
|
|||
if (this.isValueExpression) {
|
||||
classes.push('expression');
|
||||
}
|
||||
if (this.getIssues.length) {
|
||||
if (this.getIssues.length || this.errorHighlight) {
|
||||
classes.push('has-issues');
|
||||
}
|
||||
return classes;
|
||||
|
@ -561,7 +571,7 @@ export default mixins(
|
|||
const resolvedNodeParameters = this.resolveParameter(currentNodeParameters) as INodeParameters;
|
||||
|
||||
try {
|
||||
const options = await this.restApi().getNodeParameterOptions(this.node.type, this.path, this.remoteMethod, resolvedNodeParameters, this.node.credentials);
|
||||
const options = await this.restApi().getNodeParameterOptions({name: this.node.type, version: this.node.typeVersion}, this.path, this.remoteMethod, resolvedNodeParameters, this.node.credentials);
|
||||
this.remoteParameterOptions.push.apply(this.remoteParameterOptions, options);
|
||||
} catch (error) {
|
||||
this.remoteParameterOptionsLoadingIssues = error.message;
|
||||
|
@ -602,8 +612,12 @@ export default mixins(
|
|||
openExpressionEdit() {
|
||||
if (this.isValueExpression) {
|
||||
this.expressionEditDialogVisible = true;
|
||||
return;
|
||||
}
|
||||
},
|
||||
onBlur () {
|
||||
this.$emit('blur');
|
||||
},
|
||||
setFocus () {
|
||||
if (this.isValueExpression) {
|
||||
this.expressionEditDialogVisible = true;
|
||||
|
@ -644,6 +658,15 @@ export default mixins(
|
|||
const [r, g, b, a] = valueMatch.splice(1, 4).map(v => Number(v));
|
||||
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1) + ((1 << 8) + Math.floor((1-a)*255)).toString(16).slice(1);
|
||||
},
|
||||
onTextInputChange (value: string) {
|
||||
const parameterData = {
|
||||
node: this.node !== null ? this.node.name : this.nodeName,
|
||||
name: this.path,
|
||||
value,
|
||||
};
|
||||
|
||||
this.$emit('textInput', parameterData);
|
||||
},
|
||||
valueChanged (value: string | number | boolean | Date | null) {
|
||||
if (value instanceof Date) {
|
||||
value = value.toISOString();
|
||||
|
@ -810,6 +833,7 @@ export default mixins(
|
|||
max-width: 340px;
|
||||
margin: 6px 0;
|
||||
white-space: normal;
|
||||
padding-right: 20px;
|
||||
|
||||
.option-headline {
|
||||
font-weight: var(--font-weight-bold);
|
||||
|
@ -840,4 +864,16 @@ export default mixins(
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.errors {
|
||||
margin-top: var(--spacing-2xs);
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: var(--font-weight-regular);
|
||||
|
||||
a {
|
||||
color: var(--color-danger);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
68
packages/editor-ui/src/components/ParameterInputExpanded.vue
Normal file
68
packages/editor-ui/src/components/ParameterInputExpanded.vue
Normal file
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<n8n-input-label
|
||||
:label="parameter.displayName"
|
||||
:tooltipText="parameter.description"
|
||||
:required="parameter.required"
|
||||
>
|
||||
<parameter-input
|
||||
:parameter="parameter"
|
||||
:value="value"
|
||||
:path="parameter.name"
|
||||
:hideIssues="true"
|
||||
:displayOptions="true"
|
||||
:documentationUrl="documentationUrl"
|
||||
:errorHighlight="showRequiredErrors"
|
||||
|
||||
@blur="onBlur"
|
||||
@textInput="valueChanged"
|
||||
@valueChanged="valueChanged"
|
||||
inputSize="large"
|
||||
/>
|
||||
<div class="errors" v-if="showRequiredErrors">
|
||||
This field is required. <a v-if="documentationUrl" :href="documentationUrl" target="_blank">Open docs</a>
|
||||
</div>
|
||||
</n8n-input-label>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { IUpdateInformation } from '@/Interface';
|
||||
import ParameterInput from './ParameterInput.vue';
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'ParameterInputExpanded',
|
||||
components: {
|
||||
ParameterInput,
|
||||
},
|
||||
props: {
|
||||
parameter: {
|
||||
},
|
||||
value: {
|
||||
},
|
||||
showValidationWarnings: {
|
||||
type: Boolean,
|
||||
},
|
||||
documentationUrl: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
blurred: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
showRequiredErrors(): boolean {
|
||||
return this.$props.parameter.type !== 'boolean' && !this.value && this.$props.parameter.required && (this.blurred || this.showValidationWarnings);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onBlur() {
|
||||
this.blurred = true;
|
||||
},
|
||||
valueChanged(parameterData: IUpdateInformation) {
|
||||
this.$emit('change', parameterData);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
|
@ -3,12 +3,12 @@
|
|||
<el-col :span="isMultiLineParameter ? 24 : 10" class="parameter-name" :class="{'multi-line': isMultiLineParameter}">
|
||||
<span class="title" :title="parameter.displayName">{{parameter.displayName}}</span>:
|
||||
<n8n-tooltip class="parameter-info" placement="top" v-if="parameter.description" >
|
||||
<div slot="content" v-html="parameter.description"></div>
|
||||
<div slot="content" v-html="addTargetBlank(parameter.description)"></div>
|
||||
<font-awesome-icon icon="question-circle" />
|
||||
</n8n-tooltip>
|
||||
</el-col>
|
||||
<el-col :span="isMultiLineParameter ? 24 : 14" class="parameter-value">
|
||||
<parameter-input :parameter="parameter" :value="value" :displayOptions="displayOptions" :path="path" @valueChanged="valueChanged" inputSize="small" />
|
||||
<parameter-input :parameter="parameter" :value="value" :displayOptions="displayOptions" :path="path" :isReadOnly="isReadOnly" @valueChanged="valueChanged" inputSize="small" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
|
@ -47,6 +47,7 @@ export default Vue
|
|||
},
|
||||
props: [
|
||||
'displayOptions',
|
||||
'isReadOnly',
|
||||
'parameter',
|
||||
'path',
|
||||
'value',
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
</div>
|
||||
{{parameter.displayName}}:
|
||||
<n8n-tooltip placement="top" class="parameter-info" v-if="parameter.description" >
|
||||
<div slot="content" v-html="parameter.description"></div>
|
||||
<div slot="content" v-html="addTargetBlank(parameter.description)"></div>
|
||||
<font-awesome-icon icon="question-circle"/>
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
|
@ -70,6 +70,7 @@
|
|||
:value="getParameterValue(nodeValues, parameter.name, path)"
|
||||
:displayOptions="true"
|
||||
:path="getPath(parameter.name)"
|
||||
:isReadOnly="isReadOnly"
|
||||
@valueChanged="valueChanged"
|
||||
/>
|
||||
</div>
|
||||
|
@ -312,6 +313,7 @@ export default mixins(
|
|||
margin: 0.3em 0;
|
||||
padding: 0.8em;
|
||||
line-height: 1.5;
|
||||
word-break: normal;
|
||||
|
||||
a {
|
||||
font-weight: var(--font-weight-bold);
|
||||
|
|
|
@ -26,10 +26,9 @@
|
|||
<n8n-select size="mini" v-model="maxDisplayItems" @click.stop>
|
||||
<n8n-option v-for="option in maxDisplayItemsOptions" :label="option" :value="option" :key="option" />
|
||||
</n8n-select>
|
||||
</span> /
|
||||
</span>/
|
||||
<strong>{{ dataCount }}</strong>
|
||||
</div>
|
||||
|
||||
<n8n-tooltip
|
||||
v-if="runMetadata"
|
||||
placement="right"
|
||||
|
@ -41,7 +40,7 @@
|
|||
<font-awesome-icon icon="info-circle" class="primary-color" />
|
||||
</n8n-tooltip>
|
||||
<span v-if="maxOutputIndex > 0">
|
||||
| Output:
|
||||
| Output:
|
||||
</span>
|
||||
<span class="opts" v-if="maxOutputIndex > 0" >
|
||||
<n8n-select size="mini" v-model="outputIndex" @click.stop>
|
||||
|
@ -51,7 +50,7 @@
|
|||
</span>
|
||||
|
||||
<span v-if="maxRunIndex > 0">
|
||||
| Data of Execution:
|
||||
| Data of Execution:
|
||||
</span>
|
||||
<span class="opts">
|
||||
<n8n-select v-if="maxRunIndex > 0" size="mini" v-model="runIndex" @click.stop>
|
||||
|
@ -270,6 +269,9 @@ export default mixins(
|
|||
MAX_DISPLAY_ITEMS_AUTO_ALL,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.init();
|
||||
},
|
||||
computed: {
|
||||
hasNodeRun(): boolean {
|
||||
return Boolean(this.node && this.workflowRunData && this.workflowRunData.hasOwnProperty(this.node.name));
|
||||
|
@ -423,6 +425,18 @@ export default mixins(
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
// Reset the selected output index every time another node gets selected
|
||||
this.outputIndex = 0;
|
||||
this.maxDisplayItems = 25;
|
||||
this.refreshDataSize();
|
||||
if (this.displayMode === 'Binary') {
|
||||
this.closeBinaryDataDisplay();
|
||||
if (this.binaryData.length === 0) {
|
||||
this.displayMode = 'Table';
|
||||
}
|
||||
}
|
||||
},
|
||||
closeBinaryDataDisplay () {
|
||||
this.binaryDataDisplayVisible = false;
|
||||
this.binaryDataDisplayData = null;
|
||||
|
@ -607,17 +621,8 @@ export default mixins(
|
|||
},
|
||||
},
|
||||
watch: {
|
||||
node (newNode, oldNode) {
|
||||
// Reset the selected output index every time another node gets selected
|
||||
this.outputIndex = 0;
|
||||
this.maxDisplayItems = 25;
|
||||
this.refreshDataSize();
|
||||
if (this.displayMode === 'Binary') {
|
||||
this.closeBinaryDataDisplay();
|
||||
if (this.binaryData.length === 0) {
|
||||
this.displayMode = 'Table';
|
||||
}
|
||||
}
|
||||
node() {
|
||||
this.init();
|
||||
},
|
||||
jsonData () {
|
||||
this.refreshDataSize();
|
||||
|
@ -630,8 +635,6 @@ export default mixins(
|
|||
this.runIndex = Math.min(this.runIndex, this.maxRunIndex);
|
||||
},
|
||||
},
|
||||
mounted () {
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -639,14 +642,8 @@ export default mixins(
|
|||
|
||||
.run-data-view {
|
||||
position: relative;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin-left: 350px;
|
||||
width: calc(100% - 350px);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 100;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
background-color: #f9f9f9;
|
||||
|
||||
.data-display-content {
|
||||
|
@ -657,6 +654,7 @@ export default mixins(
|
|||
right: 0;
|
||||
overflow-y: auto;
|
||||
line-height: 1.5;
|
||||
word-break: normal;
|
||||
|
||||
.binary-data-row {
|
||||
display: inline-flex;
|
||||
|
@ -795,6 +793,10 @@ export default mixins(
|
|||
.title-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
> * {
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.title-data-display-selector {
|
||||
|
|
58
packages/editor-ui/src/components/SaveButton.vue
Normal file
58
packages/editor-ui/src/components/SaveButton.vue
Normal file
|
@ -0,0 +1,58 @@
|
|||
<template>
|
||||
<span :class="$style.container">
|
||||
<span :class="$style.saved" v-if="saved">{{ savedLabel }}</span>
|
||||
<n8n-button
|
||||
v-else
|
||||
:label="isSaving ? savingLabel : saveLabel"
|
||||
:loading="isSaving"
|
||||
:disabled="disabled"
|
||||
@click="$emit('click')"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: "SaveButton",
|
||||
props: {
|
||||
saved: {
|
||||
type: Boolean,
|
||||
},
|
||||
isSaving: {
|
||||
type: Boolean,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
},
|
||||
saveLabel: {
|
||||
type: String,
|
||||
default: 'Save',
|
||||
},
|
||||
savingLabel: {
|
||||
type: String,
|
||||
default: 'Saving',
|
||||
},
|
||||
savedLabel: {
|
||||
type: String,
|
||||
default: 'Saved',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
width: 65px;
|
||||
}
|
||||
|
||||
.saved {
|
||||
color: $--custom-font-very-light;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 12px;
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xs) var(--spacing-xs);
|
||||
}
|
||||
</style>
|
|
@ -1,51 +0,0 @@
|
|||
<template>
|
||||
<span :class="$style.container">
|
||||
<n8n-button v-if="isDirty || isNewWorkflow" label="Save" :disabled="isWorkflowSaving" @click="save" />
|
||||
<span :class="$style.saved" v-else>Saved</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from "vue-typed-mixins";
|
||||
import { mapGetters } from "vuex";
|
||||
|
||||
import { workflowHelpers } from "@/components/mixins/workflowHelpers";
|
||||
|
||||
export default mixins(workflowHelpers).extend({
|
||||
name: "SaveWorkflowButton",
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isDirty: "getStateIsDirty",
|
||||
}),
|
||||
isWorkflowSaving(): boolean {
|
||||
return this.$store.getters.isActionActive("workflowSaving");
|
||||
},
|
||||
isNewWorkflow(): boolean {
|
||||
return !this.$route.params.name;
|
||||
},
|
||||
isSaved(): boolean {
|
||||
return !this.isWorkflowSaving && !this.isDirty && !this.isNewWorkflow;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
save() {
|
||||
this.saveCurrentWorkflow();
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
width: 65px;
|
||||
}
|
||||
|
||||
.saved {
|
||||
color: $--custom-font-very-light;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 12px;
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xs) var(--spacing-xs);
|
||||
}
|
||||
</style>
|
|
@ -4,7 +4,7 @@
|
|||
:name="modalName"
|
||||
:eventBus="modalBus"
|
||||
@enter="onEnter"
|
||||
size="md"
|
||||
minWidth="620px"
|
||||
>
|
||||
<template v-slot:content>
|
||||
<el-row>
|
||||
|
@ -186,5 +186,6 @@ export default mixins(showMessage).extend({
|
|||
<style lang="scss" scoped>
|
||||
.el-row {
|
||||
min-height: $--tags-manager-min-height;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,15 +1,59 @@
|
|||
<template functional>
|
||||
<span>
|
||||
{{$options.format(props.date)}}
|
||||
<span :title="$options.methods.convertToHumanReadableDate($props)">
|
||||
{{$options.methods.format(props)}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { format } from 'timeago.js';
|
||||
import { format, LocaleFunc, register } from 'timeago.js';
|
||||
import { convertToHumanReadableDate } from './helpers';
|
||||
|
||||
const localeFunc = (num: number, index: number, totalSec: number): [string, string] => {
|
||||
// number: the timeago / timein number;
|
||||
// index: the index of array below;
|
||||
// totalSec: total seconds between date to be formatted and today's date;
|
||||
return [
|
||||
['Just now', 'Right now'],
|
||||
['Just now', 'Right now'], // ['%s seconds ago', 'in %s seconds'],
|
||||
['1 minute ago', 'in 1 minute'],
|
||||
['%s minutes ago', 'in %s minutes'],
|
||||
['1 hour ago', 'in 1 hour'],
|
||||
['%s hours ago', 'in %s hours'],
|
||||
['1 day ago', 'in 1 day'],
|
||||
['%s days ago', 'in %s days'],
|
||||
['1 week ago', 'in 1 week'],
|
||||
['%s weeks ago', 'in %s weeks'],
|
||||
['1 month ago', 'in 1 month'],
|
||||
['%s months ago', 'in %s months'],
|
||||
['1 year ago', 'in 1 year'],
|
||||
['%s years ago', 'in %s years'],
|
||||
][index] as [string, string];
|
||||
};
|
||||
|
||||
register('main', localeFunc as LocaleFunc);
|
||||
|
||||
export default {
|
||||
name: 'UpdatesPanel',
|
||||
props: ['date'],
|
||||
format,
|
||||
props: {
|
||||
date: {
|
||||
type: String,
|
||||
},
|
||||
capitalize: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
format(props: {date: string, capitalize: boolean}) {
|
||||
const text = format(props.date, 'main');
|
||||
|
||||
if (!props.capitalize) {
|
||||
return text.toLowerCase();
|
||||
}
|
||||
|
||||
return text;
|
||||
},
|
||||
convertToHumanReadableDate,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
<template>
|
||||
<Modal
|
||||
<ModalDrawer
|
||||
:name="modalName"
|
||||
:drawer="true"
|
||||
:visible="visible"
|
||||
drawerDirection="ltr"
|
||||
drawerWidth="520px"
|
||||
direction="ltr"
|
||||
width="520px"
|
||||
>
|
||||
<template slot="header">
|
||||
<span :class="$style.title">We’ve been busy ✨</span>
|
||||
|
@ -40,25 +38,25 @@
|
|||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</Modal>
|
||||
</ModalDrawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import Modal from './Modal.vue';
|
||||
import ModalDrawer from './ModalDrawer.vue';
|
||||
import TimeAgo from './TimeAgo.vue';
|
||||
import VersionCard from './VersionCard.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'UpdatesPanel',
|
||||
components: {
|
||||
Modal,
|
||||
ModalDrawer,
|
||||
VersionCard,
|
||||
TimeAgo,
|
||||
},
|
||||
props: ['modalName', 'visible'],
|
||||
props: ['modalName'],
|
||||
computed: {
|
||||
...mapGetters('versions', ['nextVersions', 'currentVersion', 'infoUrl']),
|
||||
},
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
<template>
|
||||
<Modal
|
||||
:name="modalName"
|
||||
size="xl"
|
||||
width="80%"
|
||||
minWidth="620px"
|
||||
:classic="true"
|
||||
>
|
||||
<template v-slot:header>
|
||||
<div class="workflows-header">
|
||||
|
@ -63,6 +65,7 @@ import Modal from '@/components/Modal.vue';
|
|||
import TagsContainer from '@/components/TagsContainer.vue';
|
||||
import TagsDropdown from '@/components/TagsDropdown.vue';
|
||||
import WorkflowActivator from '@/components/WorkflowActivator.vue';
|
||||
import { convertToDisplayDate } from './helpers';
|
||||
|
||||
export default mixins(
|
||||
genericHelpers,
|
||||
|
@ -176,8 +179,8 @@ export default mixins(
|
|||
this.workflows = data;
|
||||
|
||||
this.workflows.forEach((workflowData: IWorkflowShortResponse) => {
|
||||
workflowData.createdAt = this.convertToDisplayDate(workflowData.createdAt as number);
|
||||
workflowData.updatedAt = this.convertToDisplayDate(workflowData.updatedAt as number);
|
||||
workflowData.createdAt = convertToDisplayDate(workflowData.createdAt as number);
|
||||
workflowData.updatedAt = convertToDisplayDate(workflowData.updatedAt as number);
|
||||
});
|
||||
this.isDataLoading = false;
|
||||
},
|
||||
|
@ -215,7 +218,6 @@ export default mixins(
|
|||
flex-grow: 1;
|
||||
|
||||
h1 {
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,14 @@
|
|||
<template>
|
||||
<span>
|
||||
<el-dialog class="workflow-settings" custom-class="classic" :visible="dialogVisible" append-to-body width="65%" title="Workflow Settings" :before-close="closeDialog">
|
||||
<div v-loading="isLoading">
|
||||
<Modal
|
||||
:name="modalName"
|
||||
width="65%"
|
||||
maxHeight="80%"
|
||||
title="Workflow Settings"
|
||||
:eventBus="modalBus"
|
||||
:scrollable="true"
|
||||
>
|
||||
<template v-slot:content>
|
||||
<div v-loading="isLoading" class="workflow-settings">
|
||||
<el-row>
|
||||
<el-col :span="10" class="setting-name">
|
||||
Error Workflow:
|
||||
|
@ -156,12 +163,14 @@
|
|||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<n8n-button label="Save" size="large" @click="saveSettings" />
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</span>
|
||||
</template>
|
||||
<template v-slot:footer>
|
||||
<div class="action-buttons">
|
||||
<n8n-button label="Save" size="large" @click="saveSettings" />
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
@ -177,6 +186,7 @@ import {
|
|||
IWorkflowSettings,
|
||||
IWorkflowShortResponse,
|
||||
} from '@/Interface';
|
||||
import Modal from './Modal.vue';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
|
@ -187,9 +197,14 @@ export default mixins(
|
|||
showMessage,
|
||||
).extend({
|
||||
name: 'WorkflowSettings',
|
||||
props: [
|
||||
'dialogVisible',
|
||||
],
|
||||
props: {
|
||||
modalName: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Modal,
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: true,
|
||||
|
@ -220,22 +235,74 @@ export default mixins(
|
|||
executionTimeout: this.$store.getters.executionTimeout,
|
||||
maxExecutionTimeout: this.$store.getters.maxExecutionTimeout,
|
||||
timeoutHMS: { hours: 0, minutes: 0, seconds: 0 } as ITimeoutHMS,
|
||||
modalBus: new Vue(),
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
dialogVisible (newValue, oldValue) {
|
||||
if (newValue) {
|
||||
this.openDialog();
|
||||
}
|
||||
this.$externalHooks().run('workflowSettings.dialogVisibleChanged', { dialogVisible: newValue });
|
||||
},
|
||||
async mounted () {
|
||||
if (this.$route.params.name === undefined) {
|
||||
this.$showMessage({
|
||||
title: 'No workflow active',
|
||||
message: `No workflow active to display settings of.`,
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
});
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
this.defaultValues.saveDataErrorExecution = this.$store.getters.saveDataErrorExecution;
|
||||
this.defaultValues.saveDataSuccessExecution = this.$store.getters.saveDataSuccessExecution;
|
||||
this.defaultValues.saveManualExecutions = this.$store.getters.saveManualExecutions;
|
||||
this.defaultValues.timezone = this.$store.getters.timezone;
|
||||
|
||||
this.isLoading = true;
|
||||
const promises = [];
|
||||
promises.push(this.loadWorkflows());
|
||||
promises.push(this.loadSaveDataErrorExecutionOptions());
|
||||
promises.push(this.loadSaveDataSuccessExecutionOptions());
|
||||
promises.push(this.loadSaveExecutionProgressOptions());
|
||||
promises.push(this.loadSaveManualOptions());
|
||||
promises.push(this.loadTimezones());
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
this.$showError(error, 'Problem loading settings', 'The following error occurred loading the data:');
|
||||
}
|
||||
|
||||
const workflowSettings = JSON.parse(JSON.stringify(this.$store.getters.workflowSettings));
|
||||
|
||||
if (workflowSettings.timezone === undefined) {
|
||||
workflowSettings.timezone = 'DEFAULT';
|
||||
}
|
||||
if (workflowSettings.saveDataErrorExecution === undefined) {
|
||||
workflowSettings.saveDataErrorExecution = 'DEFAULT';
|
||||
}
|
||||
if (workflowSettings.saveDataSuccessExecution === undefined) {
|
||||
workflowSettings.saveDataSuccessExecution = 'DEFAULT';
|
||||
}
|
||||
if (workflowSettings.saveExecutionProgress === undefined) {
|
||||
workflowSettings.saveExecutionProgress = 'DEFAULT';
|
||||
}
|
||||
if (workflowSettings.saveManualExecutions === undefined) {
|
||||
workflowSettings.saveManualExecutions = 'DEFAULT';
|
||||
}
|
||||
if (workflowSettings.executionTimeout === undefined) {
|
||||
workflowSettings.executionTimeout = this.$store.getters.executionTimeout;
|
||||
}
|
||||
if (workflowSettings.maxExecutionTimeout === undefined) {
|
||||
workflowSettings.maxExecutionTimeout = this.$store.getters.maxExecutionTimeout;
|
||||
}
|
||||
|
||||
Vue.set(this, 'workflowSettings', workflowSettings);
|
||||
this.timeoutHMS = this.convertToHMS(workflowSettings.executionTimeout);
|
||||
this.isLoading = false;
|
||||
|
||||
this.$externalHooks().run('workflowSettings.dialogVisibleChanged', { dialogVisible: true });
|
||||
},
|
||||
methods: {
|
||||
closeDialog () {
|
||||
// Handle the close externally as the visible parameter is an external prop
|
||||
// and is so not allowed to be changed here.
|
||||
this.$emit('closeDialog');
|
||||
return false;
|
||||
this.modalBus.$emit('close');
|
||||
},
|
||||
setTimeout (key: string, value: string) {
|
||||
const time = value ? parseInt(value, 10) : 0;
|
||||
|
@ -362,66 +429,6 @@ export default mixins(
|
|||
|
||||
Vue.set(this, 'workflows', workflows);
|
||||
},
|
||||
async openDialog () {
|
||||
if (this.$route.params.name === undefined) {
|
||||
this.$showMessage({
|
||||
title: 'No workflow active',
|
||||
message: `No workflow active to display settings of.`,
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
});
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
this.defaultValues.saveDataErrorExecution = this.$store.getters.saveDataErrorExecution;
|
||||
this.defaultValues.saveDataSuccessExecution = this.$store.getters.saveDataSuccessExecution;
|
||||
this.defaultValues.saveManualExecutions = this.$store.getters.saveManualExecutions;
|
||||
this.defaultValues.timezone = this.$store.getters.timezone;
|
||||
|
||||
this.isLoading = true;
|
||||
const promises = [];
|
||||
promises.push(this.loadWorkflows());
|
||||
promises.push(this.loadSaveDataErrorExecutionOptions());
|
||||
promises.push(this.loadSaveDataSuccessExecutionOptions());
|
||||
promises.push(this.loadSaveExecutionProgressOptions());
|
||||
promises.push(this.loadSaveManualOptions());
|
||||
promises.push(this.loadTimezones());
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
this.$showError(error, 'Problem loading settings', 'The following error occurred loading the data:');
|
||||
}
|
||||
|
||||
const workflowSettings = JSON.parse(JSON.stringify(this.$store.getters.workflowSettings));
|
||||
|
||||
if (workflowSettings.timezone === undefined) {
|
||||
workflowSettings.timezone = 'DEFAULT';
|
||||
}
|
||||
if (workflowSettings.saveDataErrorExecution === undefined) {
|
||||
workflowSettings.saveDataErrorExecution = 'DEFAULT';
|
||||
}
|
||||
if (workflowSettings.saveDataSuccessExecution === undefined) {
|
||||
workflowSettings.saveDataSuccessExecution = 'DEFAULT';
|
||||
}
|
||||
if (workflowSettings.saveExecutionProgress === undefined) {
|
||||
workflowSettings.saveExecutionProgress = 'DEFAULT';
|
||||
}
|
||||
if (workflowSettings.saveManualExecutions === undefined) {
|
||||
workflowSettings.saveManualExecutions = 'DEFAULT';
|
||||
}
|
||||
if (workflowSettings.executionTimeout === undefined) {
|
||||
workflowSettings.executionTimeout = this.$store.getters.executionTimeout;
|
||||
}
|
||||
if (workflowSettings.maxExecutionTimeout === undefined) {
|
||||
workflowSettings.maxExecutionTimeout = this.$store.getters.maxExecutionTimeout;
|
||||
}
|
||||
|
||||
Vue.set(this, 'workflowSettings', workflowSettings);
|
||||
this.timeoutHMS = this.convertToHMS(workflowSettings.executionTimeout);
|
||||
this.isLoading = false;
|
||||
},
|
||||
async saveSettings () {
|
||||
// Set that the active state should be changed
|
||||
const data: IWorkflowDataUpdate = {
|
||||
|
|
|
@ -1,5 +1,21 @@
|
|||
import dateformat from 'dateformat';
|
||||
|
||||
const KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
|
||||
|
||||
export function addTargetBlank(html: string) {
|
||||
return html.includes('href=')
|
||||
? html.replace(/href=/g, 'target="_blank" href=')
|
||||
: html;
|
||||
}
|
||||
|
||||
export function convertToDisplayDate (epochTime: number) {
|
||||
return dateformat(epochTime, 'yyyy-mm-dd HH:MM:ss');
|
||||
}
|
||||
|
||||
export function convertToHumanReadableDate (epochTime: number) {
|
||||
return dateformat(epochTime, 'd mmmm, yyyy @ HH:MM Z');
|
||||
}
|
||||
|
||||
export function getAppNameFromCredType(name: string) {
|
||||
return name.split(' ').filter((word) => !KEYWORDS_TO_FILTER.includes(word)).join(' ');
|
||||
}
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
import dateformat from 'dateformat';
|
||||
|
||||
import { showMessage } from '@/components/mixins/showMessage';
|
||||
import { MessageType } from '@/Interface';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
@ -22,9 +19,6 @@ export const genericHelpers = mixins(showMessage).extend({
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
convertToDisplayDate (epochTime: number) {
|
||||
return dateformat(epochTime, 'yyyy-mm-dd HH:MM:ss');
|
||||
},
|
||||
displayTimer (msPassed: number, showMs = false): string {
|
||||
if (msPassed < 60000) {
|
||||
if (showMs === false) {
|
||||
|
@ -91,20 +85,5 @@ export const genericHelpers = mixins(showMessage).extend({
|
|||
// @ts-ignore
|
||||
await this.debouncedFunctions[functionName].apply(this, inputParameters);
|
||||
},
|
||||
|
||||
async confirmMessage (message: string, headline: string, type = 'warning' as MessageType, confirmButtonText = 'OK', cancelButtonText = 'Cancel'): Promise<boolean> {
|
||||
try {
|
||||
await this.$confirm(message, headline, {
|
||||
confirmButtonText,
|
||||
cancelButtonText,
|
||||
type,
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
},
|
||||
});
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue