🔀 Merge branch 'master' into arpadgabor-feat/monaco

This commit is contained in:
Jan Oberhauser 2021-12-20 22:47:06 +01:00
commit 76fcc0ba42
945 changed files with 65425 additions and 6618 deletions

View file

@ -34,10 +34,10 @@ jobs:
- -
name: Install dependencies name: Install dependencies
run: | run: |
apt update -y sudo apt update -y
echo 'tzdata tzdata/Areas select Europe' | debconf-set-selections echo 'tzdata tzdata/Areas select Europe' | sudo debconf-set-selections
echo 'tzdata tzdata/Zones/Europe select Paris' | debconf-set-selections echo 'tzdata tzdata/Zones/Europe select Paris' | sudo debconf-set-selections
DEBIAN_FRONTEND="noninteractive" apt-get install -y graphicsmagick DEBIAN_FRONTEND="noninteractive" sudo apt-get install -y graphicsmagick
shell: bash shell: bash
- -
name: npm install and build name: npm install and build
@ -75,19 +75,19 @@ jobs:
shell: bash shell: bash
env: env:
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}} N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
- # -
name: Export credentials # name: Export credentials
if: always() # if: always()
run: n8n/packages/cli/bin/n8n export:credentials --output=test-workflows/credentials.json --all --pretty # run: n8n/packages/cli/bin/n8n export:credentials --output=test-workflows/credentials.json --all --pretty
shell: bash # shell: bash
env: # env:
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}} # N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
- # -
name: Commit and push credential changes # name: Commit and push credential changes
if: always() # if: always()
run: | # run: |
cd test-workflows # cd test-workflows
git config --global user.name 'n8n test bot' # git config --global user.name 'n8n test bot'
git config --global user.email 'n8n-test-bot@users.noreply.github.com' # git config --global user.email 'n8n-test-bot@users.noreply.github.com'
git commit -am "Automated credential update" # git commit -am "Automated credential update"
git push --force --quiet "https://janober:${{ secrets.TOKEN }}@github.com/n8n-io/test-workflows.git" main:main # git push --force --quiet "https://janober:${{ secrets.TOKEN }}@github.com/n8n-io/test-workflows.git" main:main

3
.gitignore vendored
View file

@ -5,7 +5,6 @@ tmp
dist dist
npm-debug.log* npm-debug.log*
lerna-debug.log lerna-debug.log
package-lock.json
yarn.lock yarn.lock
google-generated-credentials.json google-generated-credentials.json
_START_PACKAGE _START_PACKAGE
@ -13,5 +12,5 @@ _START_PACKAGE
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
vetur.config.js
nodelinter.config.json nodelinter.config.json
packages/*/package-lock.json

1
.npmrc Normal file
View file

@ -0,0 +1 @@
legacy-peer-deps=true

View file

@ -6,6 +6,8 @@ if [ -d /root/.n8n ] ; then
ln -s /root/.n8n /home/node/ ln -s /root/.n8n /home/node/
fi fi
chown -R node /home/node
if [ "$#" -gt 0 ]; then if [ "$#" -gt 0 ]; then
# Got started with arguments # Got started with arguments
COMMAND=$1; COMMAND=$1;

44386
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -26,7 +26,7 @@ var nodeVersion = process.versions.node.split('.');
if (parseInt(nodeVersion[0], 10) < 14) { if (parseInt(nodeVersion[0], 10) < 14) {
console.log(`\nYour Node.js version (${process.versions.node}) is too old to run n8n.\nPlease update to version 14 or later!\n`); console.log(`\nYour Node.js version (${process.versions.node}) is too old to run n8n.\nPlease update to version 14 or later!\n`);
process.exit(0); process.exit(1);
} }
require('@oclif/command').run() require('@oclif/command').run()

View file

@ -11,6 +11,8 @@ import {
CredentialTypes, CredentialTypes,
Db, Db,
ExternalHooks, ExternalHooks,
GenericHelpers,
InternalHooksManager,
IWorkflowBase, IWorkflowBase,
IWorkflowExecutionDataProcess, IWorkflowExecutionDataProcess,
LoadNodesAndCredentials, LoadNodesAndCredentials,
@ -123,6 +125,10 @@ export class Execute extends Command {
const externalHooks = ExternalHooks(); const externalHooks = ExternalHooks();
await externalHooks.init(); await externalHooks.init();
const instanceId = await UserSettings.getInstanceId();
const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli);
// Add the found types to an instance other parts of the application can use // Add the found types to an instance other parts of the application can use
const nodeTypes = NodeTypes(); const nodeTypes = NodeTypes();
await nodeTypes.init(loadNodesAndCredentials.nodeTypes); await nodeTypes.init(loadNodesAndCredentials.nodeTypes);

View file

@ -28,6 +28,8 @@ import {
CredentialTypes, CredentialTypes,
Db, Db,
ExternalHooks, ExternalHooks,
GenericHelpers,
InternalHooksManager,
IWorkflowDb, IWorkflowDb,
IWorkflowExecutionDataProcess, IWorkflowExecutionDataProcess,
LoadNodesAndCredentials, LoadNodesAndCredentials,
@ -55,12 +57,12 @@ export class ExecuteBatch extends Command {
static executionTimeout = 3 * 60 * 1000; static executionTimeout = 3 * 60 * 1000;
static examples = [ static examples = [
`$ n8n executeAll`, `$ n8n executeBatch`,
`$ n8n executeAll --concurrency=10 --skipList=/data/skipList.txt`, `$ n8n executeBatch --concurrency=10 --skipList=/data/skipList.txt`,
`$ n8n executeAll --debug --output=/data/output.json`, `$ n8n executeBatch --debug --output=/data/output.json`,
`$ n8n executeAll --ids=10,13,15 --shortOutput`, `$ n8n executeBatch --ids=10,13,15 --shortOutput`,
`$ n8n executeAll --snapshot=/data/snapshots --shallow`, `$ n8n executeBatch --snapshot=/data/snapshots --shallow`,
`$ n8n executeAll --compare=/data/previousExecutionData --retries=2`, `$ n8n executeBatch --compare=/data/previousExecutionData --retries=2`,
]; ];
static flags = { static flags = {
@ -303,6 +305,10 @@ export class ExecuteBatch extends Command {
const externalHooks = ExternalHooks(); const externalHooks = ExternalHooks();
await externalHooks.init(); await externalHooks.init();
const instanceId = await UserSettings.getInstanceId();
const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli);
// Add the found types to an instance other parts of the application can use // Add the found types to an instance other parts of the application can use
const nodeTypes = NodeTypes(); const nodeTypes = NodeTypes();
await nodeTypes.init(loadNodesAndCredentials.nodeTypes); await nodeTypes.init(loadNodesAndCredentials.nodeTypes);
@ -813,10 +819,22 @@ export class ExecuteBatch extends Command {
const changes = diff(JSON.parse(contents), data, { keysOnly: true }); const changes = diff(JSON.parse(contents), data, { keysOnly: true });
if (changes !== undefined) { if (changes !== undefined) {
// If we had only additions with no removals
// Then we treat as a warning and not an error.
// To find this, we convert the object to JSON
// and search for the `__deleted` string
const changesJson = JSON.stringify(changes);
if (changesJson.includes('__deleted')) {
// we have structural changes. Report them. // we have structural changes. Report them.
executionResult.error = `Workflow may contain breaking changes`; executionResult.error = 'Workflow may contain breaking changes';
executionResult.changes = changes; executionResult.changes = changes;
executionResult.executionStatus = 'error'; executionResult.executionStatus = 'error';
} else {
executionResult.error =
'Workflow contains new data that previously did not exist.';
executionResult.changes = changes;
executionResult.executionStatus = 'warning';
}
} else { } else {
executionResult.executionStatus = 'success'; executionResult.executionStatus = 'success';
} }
@ -838,7 +856,8 @@ export class ExecuteBatch extends Command {
} }
} }
} catch (e) { } catch (e) {
executionResult.error = 'Workflow failed to execute.'; // eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access
executionResult.error = `Workflow failed to execute: ${e.message}`;
executionResult.executionStatus = 'error'; executionResult.executionStatus = 'error';
} }
clearTimeout(timeoutTimer); clearTimeout(timeoutTimer);

View file

@ -6,7 +6,6 @@ import { INode, INodeCredentialsDetails, LoggerProxy } from 'n8n-workflow';
import * as fs from 'fs'; import * as fs from 'fs';
import * as glob from 'fast-glob'; import * as glob from 'fast-glob';
import * as path from 'path';
import { UserSettings } from 'n8n-core'; import { UserSettings } from 'n8n-core';
import { getLogger } from '../../src/Logger'; import { getLogger } from '../../src/Logger';
import { Db, ICredentialsDb } from '../../src'; import { Db, ICredentialsDb } from '../../src';
@ -86,9 +85,12 @@ export class ImportWorkflowsCommand extends Command {
const credentialsEntities = (await Db.collections.Credentials?.find()) ?? []; const credentialsEntities = (await Db.collections.Credentials?.find()) ?? [];
let i; let i;
if (flags.separate) { if (flags.separate) {
const files = await glob( let inputPath = flags.input;
`${flags.input.endsWith(path.sep) ? flags.input : flags.input + path.sep}*.json`, if (process.platform === 'win32') {
); inputPath = inputPath.replace(/\\/g, '/');
}
inputPath = inputPath.replace(/\/$/g, '');
const files = await glob(`${inputPath}/*.json`);
for (i = 0; i < files.length; i++) { for (i = 0; i < files.length; i++) {
const workflow = JSON.parse(fs.readFileSync(files[i], { encoding: 'utf8' })); const workflow = JSON.parse(fs.readFileSync(files[i], { encoding: 'utf8' }));
if (credentialsEntities.length > 0) { if (credentialsEntities.length > 0) {

View file

@ -153,17 +153,6 @@ export class Start extends Command {
LoggerProxy.init(logger); LoggerProxy.init(logger);
logger.info('Initializing n8n process'); logger.info('Initializing n8n process');
logger.info(
'\n' +
'****************************************************\n' +
'* *\n' +
'* n8n now sends selected, anonymous telemetry. *\n' +
'* For more details (and how to opt out): *\n' +
'* https://docs.n8n.io/reference/telemetry.html *\n' +
'* *\n' +
'****************************************************\n',
);
// Start directly with the init of the database to improve startup time // Start directly with the init of the database to improve startup time
const startDbInitPromise = Db.init().catch((error: Error) => { const startDbInitPromise = Db.init().catch((error: Error) => {
logger.error(`There was an error initializing DB: "${error.message}"`); logger.error(`There was an error initializing DB: "${error.message}"`);
@ -313,7 +302,8 @@ export class Start extends Command {
} }
const instanceId = await UserSettings.getInstanceId(); const instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId); const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli);
await Server.start(); await Server.start();

View file

@ -149,7 +149,8 @@ export class Webhook extends Command {
await startDbInitPromise; await startDbInitPromise;
const instanceId = await UserSettings.getInstanceId(); const instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId); const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli);
if (config.get('executions.mode') === 'queue') { if (config.get('executions.mode') === 'queue') {
const redisHost = config.get('queue.bull.redis.host'); const redisHost = config.get('queue.bull.redis.host');

View file

@ -12,7 +12,7 @@ import * as PCancelable from 'p-cancelable';
import { Command, flags } from '@oclif/command'; import { Command, flags } from '@oclif/command';
import { UserSettings, WorkflowExecute } from 'n8n-core'; import { UserSettings, WorkflowExecute } from 'n8n-core';
import { INodeTypes, IRun, Workflow, LoggerProxy } from 'n8n-workflow'; import { IExecuteResponsePromiseData, INodeTypes, IRun, Workflow, LoggerProxy } from 'n8n-workflow';
import { FindOneOptions } from 'typeorm'; import { FindOneOptions } from 'typeorm';
@ -25,11 +25,13 @@ import {
GenericHelpers, GenericHelpers,
IBullJobData, IBullJobData,
IBullJobResponse, IBullJobResponse,
IBullWebhookResponse,
IExecutionFlattedDb, IExecutionFlattedDb,
InternalHooksManager, InternalHooksManager,
LoadNodesAndCredentials, LoadNodesAndCredentials,
NodeTypes, NodeTypes,
ResponseHelper, ResponseHelper,
WebhookHelpers,
WorkflowExecuteAdditionalData, WorkflowExecuteAdditionalData,
} from '../src'; } from '../src';
@ -172,6 +174,16 @@ export class Worker extends Command {
currentExecutionDb.workflowData, currentExecutionDb.workflowData,
{ retryOf: currentExecutionDb.retryOf as string }, { retryOf: currentExecutionDb.retryOf as string },
); );
additionalData.hooks.hookFunctions.sendResponse = [
async (response: IExecuteResponsePromiseData): Promise<void> => {
await job.progress({
executionId: job.data.executionId as string,
response: WebhookHelpers.encodeWebhookResponse(response),
} as IBullWebhookResponse);
},
];
additionalData.executionId = jobData.executionId; additionalData.executionId = jobData.executionId;
let workflowExecute: WorkflowExecute; let workflowExecute: WorkflowExecute;
@ -259,10 +271,10 @@ export class Worker extends Command {
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
Worker.jobQueue.process(flags.concurrency, async (job) => this.runJob(job, nodeTypes)); Worker.jobQueue.process(flags.concurrency, async (job) => this.runJob(job, nodeTypes));
const instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId);
const versions = await GenericHelpers.getVersions(); const versions = await GenericHelpers.getVersions();
const instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId, versions.cli);
console.info('\nn8n worker is now ready'); console.info('\nn8n worker is now ready');
console.info(` * Version: ${versions.cli}`); console.info(` * Version: ${versions.cli}`);

View file

@ -689,6 +689,13 @@ const config = convict({
}, },
}, },
}, },
defaultLocale: {
doc: 'Default locale for the UI',
format: String,
default: 'en',
env: 'N8N_DEFAULT_LOCALE',
},
}); });
// Overwrite default configuration with settings which got defined in // Overwrite default configuration with settings which got defined in

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n", "name": "n8n",
"version": "0.145.0", "version": "0.155.2",
"description": "n8n Workflow Automation Tool", "description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
@ -70,6 +70,7 @@
"@types/open": "^6.1.0", "@types/open": "^6.1.0",
"@types/parseurl": "^1.3.1", "@types/parseurl": "^1.3.1",
"@types/request-promise-native": "~1.0.15", "@types/request-promise-native": "~1.0.15",
"@types/validator": "^13.7.0",
"concurrently": "^5.1.0", "concurrently": "^5.1.0",
"jest": "^26.4.2", "jest": "^26.4.2",
"nodemon": "^2.0.2", "nodemon": "^2.0.2",
@ -83,7 +84,7 @@
"dependencies": { "dependencies": {
"@oclif/command": "^1.5.18", "@oclif/command": "^1.5.18",
"@oclif/errors": "^1.2.2", "@oclif/errors": "^1.2.2",
"@rudderstack/rudder-sdk-node": "^1.0.2", "@rudderstack/rudder-sdk-node": "1.0.6",
"@types/json-diff": "^0.5.1", "@types/json-diff": "^0.5.1",
"@types/jsonwebtoken": "^8.5.2", "@types/jsonwebtoken": "^8.5.2",
"basic-auth": "^2.0.1", "basic-auth": "^2.0.1",
@ -110,10 +111,10 @@
"localtunnel": "^2.0.0", "localtunnel": "^2.0.0",
"lodash.get": "^4.4.2", "lodash.get": "^4.4.2",
"mysql2": "~2.3.0", "mysql2": "~2.3.0",
"n8n-core": "~0.90.0", "n8n-core": "~0.97.0",
"n8n-editor-ui": "~0.113.0", "n8n-editor-ui": "~0.122.1",
"n8n-nodes-base": "~0.142.0", "n8n-nodes-base": "~0.153.0",
"n8n-workflow": "~0.73.0", "n8n-workflow": "~0.80.0",
"oauth-1.0a": "^2.2.6", "oauth-1.0a": "^2.2.6",
"open": "^7.0.0", "open": "^7.0.0",
"pg": "^8.3.0", "pg": "^8.3.0",
@ -122,7 +123,7 @@
"sqlite3": "^5.0.1", "sqlite3": "^5.0.1",
"sse-channel": "^3.1.1", "sse-channel": "^3.1.1",
"tslib": "1.14.1", "tslib": "1.14.1",
"typeorm": "^0.2.30", "typeorm": "0.2.30",
"winston": "^3.3.3" "winston": "^3.3.3"
}, },
"jest": { "jest": {

View file

@ -5,11 +5,15 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { IRun } from 'n8n-workflow'; import {
createDeferredPromise,
import { createDeferredPromise } from 'n8n-core'; IDeferredPromise,
IExecuteResponsePromiseData,
IRun,
} from 'n8n-workflow';
import { ChildProcess } from 'child_process'; import { ChildProcess } from 'child_process';
import { stringify } from 'flatted';
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import * as PCancelable from 'p-cancelable'; import * as PCancelable from 'p-cancelable';
// eslint-disable-next-line import/no-cycle // eslint-disable-next-line import/no-cycle
@ -79,6 +83,7 @@ export class ActiveExecutions {
const execution = { const execution = {
id: executionId, id: executionId,
data: stringify(executionData.executionData!),
waitTill: null, waitTill: null,
}; };
@ -116,6 +121,28 @@ export class ActiveExecutions {
this.activeExecutions[executionId].workflowExecution = workflowExecution; this.activeExecutions[executionId].workflowExecution = workflowExecution;
} }
attachResponsePromise(
executionId: string,
responsePromise: IDeferredPromise<IExecuteResponsePromiseData>,
): void {
if (this.activeExecutions[executionId] === undefined) {
throw new Error(
`No active execution with id "${executionId}" got found to attach to workflowExecution to!`,
);
}
this.activeExecutions[executionId].responsePromise = responsePromise;
}
resolveResponsePromise(executionId: string, response: IExecuteResponsePromiseData): void {
if (this.activeExecutions[executionId] === undefined) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
this.activeExecutions[executionId].responsePromise?.resolve(response);
}
/** /**
* Remove an active execution * Remove an active execution
* *
@ -193,6 +220,7 @@ export class ActiveExecutions {
this.activeExecutions[executionId].postExecutePromises.push(waitPromise); this.activeExecutions[executionId].postExecutePromises.push(waitPromise);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
return waitPromise.promise(); return waitPromise.promise();
} }

View file

@ -12,7 +12,9 @@
import { ActiveWorkflows, NodeExecuteFunctions } from 'n8n-core'; import { ActiveWorkflows, NodeExecuteFunctions } from 'n8n-core';
import { import {
IDeferredPromise,
IExecuteData, IExecuteData,
IExecuteResponsePromiseData,
IGetExecutePollFunctions, IGetExecutePollFunctions,
IGetExecuteTriggerFunctions, IGetExecuteTriggerFunctions,
INode, INode,
@ -40,8 +42,6 @@ import {
NodeTypes, NodeTypes,
ResponseHelper, ResponseHelper,
WebhookHelpers, WebhookHelpers,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
WorkflowCredentials,
WorkflowExecuteAdditionalData, WorkflowExecuteAdditionalData,
WorkflowHelpers, WorkflowHelpers,
WorkflowRunner, WorkflowRunner,
@ -550,6 +550,7 @@ export class ActiveWorkflowRunner {
data: INodeExecutionData[][], data: INodeExecutionData[][],
additionalData: IWorkflowExecuteAdditionalDataWorkflow, additionalData: IWorkflowExecuteAdditionalDataWorkflow,
mode: WorkflowExecuteMode, mode: WorkflowExecuteMode,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
) { ) {
const nodeExecutionStack: IExecuteData[] = [ const nodeExecutionStack: IExecuteData[] = [
{ {
@ -580,7 +581,7 @@ export class ActiveWorkflowRunner {
}; };
const workflowRunner = new WorkflowRunner(); const workflowRunner = new WorkflowRunner();
return workflowRunner.run(runData, true); return workflowRunner.run(runData, true, undefined, undefined, responsePromise);
} }
/** /**
@ -641,13 +642,16 @@ export class ActiveWorkflowRunner {
mode, mode,
activation, activation,
); );
returnFunctions.emit = (data: INodeExecutionData[][]): void => { returnFunctions.emit = (
data: INodeExecutionData[][],
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): void => {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
Logger.debug(`Received trigger for workflow "${workflow.name}"`); Logger.debug(`Received trigger for workflow "${workflow.name}"`);
WorkflowHelpers.saveStaticData(workflow); WorkflowHelpers.saveStaticData(workflow);
// eslint-disable-next-line id-denylist // eslint-disable-next-line id-denylist
this.runWorkflow(workflowData, node, data, additionalData, mode).catch((err) => this.runWorkflow(workflowData, node, data, additionalData, mode, responsePromise).catch(
console.error(err), (error) => console.error(error),
); );
}; };
return returnFunctions; return returnFunctions;

View file

@ -7,19 +7,19 @@ import {
ICredentialsEncrypted, ICredentialsEncrypted,
ICredentialType, ICredentialType,
IDataObject, IDataObject,
IDeferredPromise,
IExecuteResponsePromiseData,
IRun, IRun,
IRunData, IRunData,
IRunExecutionData, IRunExecutionData,
ITaskData, ITaskData,
ITelemetrySettings, ITelemetrySettings,
IWorkflowBase as IWorkflowBaseWorkflow, IWorkflowBase as IWorkflowBaseWorkflow,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
IWorkflowCredentials,
Workflow, Workflow,
WorkflowExecuteMode, WorkflowExecuteMode,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { IDeferredPromise, WorkflowExecute } from 'n8n-core'; import { WorkflowExecute } from 'n8n-core';
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import * as PCancelable from 'p-cancelable'; import * as PCancelable from 'p-cancelable';
@ -47,6 +47,11 @@ export interface IBullJobResponse {
success: boolean; success: boolean;
} }
export interface IBullWebhookResponse {
executionId: string;
response: IExecuteResponsePromiseData;
}
export interface ICustomRequest extends Request { export interface ICustomRequest extends Request {
parsedUrl: Url | undefined; parsedUrl: Url | undefined;
} }
@ -237,6 +242,7 @@ export interface IExecutingWorkflowData {
process?: ChildProcess; process?: ChildProcess;
startedAt: Date; startedAt: Date;
postExecutePromises: Array<IDeferredPromise<IRun | undefined>>; postExecutePromises: Array<IDeferredPromise<IRun | undefined>>;
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>;
workflowExecution?: PCancelable<IRun>; workflowExecution?: PCancelable<IRun>;
} }
@ -308,7 +314,10 @@ export interface IDiagnosticInfo {
export interface IInternalHooksClass { export interface IInternalHooksClass {
onN8nStop(): Promise<void>; onN8nStop(): Promise<void>;
onServerStarted(diagnosticInfo: IDiagnosticInfo): Promise<unknown[]>; onServerStarted(
diagnosticInfo: IDiagnosticInfo,
firstWorkflowCreatedAt?: Date,
): Promise<unknown[]>;
onPersonalizationSurveySubmitted(answers: IPersonalizationSurveyAnswers): Promise<void>; onPersonalizationSurveySubmitted(answers: IPersonalizationSurveyAnswers): Promise<void>;
onWorkflowCreated(workflow: IWorkflowBase): Promise<void>; onWorkflowCreated(workflow: IWorkflowBase): Promise<void>;
onWorkflowDeleted(workflowId: string): Promise<void>; onWorkflowDeleted(workflowId: string): Promise<void>;
@ -394,13 +403,16 @@ export interface IN8nUISettings {
instanceId: string; instanceId: string;
telemetry: ITelemetrySettings; telemetry: ITelemetrySettings;
personalizationSurvey: IPersonalizationSurvey; personalizationSurvey: IPersonalizationSurvey;
defaultLocale: string;
} }
export interface IPersonalizationSurveyAnswers { export interface IPersonalizationSurveyAnswers {
companySize: string | null;
codingSkill: string | null; codingSkill: string | null;
workArea: string | null; companyIndustry: string[];
companySize: string | null;
otherCompanyIndustry: string | null;
otherWorkArea: string | null; otherWorkArea: string | null;
workArea: string[] | string | null;
} }
export interface IPersonalizationSurvey { export interface IPersonalizationSurvey {
@ -490,6 +502,7 @@ export interface IPushDataConsoleMessage {
export interface IResponseCallbackData { export interface IResponseCallbackData {
data?: IDataObject | IDataObject[]; data?: IDataObject | IDataObject[];
headers?: object;
noWebhookResponse?: boolean; noWebhookResponse?: boolean;
responseCode?: number; responseCode?: number;
} }

View file

@ -9,9 +9,16 @@ import {
import { Telemetry } from './telemetry'; import { Telemetry } from './telemetry';
export class InternalHooksClass implements IInternalHooksClass { export class InternalHooksClass implements IInternalHooksClass {
constructor(private telemetry: Telemetry) {} private versionCli: string;
async onServerStarted(diagnosticInfo: IDiagnosticInfo): Promise<unknown[]> { constructor(private telemetry: Telemetry, versionCli: string) {
this.versionCli = versionCli;
}
async onServerStarted(
diagnosticInfo: IDiagnosticInfo,
earliestWorkflowCreatedAt?: Date,
): Promise<unknown[]> {
const info = { const info = {
version_cli: diagnosticInfo.versionCli, version_cli: diagnosticInfo.versionCli,
db_type: diagnosticInfo.databaseType, db_type: diagnosticInfo.databaseType,
@ -25,7 +32,10 @@ export class InternalHooksClass implements IInternalHooksClass {
return Promise.all([ return Promise.all([
this.telemetry.identify(info), this.telemetry.identify(info),
this.telemetry.track('Instance started', info), this.telemetry.track('Instance started', {
...info,
earliest_workflow_created: earliestWorkflowCreatedAt,
}),
]); ]);
} }
@ -35,13 +45,17 @@ export class InternalHooksClass implements IInternalHooksClass {
coding_skill: answers.codingSkill, coding_skill: answers.codingSkill,
work_area: answers.workArea, work_area: answers.workArea,
other_work_area: answers.otherWorkArea, other_work_area: answers.otherWorkArea,
company_industry: answers.companyIndustry,
other_company_industry: answers.otherCompanyIndustry,
}); });
} }
async onWorkflowCreated(workflow: IWorkflowBase): Promise<void> { async onWorkflowCreated(workflow: IWorkflowBase): Promise<void> {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow);
return this.telemetry.track('User created workflow', { return this.telemetry.track('User created workflow', {
workflow_id: workflow.id, workflow_id: workflow.id,
node_graph: TelemetryHelpers.generateNodesGraph(workflow).nodeGraph, node_graph: nodeGraph,
node_graph_string: JSON.stringify(nodeGraph),
}); });
} }
@ -52,9 +66,13 @@ export class InternalHooksClass implements IInternalHooksClass {
} }
async onWorkflowSaved(workflow: IWorkflowBase): Promise<void> { async onWorkflowSaved(workflow: IWorkflowBase): Promise<void> {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow);
return this.telemetry.track('User saved workflow', { return this.telemetry.track('User saved workflow', {
workflow_id: workflow.id, workflow_id: workflow.id,
node_graph: TelemetryHelpers.generateNodesGraph(workflow).nodeGraph, node_graph: nodeGraph,
node_graph_string: JSON.stringify(nodeGraph),
version_cli: this.versionCli,
}); });
} }
@ -62,6 +80,7 @@ export class InternalHooksClass implements IInternalHooksClass {
const properties: IDataObject = { const properties: IDataObject = {
workflow_id: workflow.id, workflow_id: workflow.id,
is_manual: false, is_manual: false,
version_cli: this.versionCli,
}; };
if (runData !== undefined) { if (runData !== undefined) {
@ -92,6 +111,8 @@ export class InternalHooksClass implements IInternalHooksClass {
if (properties.is_manual) { if (properties.is_manual) {
const nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow); const nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow);
properties.node_graph = nodeGraphResult.nodeGraph; properties.node_graph = nodeGraphResult.nodeGraph;
properties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph);
if (errorNodeName) { if (errorNodeName) {
properties.error_node_id = nodeGraphResult.nameIndices[errorNodeName]; properties.error_node_id = nodeGraphResult.nameIndices[errorNodeName];
} }

View file

@ -13,9 +13,12 @@ export class InternalHooksManager {
throw new Error('InternalHooks not initialized'); throw new Error('InternalHooks not initialized');
} }
static init(instanceId: string): InternalHooksClass { static init(instanceId: string, versionCli: string): InternalHooksClass {
if (!this.internalHooksInstance) { if (!this.internalHooksInstance) {
this.internalHooksInstance = new InternalHooksClass(new Telemetry(instanceId)); this.internalHooksInstance = new InternalHooksClass(
new Telemetry(instanceId, versionCli),
versionCli,
);
} }
return this.internalHooksInstance; return this.internalHooksInstance;

View file

@ -5,6 +5,7 @@
import { import {
INodeType, INodeType,
INodeTypeData, INodeTypeData,
INodeTypeDescription,
INodeTypes, INodeTypes,
INodeVersionedType, INodeVersionedType,
NodeHelpers, NodeHelpers,
@ -18,7 +19,7 @@ class NodeTypesClass implements INodeTypes {
// polling nodes the polling times // polling nodes the polling times
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
for (const nodeTypeData of Object.values(nodeTypes)) { for (const nodeTypeData of Object.values(nodeTypes)) {
const nodeType = NodeHelpers.getVersionedTypeNode(nodeTypeData.type); const nodeType = NodeHelpers.getVersionedNodeType(nodeTypeData.type);
const applyParameters = NodeHelpers.getSpecialNodeParameters(nodeType); const applyParameters = NodeHelpers.getSpecialNodeParameters(nodeType);
if (applyParameters.length) { if (applyParameters.length) {
@ -39,8 +40,29 @@ class NodeTypesClass implements INodeTypes {
return this.nodeTypes[nodeType].type; return this.nodeTypes[nodeType].type;
} }
/**
* Variant of `getByNameAndVersion` that includes the node's source path, used to locate a node's translations.
*/
getWithSourcePath(
nodeTypeName: string,
version: number,
): { description: INodeTypeDescription } & { sourcePath: string } {
const nodeType = this.nodeTypes[nodeTypeName];
if (!nodeType) {
throw new Error(`Unknown node type: ${nodeTypeName}`);
}
const { description } = NodeHelpers.getVersionedNodeType(nodeType.type, version);
return { description: { ...description }, sourcePath: nodeType.sourcePath };
}
getByNameAndVersion(nodeType: string, version?: number): INodeType { getByNameAndVersion(nodeType: string, version?: number): INodeType {
return NodeHelpers.getVersionedTypeNode(this.nodeTypes[nodeType].type, version); if (this.nodeTypes[nodeType] === undefined) {
throw new Error(`The node-type "${nodeType}" is not known!`);
}
return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType].type, version);
} }
} }

View file

@ -1,12 +1,21 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import * as Bull from 'bull'; import * as Bull from 'bull';
import * as config from '../config'; import * as config from '../config';
// eslint-disable-next-line import/no-cycle // eslint-disable-next-line import/no-cycle
import { IBullJobData } from './Interfaces'; import { IBullJobData, IBullWebhookResponse } from './Interfaces';
// eslint-disable-next-line import/no-cycle
import * as ActiveExecutions from './ActiveExecutions';
// eslint-disable-next-line import/no-cycle
import * as WebhookHelpers from './WebhookHelpers';
export class Queue { export class Queue {
private activeExecutions: ActiveExecutions.ActiveExecutions;
private jobQueue: Bull.Queue; private jobQueue: Bull.Queue;
constructor() { constructor() {
this.activeExecutions = ActiveExecutions.getInstance();
const prefix = config.get('queue.bull.prefix') as string; const prefix = config.get('queue.bull.prefix') as string;
const redisOptions = config.get('queue.bull.redis') as object; const redisOptions = config.get('queue.bull.redis') as object;
// Disabling ready check is necessary as it allows worker to // Disabling ready check is necessary as it allows worker to
@ -16,6 +25,14 @@ export class Queue {
// More here: https://github.com/OptimalBits/bull/issues/890 // More here: https://github.com/OptimalBits/bull/issues/890
// @ts-ignore // @ts-ignore
this.jobQueue = new Bull('jobs', { prefix, redis: redisOptions, enableReadyCheck: false }); this.jobQueue = new Bull('jobs', { prefix, redis: redisOptions, enableReadyCheck: false });
this.jobQueue.on('global:progress', (jobId, progress: IBullWebhookResponse) => {
this.activeExecutions.resolveResponsePromise(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
progress.executionId,
WebhookHelpers.decodeWebhookResponse(progress.response),
);
});
} }
async add(jobData: IBullJobData, jobOptions: object): Promise<Bull.Job> { async add(jobData: IBullJobData, jobOptions: object): Promise<Bull.Job> {

View file

@ -72,11 +72,16 @@ export function sendSuccessResponse(
data: any, data: any,
raw?: boolean, raw?: boolean,
responseCode?: number, responseCode?: number,
responseHeader?: object,
) { ) {
if (responseCode !== undefined) { if (responseCode !== undefined) {
res.status(responseCode); res.status(responseCode);
} }
if (responseHeader) {
res.header(responseHeader);
}
if (raw === true) { if (raw === true) {
if (typeof data === 'string') { if (typeof data === 'string') {
res.send(data); res.send(data);

View file

@ -24,8 +24,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-restricted-syntax */ /* eslint-disable no-restricted-syntax */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable import/no-dynamic-require */
/* eslint-disable no-await-in-loop */
import * as express from 'express'; import * as express from 'express';
import { readFileSync } from 'fs'; import { readFileSync, existsSync } from 'fs';
import { readFile } from 'fs/promises';
import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from 'path'; import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from 'path';
import { FindManyOptions, getConnectionManager, In, IsNull, LessThanOrEqual, Not } from 'typeorm'; import { FindManyOptions, getConnectionManager, In, IsNull, LessThanOrEqual, Not } from 'typeorm';
import * as bodyParser from 'body-parser'; import * as bodyParser from 'body-parser';
@ -144,6 +148,7 @@ import { InternalHooksManager } from './InternalHooksManager';
import { TagEntity } from './databases/entities/TagEntity'; import { TagEntity } from './databases/entities/TagEntity';
import { WorkflowEntity } from './databases/entities/WorkflowEntity'; import { WorkflowEntity } from './databases/entities/WorkflowEntity';
import { NameRequest } from './WorkflowHelpers'; import { NameRequest } from './WorkflowHelpers';
import { getNodeTranslationPath } from './TranslationHelpers';
require('body-parser-xml')(bodyParser); require('body-parser-xml')(bodyParser);
@ -280,6 +285,7 @@ class App {
personalizationSurvey: { personalizationSurvey: {
shouldShow: false, shouldShow: false,
}, },
defaultLocale: config.get('defaultLocale'),
}; };
} }
@ -679,6 +685,7 @@ class App {
// @ts-ignore // @ts-ignore
savedWorkflow.id = savedWorkflow.id.toString(); savedWorkflow.id = savedWorkflow.id.toString();
await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]);
void InternalHooksManager.getInstance().onWorkflowCreated(newWorkflow as IWorkflowBase); void InternalHooksManager.getInstance().onWorkflowCreated(newWorkflow as IWorkflowBase);
return savedWorkflow; return savedWorkflow;
}, },
@ -1150,13 +1157,13 @@ class App {
if (onlyLatest) { if (onlyLatest) {
allNodes.forEach((nodeData) => { allNodes.forEach((nodeData) => {
const nodeType = NodeHelpers.getVersionedTypeNode(nodeData); const nodeType = NodeHelpers.getVersionedNodeType(nodeData);
const nodeInfo: INodeTypeDescription = getNodeDescription(nodeType); const nodeInfo: INodeTypeDescription = getNodeDescription(nodeType);
returnData.push(nodeInfo); returnData.push(nodeInfo);
}); });
} else { } else {
allNodes.forEach((nodeData) => { allNodes.forEach((nodeData) => {
const allNodeTypes = NodeHelpers.getVersionedTypeNodeAll(nodeData); const allNodeTypes = NodeHelpers.getVersionedNodeTypeAll(nodeData);
allNodeTypes.forEach((element) => { allNodeTypes.forEach((element) => {
const nodeInfo: INodeTypeDescription = getNodeDescription(element); const nodeInfo: INodeTypeDescription = getNodeDescription(element);
returnData.push(nodeInfo); returnData.push(nodeInfo);
@ -1175,17 +1182,60 @@ class App {
ResponseHelper.send( ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<INodeTypeDescription[]> => { async (req: express.Request, res: express.Response): Promise<INodeTypeDescription[]> => {
const nodeInfos = _.get(req, 'body.nodeInfos', []) as INodeTypeNameVersion[]; const nodeInfos = _.get(req, 'body.nodeInfos', []) as INodeTypeNameVersion[];
const nodeTypes = NodeTypes();
const returnData: INodeTypeDescription[] = []; const { defaultLocale } = this.frontendSettings;
nodeInfos.forEach((nodeInfo) => {
const nodeType = nodeTypes.getByNameAndVersion(nodeInfo.name, nodeInfo.version); if (defaultLocale === 'en') {
if (nodeType?.description) { return nodeInfos.reduce<INodeTypeDescription[]>((acc, { name, version }) => {
returnData.push(nodeType.description); const { description } = NodeTypes().getByNameAndVersion(name, version);
acc.push(description);
return acc;
}, []);
} }
});
return returnData; async function populateTranslation(
name: string,
version: number,
nodeTypes: INodeTypeDescription[],
) {
const { description, sourcePath } = NodeTypes().getWithSourcePath(name, version);
const translationPath = await getNodeTranslationPath(sourcePath, defaultLocale);
try {
const translation = await readFile(translationPath, 'utf8');
description.translation = JSON.parse(translation);
} catch (error) {
// ignore - no translation at expected translation path
}
nodeTypes.push(description);
}
const nodeTypes: INodeTypeDescription[] = [];
const promises = nodeInfos.map(async ({ name, version }) =>
populateTranslation(name, version, nodeTypes),
);
await Promise.all(promises);
return nodeTypes;
},
),
);
// Returns node information based on node names and versions
this.app.get(
`/${this.restEndpoint}/node-translation-headers`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<object | void> => {
const packagesPath = pathJoin(__dirname, '..', '..', '..');
const headersPath = pathJoin(packagesPath, 'nodes-base', 'dist', 'nodes', 'headers');
try {
return require(headersPath);
} catch (error) {
res.status(500).send('Failed to find headers file');
}
}, },
), ),
); );
@ -1578,12 +1628,13 @@ class App {
async (req: express.Request, res: express.Response): Promise<ICredentialsResponse[]> => { async (req: express.Request, res: express.Response): Promise<ICredentialsResponse[]> => {
const findQuery = {} as FindManyOptions; const findQuery = {} as FindManyOptions;
if (req.query.filter) { if (req.query.filter) {
findQuery.where = JSON.parse(req.query.filter as string); findQuery.where = JSON.parse(req.query.filter as string) as IDataObject;
if ((findQuery.where! as IDataObject).id !== undefined) { if (findQuery.where.id !== undefined) {
// No idea if multiple where parameters make db search // No idea if multiple where parameters make db search
// slower but to be sure that that is not the case we // slower but to be sure that that is not the case we
// remove all unnecessary fields in case the id is defined. // remove all unnecessary fields in case the id is defined.
findQuery.where = { id: (findQuery.where! as IDataObject).id }; // @ts-ignore
findQuery.where = { id: findQuery.where.id };
} }
} }
@ -2668,7 +2719,13 @@ class App {
return; return;
} }
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
}, },
); );
@ -2719,7 +2776,13 @@ class App {
return; return;
} }
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
}, },
); );
@ -2745,7 +2808,13 @@ class App {
return; return;
} }
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
}, },
); );
@ -2840,6 +2909,12 @@ export async function start(): Promise<void> {
console.log(`n8n ready on ${ADDRESS}, port ${PORT}`); console.log(`n8n ready on ${ADDRESS}, port ${PORT}`);
console.log(`Version: ${versions.cli}`); console.log(`Version: ${versions.cli}`);
const defaultLocale = config.get('defaultLocale');
if (defaultLocale !== 'en') {
console.log(`Locale: ${defaultLocale}`);
}
await app.externalHooks.run('n8n.ready', [app]); await app.externalHooks.run('n8n.ready', [app]);
const cpus = os.cpus(); const cpus = os.cpus();
const diagnosticInfo: IDiagnosticInfo = { const diagnosticInfo: IDiagnosticInfo = {
@ -2877,7 +2952,23 @@ export async function start(): Promise<void> {
deploymentType: config.get('deployment.type'), deploymentType: config.get('deployment.type'),
}; };
void InternalHooksManager.getInstance().onServerStarted(diagnosticInfo); void Db.collections
.Workflow!.findOne({
select: ['createdAt'],
order: { createdAt: 'ASC' },
})
.then(async (workflow) =>
InternalHooksManager.getInstance().onServerStarted(diagnosticInfo, workflow?.createdAt),
);
});
server.on('error', (error: Error & { code: string }) => {
if (error.code === 'EADDRINUSE') {
console.log(
`n8n's port ${PORT} is already in use. Do you have another instance of n8n running already?`,
);
process.exit(1);
}
}); });
} }

View file

@ -0,0 +1,39 @@
import { join, dirname } from 'path';
import { readdir } from 'fs/promises';
import { Dirent } from 'fs';
const ALLOWED_VERSIONED_DIRNAME_LENGTH = [2, 3]; // v1, v10
function isVersionedDirname(dirent: Dirent) {
if (!dirent.isDirectory()) return false;
return (
ALLOWED_VERSIONED_DIRNAME_LENGTH.includes(dirent.name.length) &&
dirent.name.toLowerCase().startsWith('v')
);
}
async function getMaxVersion(from: string) {
const entries = await readdir(from, { withFileTypes: true });
const dirnames = entries.reduce<string[]>((acc, cur) => {
if (isVersionedDirname(cur)) acc.push(cur.name);
return acc;
}, []);
if (!dirnames.length) return null;
return Math.max(...dirnames.map((d) => parseInt(d.charAt(1), 10)));
}
export async function getNodeTranslationPath(
nodeSourcePath: string,
language: string,
): Promise<string> {
const nodeDir = dirname(nodeSourcePath);
const maxVersion = await getMaxVersion(nodeDir);
return maxVersion
? join(nodeDir, `v${maxVersion}`, 'translations', `${language}.json`)
: join(nodeDir, 'translations', `${language}.json`);
}

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
/* eslint-disable @typescript-eslint/prefer-optional-chain */ /* eslint-disable @typescript-eslint/prefer-optional-chain */
/* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable @typescript-eslint/no-shadow */
@ -18,9 +19,13 @@ import { get } from 'lodash';
import { BINARY_ENCODING, NodeExecuteFunctions } from 'n8n-core'; import { BINARY_ENCODING, NodeExecuteFunctions } from 'n8n-core';
import { import {
createDeferredPromise,
IBinaryKeyData, IBinaryKeyData,
IDataObject, IDataObject,
IDeferredPromise,
IExecuteData, IExecuteData,
IExecuteResponsePromiseData,
IN8nHttpFullResponse,
INode, INode,
IRunExecutionData, IRunExecutionData,
IWebhookData, IWebhookData,
@ -34,20 +39,20 @@ import {
} from 'n8n-workflow'; } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle // eslint-disable-next-line import/no-cycle
import { import {
ActiveExecutions,
GenericHelpers, GenericHelpers,
IExecutionDb, IExecutionDb,
IResponseCallbackData, IResponseCallbackData,
IWorkflowDb, IWorkflowDb,
IWorkflowExecutionDataProcess, IWorkflowExecutionDataProcess,
ResponseHelper, ResponseHelper,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
WorkflowCredentials,
WorkflowExecuteAdditionalData, WorkflowExecuteAdditionalData,
WorkflowHelpers, WorkflowHelpers,
WorkflowRunner, WorkflowRunner,
} from '.'; } from '.';
// eslint-disable-next-line import/no-cycle
import * as ActiveExecutions from './ActiveExecutions';
const activeExecutions = ActiveExecutions.getInstance(); const activeExecutions = ActiveExecutions.getInstance();
/** /**
@ -91,6 +96,35 @@ export function getWorkflowWebhooks(
return returnData; return returnData;
} }
export function decodeWebhookResponse(
response: IExecuteResponsePromiseData,
): IExecuteResponsePromiseData {
if (
typeof response === 'object' &&
typeof response.body === 'object' &&
(response.body as IDataObject)['__@N8nEncodedBuffer@__']
) {
response.body = Buffer.from(
(response.body as IDataObject)['__@N8nEncodedBuffer@__'] as string,
BINARY_ENCODING,
);
}
return response;
}
export function encodeWebhookResponse(
response: IExecuteResponsePromiseData,
): IExecuteResponsePromiseData {
if (typeof response === 'object' && Buffer.isBuffer(response.body)) {
response.body = {
'__@N8nEncodedBuffer@__': response.body.toString(BINARY_ENCODING),
};
}
return response;
}
/** /**
* Returns all the webhooks which should be created for the give workflow * Returns all the webhooks which should be created for the give workflow
* *
@ -169,7 +203,7 @@ export async function executeWebhook(
200, 200,
) as number; ) as number;
if (!['onReceived', 'lastNode'].includes(responseMode as string)) { if (!['onReceived', 'lastNode', 'responseNode'].includes(responseMode as string)) {
// If the mode is not known we error. Is probably best like that instead of using // If the mode is not known we error. Is probably best like that instead of using
// the default that people know as early as possible (probably already testing phase) // the default that people know as early as possible (probably already testing phase)
// that something does not resolve properly. // that something does not resolve properly.
@ -356,9 +390,52 @@ export async function executeWebhook(
workflowData, workflowData,
}; };
let responsePromise: IDeferredPromise<IN8nHttpFullResponse> | undefined;
if (responseMode === 'responseNode') {
responsePromise = await createDeferredPromise<IN8nHttpFullResponse>();
responsePromise
.promise()
.then((response: IN8nHttpFullResponse) => {
if (didSendResponse) {
return;
}
if (Buffer.isBuffer(response.body)) {
res.header(response.headers);
res.end(response.body);
responseCallback(null, {
noWebhookResponse: true,
});
} else {
// TODO: This probably needs some more changes depending on the options on the
// Webhook Response node
responseCallback(null, {
data: response.body as IDataObject,
headers: response.headers,
responseCode: response.statusCode,
});
}
didSendResponse = true;
})
.catch(async (error) => {
Logger.error(
`Error with Webhook-Response for execution "${executionId}": "${error.message}"`,
{ executionId, workflowId: workflow.id },
);
});
}
// Start now to run the workflow // Start now to run the workflow
const workflowRunner = new WorkflowRunner(); const workflowRunner = new WorkflowRunner();
executionId = await workflowRunner.run(runData, true, !didSendResponse, executionId); executionId = await workflowRunner.run(
runData,
true,
!didSendResponse,
executionId,
responsePromise,
);
Logger.verbose( Logger.verbose(
`Started execution of workflow "${workflow.name}" from webhook with execution ID ${executionId}`, `Started execution of workflow "${workflow.name}" from webhook with execution ID ${executionId}`,
@ -398,6 +475,20 @@ export async function executeWebhook(
return data; return data;
} }
if (responseMode === 'responseNode') {
if (!didSendResponse) {
// Return an error if no Webhook-Response node did send any data
responseCallback(null, {
data: {
message: 'Workflow executed sucessfully.',
},
responseCode,
});
didSendResponse = true;
}
return undefined;
}
if (returnData === undefined) { if (returnData === undefined) {
if (!didSendResponse) { if (!didSendResponse) {
responseCallback(null, { responseCallback(null, {

View file

@ -64,7 +64,13 @@ export function registerProductionWebhooks() {
return; return;
} }
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
}, },
); );
@ -115,7 +121,13 @@ export function registerProductionWebhooks() {
return; return;
} }
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
}, },
); );
@ -141,7 +153,13 @@ export function registerProductionWebhooks() {
return; return;
} }
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
}, },
); );
@ -173,7 +191,13 @@ export function registerProductionWebhooks() {
return; return;
} }
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
}, },
); );
@ -199,7 +223,13 @@ export function registerProductionWebhooks() {
return; return;
} }
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
}, },
); );
@ -225,7 +255,13 @@ export function registerProductionWebhooks() {
return; return;
} }
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode); ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
}, },
); );
} }

View file

@ -509,7 +509,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
this.workflowData, this.workflowData,
fullRunData, fullRunData,
this.mode, this.mode,
undefined, this.executionId,
this.retryOf, this.retryOf,
); );
} }
@ -585,7 +585,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
this.workflowData, this.workflowData,
fullRunData, fullRunData,
this.mode, this.mode,
undefined, this.executionId,
this.retryOf, this.retryOf,
); );
} }
@ -635,7 +635,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
this.workflowData, this.workflowData,
fullRunData, fullRunData,
this.mode, this.mode,
undefined, this.executionId,
this.retryOf, this.retryOf,
); );
} }
@ -676,7 +676,13 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
}); });
} }
} catch (error) { } catch (error) {
executeErrorWorkflow(this.workflowData, fullRunData, this.mode, undefined, this.retryOf); executeErrorWorkflow(
this.workflowData,
fullRunData,
this.mode,
this.executionId,
this.retryOf,
);
} }
}, },
], ],
@ -924,7 +930,7 @@ export async function executeWorkflow(
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function sendMessageToUI(source: string, message: any) { export function sendMessageToUI(source: string, messages: any[]) {
if (this.sessionId === undefined) { if (this.sessionId === undefined) {
return; return;
} }
@ -936,7 +942,7 @@ export function sendMessageToUI(source: string, message: any) {
'sendConsoleMessage', 'sendConsoleMessage',
{ {
source: `Node: "${source}"`, source: `Node: "${source}"`,
message, messages,
}, },
this.sessionId, this.sessionId,
); );

View file

@ -15,6 +15,8 @@ import { IProcessMessage, WorkflowExecute } from 'n8n-core';
import { import {
ExecutionError, ExecutionError,
IDeferredPromise,
IExecuteResponsePromiseData,
IRun, IRun,
LoggerProxy as Logger, LoggerProxy as Logger,
Workflow, Workflow,
@ -41,9 +43,7 @@ import {
IBullJobResponse, IBullJobResponse,
ICredentialsOverwrite, ICredentialsOverwrite,
ICredentialsTypeData, ICredentialsTypeData,
IExecutionDb,
IExecutionFlattedDb, IExecutionFlattedDb,
IExecutionResponse,
IProcessMessageDataHook, IProcessMessageDataHook,
ITransferNodeTypes, ITransferNodeTypes,
IWorkflowExecutionDataProcess, IWorkflowExecutionDataProcess,
@ -51,6 +51,7 @@ import {
NodeTypes, NodeTypes,
Push, Push,
ResponseHelper, ResponseHelper,
WebhookHelpers,
WorkflowExecuteAdditionalData, WorkflowExecuteAdditionalData,
WorkflowHelpers, WorkflowHelpers,
} from '.'; } from '.';
@ -146,6 +147,7 @@ export class WorkflowRunner {
loadStaticData?: boolean, loadStaticData?: boolean,
realtime?: boolean, realtime?: boolean,
executionId?: string, executionId?: string,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): Promise<string> { ): Promise<string> {
const executionsProcess = config.get('executions.process') as string; const executionsProcess = config.get('executions.process') as string;
const executionsMode = config.get('executions.mode') as string; const executionsMode = config.get('executions.mode') as string;
@ -153,11 +155,17 @@ export class WorkflowRunner {
if (executionsMode === 'queue' && data.executionMode !== 'manual') { if (executionsMode === 'queue' && data.executionMode !== 'manual') {
// Do not run "manual" executions in bull because sending events to the // Do not run "manual" executions in bull because sending events to the
// frontend would not be possible // frontend would not be possible
executionId = await this.runBull(data, loadStaticData, realtime, executionId); executionId = await this.runBull(
data,
loadStaticData,
realtime,
executionId,
responsePromise,
);
} else if (executionsProcess === 'main') { } else if (executionsProcess === 'main') {
executionId = await this.runMainProcess(data, loadStaticData, executionId); executionId = await this.runMainProcess(data, loadStaticData, executionId, responsePromise);
} else { } else {
executionId = await this.runSubprocess(data, loadStaticData, executionId); executionId = await this.runSubprocess(data, loadStaticData, executionId, responsePromise);
} }
const postExecutePromise = this.activeExecutions.getPostExecutePromise(executionId); const postExecutePromise = this.activeExecutions.getPostExecutePromise(executionId);
@ -200,6 +208,7 @@ export class WorkflowRunner {
data: IWorkflowExecutionDataProcess, data: IWorkflowExecutionDataProcess,
loadStaticData?: boolean, loadStaticData?: boolean,
restartExecutionId?: string, restartExecutionId?: string,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): Promise<string> { ): Promise<string> {
if (loadStaticData === true && data.workflowData.id) { if (loadStaticData === true && data.workflowData.id) {
data.workflowData.staticData = await WorkflowHelpers.getStaticDataById( data.workflowData.staticData = await WorkflowHelpers.getStaticDataById(
@ -256,6 +265,15 @@ export class WorkflowRunner {
executionId, executionId,
true, true,
); );
additionalData.hooks.hookFunctions.sendResponse = [
async (response: IExecuteResponsePromiseData): Promise<void> => {
if (responsePromise) {
responsePromise.resolve(response);
}
},
];
additionalData.sendMessageToUI = WorkflowExecuteAdditionalData.sendMessageToUI.bind({ additionalData.sendMessageToUI = WorkflowExecuteAdditionalData.sendMessageToUI.bind({
sessionId: data.sessionId, sessionId: data.sessionId,
}); });
@ -341,11 +359,15 @@ export class WorkflowRunner {
loadStaticData?: boolean, loadStaticData?: boolean,
realtime?: boolean, realtime?: boolean,
restartExecutionId?: string, restartExecutionId?: string,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): Promise<string> { ): Promise<string> {
// TODO: If "loadStaticData" is set to true it has to load data new on worker // TODO: If "loadStaticData" is set to true it has to load data new on worker
// Register the active execution // Register the active execution
const executionId = await this.activeExecutions.add(data, undefined, restartExecutionId); const executionId = await this.activeExecutions.add(data, undefined, restartExecutionId);
if (responsePromise) {
this.activeExecutions.attachResponsePromise(executionId, responsePromise);
}
const jobData: IBullJobData = { const jobData: IBullJobData = {
executionId, executionId,
@ -545,6 +567,7 @@ export class WorkflowRunner {
data: IWorkflowExecutionDataProcess, data: IWorkflowExecutionDataProcess,
loadStaticData?: boolean, loadStaticData?: boolean,
restartExecutionId?: string, restartExecutionId?: string,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): Promise<string> { ): Promise<string> {
let startedAt = new Date(); let startedAt = new Date();
const subprocess = fork(pathJoin(__dirname, 'WorkflowRunnerProcess.js')); const subprocess = fork(pathJoin(__dirname, 'WorkflowRunnerProcess.js'));
@ -653,6 +676,10 @@ export class WorkflowRunner {
} else if (message.type === 'end') { } else if (message.type === 'end') {
clearTimeout(executionTimeout); clearTimeout(executionTimeout);
this.activeExecutions.remove(executionId, message.data.runData); this.activeExecutions.remove(executionId, message.data.runData);
} else if (message.type === 'sendResponse') {
if (responsePromise) {
responsePromise.resolve(WebhookHelpers.decodeWebhookResponse(message.data.response));
}
} else if (message.type === 'sendMessageToUI') { } else if (message.type === 'sendMessageToUI') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call // eslint-disable-next-line @typescript-eslint/no-unsafe-call
WorkflowExecuteAdditionalData.sendMessageToUI.bind({ sessionId: data.sessionId })( WorkflowExecuteAdditionalData.sendMessageToUI.bind({ sessionId: data.sessionId })(

View file

@ -10,6 +10,7 @@ import { IProcessMessage, UserSettings, WorkflowExecute } from 'n8n-core';
import { import {
ExecutionError, ExecutionError,
IDataObject, IDataObject,
IExecuteResponsePromiseData,
IExecuteWorkflowInfo, IExecuteWorkflowInfo,
ILogger, ILogger,
INodeExecutionData, INodeExecutionData,
@ -30,9 +31,11 @@ import {
CredentialTypes, CredentialTypes,
Db, Db,
ExternalHooks, ExternalHooks,
GenericHelpers,
IWorkflowExecuteProcess, IWorkflowExecuteProcess,
IWorkflowExecutionDataProcessWithExecution, IWorkflowExecutionDataProcessWithExecution,
NodeTypes, NodeTypes,
WebhookHelpers,
WorkflowExecuteAdditionalData, WorkflowExecuteAdditionalData,
WorkflowHelpers, WorkflowHelpers,
} from '.'; } from '.';
@ -135,7 +138,8 @@ export class WorkflowRunnerProcess {
await externalHooks.init(); await externalHooks.init();
const instanceId = (await UserSettings.prepareUserSettings()).instanceId ?? ''; const instanceId = (await UserSettings.prepareUserSettings()).instanceId ?? '';
InternalHooksManager.init(instanceId); const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli);
// Credentials should now be loaded from database. // Credentials should now be loaded from database.
// We check if any node uses credentials. If it does, then // We check if any node uses credentials. If it does, then
@ -200,6 +204,15 @@ export class WorkflowRunnerProcess {
workflowTimeout <= 0 ? undefined : Date.now() + workflowTimeout * 1000, workflowTimeout <= 0 ? undefined : Date.now() + workflowTimeout * 1000,
); );
additionalData.hooks = this.getProcessForwardHooks(); additionalData.hooks = this.getProcessForwardHooks();
additionalData.hooks.hookFunctions.sendResponse = [
async (response: IExecuteResponsePromiseData): Promise<void> => {
await sendToParentProcess('sendResponse', {
response: WebhookHelpers.encodeWebhookResponse(response),
});
},
];
additionalData.executionId = inputData.executionId; additionalData.executionId = inputData.executionId;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any

View file

@ -0,0 +1,39 @@
import { QueryRunner } from 'typeorm';
export class MigrationHelpers {
queryRunner: QueryRunner;
constructor(queryRunner: QueryRunner) {
this.queryRunner = queryRunner;
}
// runs an operation sequential on chunks of a query that returns a potentially large Array.
/* eslint-disable no-await-in-loop */
async runChunked(
query: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
operation: (results: any[]) => Promise<void>,
limit = 100,
): Promise<void> {
let offset = 0;
let chunkedQuery: string;
let chunkedQueryResults: unknown[];
do {
chunkedQuery = this.chunkQuery(query, limit, offset);
chunkedQueryResults = (await this.queryRunner.query(chunkedQuery)) as unknown[];
// pass a copy to prevent errors from mutation
await operation([...chunkedQueryResults]);
offset += limit;
} while (chunkedQueryResults.length === limit);
}
/* eslint-enable no-await-in-loop */
private chunkQuery(query: string, limit: number, offset = 0): string {
return `
${query}
LIMIT ${limit}
OFFSET ${offset}
`;
}
}

View file

@ -1,5 +1,6 @@
import { MigrationInterface, QueryRunner } from 'typeorm'; import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config'); import config = require('../../../../config');
import { MigrationHelpers } from '../../MigrationHelpers';
// replacing the credentials in workflows and execution // replacing the credentials in workflows and execution
// `nodeType: name` changes to `nodeType: { id, name }` // `nodeType: name` changes to `nodeType: { id, name }`
@ -8,18 +9,22 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
name = 'UpdateWorkflowCredentials1630451444017'; name = 'UpdateWorkflowCredentials1630451444017';
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
console.log('Start migration', this.name);
console.time(this.name);
const tablePrefix = config.get('database.tablePrefix'); const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(` const credentialsEntities = await queryRunner.query(`
SELECT id, name, type SELECT id, name, type
FROM ${tablePrefix}credentials_entity FROM ${tablePrefix}credentials_entity
`); `);
const workflows = await queryRunner.query(` const workflowsQuery = `
SELECT id, nodes SELECT id, nodes
FROM ${tablePrefix}workflow_entity FROM ${tablePrefix}workflow_entity
`); `;
// @ts-ignore // @ts-ignore
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => { workflows.forEach(async (workflow) => {
const nodes = workflow.nodes; const nodes = workflow.nodes;
let credentialsUpdated = false; let credentialsUpdated = false;
@ -29,7 +34,6 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
const allNodeCredentials = Object.entries(node.credentials); const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) { for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') { if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find( const matchingCredentials = credentialsEntities.find(
// @ts-ignore // @ts-ignore
(credentials) => credentials.name === name && credentials.type === type, (credentials) => credentials.name === name && credentials.type === type,
@ -41,7 +45,8 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
} }
}); });
if (credentialsUpdated) { if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
` `
UPDATE ${tablePrefix}workflow_entity UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes SET nodes = :nodes
@ -51,25 +56,19 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
{}, {},
); );
await queryRunner.query(updateQuery, updateParams); queryRunner.query(updateQuery, updateParams);
} }
}); });
});
const waitingExecutions = await queryRunner.query(` const waitingExecutionsQuery = `
SELECT id, workflowData SELECT id, workflowData
FROM ${tablePrefix}execution_entity FROM ${tablePrefix}execution_entity
WHERE waitTill IS NOT NULL AND finished = 0 WHERE waitTill IS NOT NULL AND finished = 0
`); `;
// @ts-ignore
const retryableExecutions = await queryRunner.query(` await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
SELECT id, workflowData waitingExecutions.forEach(async (execution) => {
FROM ${tablePrefix}execution_entity
WHERE waitTill IS NULL AND finished = 0 AND mode != 'retry'
ORDER BY startedAt DESC
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
const data = execution.workflowData; const data = execution.workflowData;
let credentialsUpdated = false; let credentialsUpdated = false;
// @ts-ignore // @ts-ignore
@ -78,7 +77,50 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
const allNodeCredentials = Object.entries(node.credentials); const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) { for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') { if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore // @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(`
SELECT id, workflowData
FROM ${tablePrefix}execution_entity
WHERE waitTill IS NULL AND finished = 0 AND mode != 'retry'
ORDER BY startedAt DESC
LIMIT 200
`);
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find( const matchingCredentials = credentialsEntities.find(
// @ts-ignore // @ts-ignore
(credentials) => credentials.name === name && credentials.type === type, (credentials) => credentials.name === name && credentials.type === type,
@ -100,29 +142,80 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
{}, {},
); );
await queryRunner.query(updateQuery, updateParams); queryRunner.query(updateQuery, updateParams);
} }
}); });
console.timeEnd(this.name);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix'); const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(` const credentialsEntities = await queryRunner.query(`
SELECT id, name, type SELECT id, name, type
FROM ${tablePrefix}credentials_entity FROM ${tablePrefix}credentials_entity
`); `);
const workflows = await queryRunner.query(` const workflowsQuery = `
SELECT id, nodes SELECT id, nodes
FROM ${tablePrefix}workflow_entity FROM ${tablePrefix}workflow_entity
`); `;
// @ts-ignore // @ts-ignore
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => { workflows.forEach(async (workflow) => {
const nodes = workflow.nodes; const nodes = workflow.nodes;
let credentialsUpdated = false; let credentialsUpdated = false;
// @ts-ignore // @ts-ignore
nodes.forEach((node) => { nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
});
const waitingExecutionsQuery = `
SELECT id, workflowData
FROM ${tablePrefix}execution_entity
WHERE waitTill IS NOT NULL AND finished = 0
`;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) { if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials); const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) { for (const [type, creds] of allNodeCredentials) {
@ -144,25 +237,21 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
} }
}); });
if (credentialsUpdated) { if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
` `
UPDATE ${tablePrefix}workflow_entity UPDATE ${tablePrefix}execution_entity
SET nodes = :nodes SET workflowData = :data
WHERE id = '${workflow.id}' WHERE id = '${execution.id}'
`, `,
{ nodes: JSON.stringify(nodes) }, { data: JSON.stringify(data) },
{}, {},
); );
await queryRunner.query(updateQuery, updateParams); queryRunner.query(updateQuery, updateParams);
} }
}); });
});
const waitingExecutions = await queryRunner.query(`
SELECT id, workflowData
FROM ${tablePrefix}execution_entity
WHERE waitTill IS NOT NULL AND finished = 0
`);
const retryableExecutions = await queryRunner.query(` const retryableExecutions = await queryRunner.query(`
SELECT id, workflowData SELECT id, workflowData
@ -171,8 +260,8 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
ORDER BY startedAt DESC ORDER BY startedAt DESC
LIMIT 200 LIMIT 200
`); `);
// @ts-ignore
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => { retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData; const data = execution.workflowData;
let credentialsUpdated = false; let credentialsUpdated = false;
// @ts-ignore // @ts-ignore
@ -208,7 +297,7 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
{}, {},
); );
await queryRunner.query(updateQuery, updateParams); queryRunner.query(updateQuery, updateParams);
} }
}); });
} }

View file

@ -1,5 +1,6 @@
import { MigrationInterface, QueryRunner } from 'typeorm'; import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config'); import config = require('../../../../config');
import { MigrationHelpers } from '../../MigrationHelpers';
// replacing the credentials in workflows and execution // replacing the credentials in workflows and execution
// `nodeType: name` changes to `nodeType: { id, name }` // `nodeType: name` changes to `nodeType: { id, name }`
@ -8,22 +9,26 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
name = 'UpdateWorkflowCredentials1630419189837'; name = 'UpdateWorkflowCredentials1630419189837';
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
console.log('Start migration', this.name);
console.time(this.name);
let tablePrefix = config.get('database.tablePrefix'); let tablePrefix = config.get('database.tablePrefix');
const schema = config.get('database.postgresdb.schema'); const schema = config.get('database.postgresdb.schema');
if (schema) { if (schema) {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(` const credentialsEntities = await queryRunner.query(`
SELECT id, name, type SELECT id, name, type
FROM ${tablePrefix}credentials_entity FROM ${tablePrefix}credentials_entity
`); `);
const workflows = await queryRunner.query(` const workflowsQuery = `
SELECT id, nodes SELECT id, nodes
FROM ${tablePrefix}workflow_entity FROM ${tablePrefix}workflow_entity
`); `;
// @ts-ignore // @ts-ignore
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => { workflows.forEach(async (workflow) => {
const nodes = workflow.nodes; const nodes = workflow.nodes;
let credentialsUpdated = false; let credentialsUpdated = false;
@ -33,7 +38,6 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
const allNodeCredentials = Object.entries(node.credentials); const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) { for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') { if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find( const matchingCredentials = credentialsEntities.find(
// @ts-ignore // @ts-ignore
(credentials) => credentials.name === name && credentials.type === type, (credentials) => credentials.name === name && credentials.type === type,
@ -45,7 +49,8 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
} }
}); });
if (credentialsUpdated) { if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
` `
UPDATE ${tablePrefix}workflow_entity UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes SET nodes = :nodes
@ -55,15 +60,53 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
{}, {},
); );
await queryRunner.query(updateQuery, updateParams); queryRunner.query(updateQuery, updateParams);
} }
}); });
});
const waitingExecutions = await queryRunner.query(` const waitingExecutionsQuery = `
SELECT id, "workflowData" SELECT id, "workflowData"
FROM ${tablePrefix}execution_entity FROM ${tablePrefix}execution_entity
WHERE "waitTill" IS NOT NULL AND finished = FALSE WHERE "waitTill" IS NOT NULL AND finished = FALSE
`); `;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(` const retryableExecutions = await queryRunner.query(`
SELECT id, "workflowData" SELECT id, "workflowData"
@ -73,7 +116,8 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
LIMIT 200 LIMIT 200
`); `);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => { // @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData; const data = execution.workflowData;
let credentialsUpdated = false; let credentialsUpdated = false;
// @ts-ignore // @ts-ignore
@ -104,9 +148,10 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
{}, {},
); );
await queryRunner.query(updateQuery, updateParams); queryRunner.query(updateQuery, updateParams);
} }
}); });
console.timeEnd(this.name);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {
@ -115,17 +160,19 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
if (schema) { if (schema) {
tablePrefix = schema + '.' + tablePrefix; tablePrefix = schema + '.' + tablePrefix;
} }
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(` const credentialsEntities = await queryRunner.query(`
SELECT id, name, type SELECT id, name, type
FROM ${tablePrefix}credentials_entity FROM ${tablePrefix}credentials_entity
`); `);
const workflows = await queryRunner.query(` const workflowsQuery = `
SELECT id, nodes SELECT id, nodes
FROM ${tablePrefix}workflow_entity FROM ${tablePrefix}workflow_entity
`); `;
// @ts-ignore // @ts-ignore
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => { workflows.forEach(async (workflow) => {
const nodes = workflow.nodes; const nodes = workflow.nodes;
let credentialsUpdated = false; let credentialsUpdated = false;
@ -152,7 +199,8 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
} }
}); });
if (credentialsUpdated) { if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
` `
UPDATE ${tablePrefix}workflow_entity UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes SET nodes = :nodes
@ -162,15 +210,59 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
{}, {},
); );
await queryRunner.query(updateQuery, updateParams); queryRunner.query(updateQuery, updateParams);
} }
}); });
});
const waitingExecutions = await queryRunner.query(` const waitingExecutionsQuery = `
SELECT id, "workflowData" SELECT id, "workflowData"
FROM ${tablePrefix}execution_entity FROM ${tablePrefix}execution_entity
WHERE "waitTill" IS NOT NULL AND finished = FALSE WHERE "waitTill" IS NOT NULL AND finished = FALSE
`); `;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(` const retryableExecutions = await queryRunner.query(`
SELECT id, "workflowData" SELECT id, "workflowData"
@ -179,8 +271,8 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
ORDER BY "startedAt" DESC ORDER BY "startedAt" DESC
LIMIT 200 LIMIT 200
`); `);
// @ts-ignore
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => { retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData; const data = execution.workflowData;
let credentialsUpdated = false; let credentialsUpdated = false;
// @ts-ignore // @ts-ignore
@ -216,7 +308,7 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
{}, {},
); );
await queryRunner.query(updateQuery, updateParams); queryRunner.query(updateQuery, updateParams);
} }
}); });
} }

View file

@ -1,5 +1,6 @@
import { MigrationInterface, QueryRunner } from 'typeorm'; import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config'); import config = require('../../../../config');
import { MigrationHelpers } from '../../MigrationHelpers';
// replacing the credentials in workflows and execution // replacing the credentials in workflows and execution
// `nodeType: name` changes to `nodeType: { id, name }` // `nodeType: name` changes to `nodeType: { id, name }`
@ -8,18 +9,23 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
name = 'UpdateWorkflowCredentials1630330987096'; name = 'UpdateWorkflowCredentials1630330987096';
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
console.log('Start migration', this.name);
console.time(this.name);
const tablePrefix = config.get('database.tablePrefix'); const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(` const credentialsEntities = await queryRunner.query(`
SELECT id, name, type SELECT id, name, type
FROM "${tablePrefix}credentials_entity" FROM "${tablePrefix}credentials_entity"
`); `);
const workflows = await queryRunner.query(` const workflowsQuery = `
SELECT id, nodes SELECT id, nodes
FROM "${tablePrefix}workflow_entity" FROM "${tablePrefix}workflow_entity"
`); `;
// @ts-ignore // @ts-ignore
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => { workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes); const nodes = JSON.parse(workflow.nodes);
let credentialsUpdated = false; let credentialsUpdated = false;
@ -29,19 +35,19 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
const allNodeCredentials = Object.entries(node.credentials); const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) { for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') { if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find( const matchingCredentials = credentialsEntities.find(
// @ts-ignore // @ts-ignore
(credentials) => credentials.name === name && credentials.type === type, (credentials) => credentials.name === name && credentials.type === type,
); );
node.credentials[type] = { id: matchingCredentials?.id || null, name }; node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true; credentialsUpdated = true;
} }
} }
} }
}); });
if (credentialsUpdated) { if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
` `
UPDATE "${tablePrefix}workflow_entity" UPDATE "${tablePrefix}workflow_entity"
SET nodes = :nodes SET nodes = :nodes
@ -51,25 +57,19 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
{}, {},
); );
await queryRunner.query(updateQuery, updateParams); queryRunner.query(updateQuery, updateParams);
} }
}); });
});
const waitingExecutions = await queryRunner.query(` const waitingExecutionsQuery = `
SELECT id, "workflowData" SELECT id, "workflowData"
FROM "${tablePrefix}execution_entity" FROM "${tablePrefix}execution_entity"
WHERE "waitTill" IS NOT NULL AND finished = 0 WHERE "waitTill" IS NOT NULL AND finished = 0
`); `;
// @ts-ignore
const retryableExecutions = await queryRunner.query(` await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
SELECT id, "workflowData" waitingExecutions.forEach(async (execution) => {
FROM "${tablePrefix}execution_entity"
WHERE "waitTill" IS NULL AND finished = 0 AND mode != 'retry'
ORDER BY "startedAt" DESC
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
const data = JSON.parse(execution.workflowData); const data = JSON.parse(execution.workflowData);
let credentialsUpdated = false; let credentialsUpdated = false;
// @ts-ignore // @ts-ignore
@ -78,12 +78,55 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
const allNodeCredentials = Object.entries(node.credentials); const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) { for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') { if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find( const matchingCredentials = credentialsEntities.find(
// @ts-ignore // @ts-ignore
(credentials) => credentials.name === name && credentials.type === type, (credentials) => credentials.name === name && credentials.type === type,
); );
node.credentials[type] = { id: matchingCredentials?.id || null, name }; node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(`
SELECT id, "workflowData"
FROM "${tablePrefix}execution_entity"
WHERE "waitTill" IS NULL AND finished = 0 AND mode != 'retry'
ORDER BY "startedAt" DESC
LIMIT 200
`);
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = JSON.parse(execution.workflowData);
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true; credentialsUpdated = true;
} }
} }
@ -100,23 +143,28 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
{}, {},
); );
await queryRunner.query(updateQuery, updateParams); queryRunner.query(updateQuery, updateParams);
} }
}); });
console.timeEnd(this.name);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix'); const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(` const credentialsEntities = await queryRunner.query(`
SELECT id, name, type SELECT id, name, type
FROM "${tablePrefix}credentials_entity" FROM "${tablePrefix}credentials_entity"
`); `);
const workflows = await queryRunner.query(` const workflowsQuery = `
SELECT id, nodes SELECT id, nodes
FROM "${tablePrefix}workflow_entity" FROM "${tablePrefix}workflow_entity"
`); `;
// @ts-ignore
await helpers.runChunked(workflowsQuery, (workflows) => {
// @ts-ignore // @ts-ignore
workflows.forEach(async (workflow) => { workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes); const nodes = JSON.parse(workflow.nodes);
@ -127,10 +175,9 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
const allNodeCredentials = Object.entries(node.credentials); const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) { for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') { if (typeof creds === 'object') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find( const matchingCredentials = credentialsEntities.find(
// @ts-ignore // @ts-ignore double-equals because creds.id can be string or number
(credentials) => credentials.id === creds.id && credentials.type === type, (credentials) => credentials.id == creds.id && credentials.type === type,
); );
if (matchingCredentials) { if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name; node.credentials[type] = matchingCredentials.name;
@ -144,7 +191,8 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
} }
}); });
if (credentialsUpdated) { if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters( const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
` `
UPDATE "${tablePrefix}workflow_entity" UPDATE "${tablePrefix}workflow_entity"
SET nodes = :nodes SET nodes = :nodes
@ -154,15 +202,60 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
{}, {},
); );
await queryRunner.query(updateQuery, updateParams); queryRunner.query(updateQuery, updateParams);
} }
}); });
});
const waitingExecutions = await queryRunner.query(` const waitingExecutionsQuery = `
SELECT id, "workflowData" SELECT id, "workflowData"
FROM "${tablePrefix}execution_entity" FROM "${tablePrefix}execution_entity"
WHERE "waitTill" IS NOT NULL AND finished = 0 WHERE "waitTill" IS NOT NULL AND finished = 0
`); `;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
// @ts-ignore
waitingExecutions.forEach(async (execution) => {
const data = JSON.parse(execution.workflowData);
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore double-equals because creds.id can be string or number
(credentials) => credentials.id == creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(` const retryableExecutions = await queryRunner.query(`
SELECT id, "workflowData" SELECT id, "workflowData"
@ -172,7 +265,8 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
LIMIT 200 LIMIT 200
`); `);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => { // @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = JSON.parse(execution.workflowData); const data = JSON.parse(execution.workflowData);
let credentialsUpdated = false; let credentialsUpdated = false;
// @ts-ignore // @ts-ignore
@ -181,10 +275,9 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
const allNodeCredentials = Object.entries(node.credentials); const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) { for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') { if (typeof creds === 'object') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find( const matchingCredentials = credentialsEntities.find(
// @ts-ignore // @ts-ignore double-equals because creds.id can be string or number
(credentials) => credentials.id === creds.id && credentials.type === type, (credentials) => credentials.id == creds.id && credentials.type === type,
); );
if (matchingCredentials) { if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name; node.credentials[type] = matchingCredentials.name;
@ -208,7 +301,7 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
{}, {},
); );
await queryRunner.query(updateQuery, updateParams); queryRunner.query(updateQuery, updateParams);
} }
}); });
} }

View file

@ -5,28 +5,57 @@ import { IDataObject, LoggerProxy } from 'n8n-workflow';
import config = require('../../config'); import config = require('../../config');
import { getLogger } from '../Logger'; import { getLogger } from '../Logger';
interface IExecutionCountsBufferItem { type CountBufferItemKey =
manual_success_count: number; | 'manual_success_count'
manual_error_count: number; | 'manual_error_count'
prod_success_count: number; | 'prod_success_count'
prod_error_count: number; | 'prod_error_count';
}
type FirstExecutionItemKey =
| 'first_manual_success'
| 'first_manual_error'
| 'first_prod_success'
| 'first_prod_error';
type IExecutionCountsBufferItem = {
[key in CountBufferItemKey]: number;
};
interface IExecutionCountsBuffer { interface IExecutionCountsBuffer {
[workflowId: string]: IExecutionCountsBufferItem; [workflowId: string]: IExecutionCountsBufferItem;
} }
type IFirstExecutions = {
[key in FirstExecutionItemKey]: Date | undefined;
};
interface IExecutionsBuffer {
counts: IExecutionCountsBuffer;
firstExecutions: IFirstExecutions;
}
export class Telemetry { export class Telemetry {
private client?: TelemetryClient; private client?: TelemetryClient;
private instanceId: string; private instanceId: string;
private versionCli: string;
private pulseIntervalReference: NodeJS.Timeout; private pulseIntervalReference: NodeJS.Timeout;
private executionCountsBuffer: IExecutionCountsBuffer = {}; private executionCountsBuffer: IExecutionsBuffer = {
counts: {},
firstExecutions: {
first_manual_error: undefined,
first_manual_success: undefined,
first_prod_error: undefined,
first_prod_success: undefined,
},
};
constructor(instanceId: string) { constructor(instanceId: string, versionCli: string) {
this.instanceId = instanceId; this.instanceId = instanceId;
this.versionCli = versionCli;
const enabled = config.get('diagnostics.enabled') as boolean; const enabled = config.get('diagnostics.enabled') as boolean;
if (enabled) { if (enabled) {
@ -53,33 +82,41 @@ export class Telemetry {
return Promise.resolve(); return Promise.resolve();
} }
const allPromises = Object.keys(this.executionCountsBuffer).map(async (workflowId) => { const allPromises = Object.keys(this.executionCountsBuffer.counts).map(async (workflowId) => {
const promise = this.track('Workflow execution count', { const promise = this.track('Workflow execution count', {
version_cli: this.versionCli,
workflow_id: workflowId, workflow_id: workflowId,
...this.executionCountsBuffer[workflowId], ...this.executionCountsBuffer.counts[workflowId],
...this.executionCountsBuffer.firstExecutions,
}); });
this.executionCountsBuffer[workflowId].manual_error_count = 0;
this.executionCountsBuffer[workflowId].manual_success_count = 0; this.executionCountsBuffer.counts[workflowId].manual_error_count = 0;
this.executionCountsBuffer[workflowId].prod_error_count = 0; this.executionCountsBuffer.counts[workflowId].manual_success_count = 0;
this.executionCountsBuffer[workflowId].prod_success_count = 0; this.executionCountsBuffer.counts[workflowId].prod_error_count = 0;
this.executionCountsBuffer.counts[workflowId].prod_success_count = 0;
return promise; return promise;
}); });
allPromises.push(this.track('pulse')); allPromises.push(this.track('pulse', { version_cli: this.versionCli }));
return Promise.all(allPromises); return Promise.all(allPromises);
} }
async trackWorkflowExecution(properties: IDataObject): Promise<void> { async trackWorkflowExecution(properties: IDataObject): Promise<void> {
if (this.client) { if (this.client) {
const workflowId = properties.workflow_id as string; const workflowId = properties.workflow_id as string;
this.executionCountsBuffer[workflowId] = this.executionCountsBuffer[workflowId] ?? { this.executionCountsBuffer.counts[workflowId] = this.executionCountsBuffer.counts[
workflowId
] ?? {
manual_error_count: 0, manual_error_count: 0,
manual_success_count: 0, manual_success_count: 0,
prod_error_count: 0, prod_error_count: 0,
prod_success_count: 0, prod_success_count: 0,
}; };
let countKey: CountBufferItemKey;
let firstExecKey: FirstExecutionItemKey;
if ( if (
properties.success === false && properties.success === false &&
properties.error_node_type && properties.error_node_type &&
@ -89,15 +126,28 @@ export class Telemetry {
void this.track('Workflow execution errored', properties); void this.track('Workflow execution errored', properties);
if (properties.is_manual) { if (properties.is_manual) {
this.executionCountsBuffer[workflowId].manual_error_count++; firstExecKey = 'first_manual_error';
countKey = 'manual_error_count';
} else { } else {
this.executionCountsBuffer[workflowId].prod_error_count++; firstExecKey = 'first_prod_error';
countKey = 'prod_error_count';
} }
} else if (properties.is_manual) { } else if (properties.is_manual) {
this.executionCountsBuffer[workflowId].manual_success_count++; countKey = 'manual_success_count';
firstExecKey = 'first_manual_success';
} else { } else {
this.executionCountsBuffer[workflowId].prod_success_count++; countKey = 'prod_success_count';
firstExecKey = 'first_prod_success';
} }
if (
!this.executionCountsBuffer.firstExecutions[firstExecKey] &&
this.executionCountsBuffer.counts[workflowId][countKey] === 0
) {
this.executionCountsBuffer.firstExecutions[firstExecKey] = new Date();
}
this.executionCountsBuffer.counts[workflowId][countKey]++;
} }
} }
@ -119,6 +169,7 @@ export class Telemetry {
this.client.identify( this.client.identify(
{ {
userId: this.instanceId, userId: this.instanceId,
anonymousId: '000000000000',
traits: { traits: {
...traits, ...traits,
instanceId: this.instanceId, instanceId: this.instanceId,
@ -139,6 +190,7 @@ export class Telemetry {
this.client.track( this.client.track(
{ {
userId: this.instanceId, userId: this.instanceId,
anonymousId: '000000000000',
event: eventName, event: eventName,
// @ts-ignore // @ts-ignore
properties, properties,

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-core", "name": "n8n-core",
"version": "0.90.0", "version": "0.97.0",
"description": "Core functionality of n8n", "description": "Core functionality of n8n",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
@ -50,7 +50,7 @@
"form-data": "^4.0.0", "form-data": "^4.0.0",
"lodash.get": "^4.4.2", "lodash.get": "^4.4.2",
"mime-types": "^2.1.27", "mime-types": "^2.1.27",
"n8n-workflow": "~0.73.0", "n8n-workflow": "~0.80.0",
"oauth-1.0a": "^2.2.6", "oauth-1.0a": "^2.2.6",
"p-cancelable": "^2.0.0", "p-cancelable": "^2.0.0",
"qs": "^6.10.1", "qs": "^6.10.1",

View file

@ -22,6 +22,7 @@ import {
ICredentialsExpressionResolveValues, ICredentialsExpressionResolveValues,
IDataObject, IDataObject,
IExecuteFunctions, IExecuteFunctions,
IExecuteResponsePromiseData,
IExecuteSingleFunctions, IExecuteSingleFunctions,
IExecuteWorkflowInfo, IExecuteWorkflowInfo,
IHttpRequestOptions, IHttpRequestOptions,
@ -71,7 +72,7 @@ import { fromBuffer } from 'file-type';
import { lookup } from 'mime-types'; import { lookup } from 'mime-types';
import axios, { AxiosProxyConfig, AxiosRequestConfig, Method } from 'axios'; import axios, { AxiosProxyConfig, AxiosRequestConfig, Method } from 'axios';
import { URLSearchParams } from 'url'; import { URL, URLSearchParams } from 'url';
// eslint-disable-next-line import/no-cycle // eslint-disable-next-line import/no-cycle
import { import {
BINARY_ENCODING, BINARY_ENCODING,
@ -86,6 +87,14 @@ import {
axios.defaults.timeout = 300000; axios.defaults.timeout = 300000;
// Prevent axios from adding x-form-www-urlencoded headers by default // Prevent axios from adding x-form-www-urlencoded headers by default
axios.defaults.headers.post = {}; axios.defaults.headers.post = {};
axios.defaults.headers.put = {};
axios.defaults.headers.patch = {};
axios.defaults.paramsSerializer = (params) => {
if (params instanceof URLSearchParams) {
return params.toString();
}
return stringify(params, { arrayFormat: 'indices' });
};
const requestPromiseWithDefaults = requestPromise.defaults({ const requestPromiseWithDefaults = requestPromise.defaults({
timeout: 300000, // 5 minutes timeout: 300000, // 5 minutes
@ -128,6 +137,28 @@ function searchForHeader(headers: IDataObject, headerName: string) {
return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName); return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName);
} }
async function generateContentLengthHeader(formData: FormData, headers: IDataObject) {
if (!formData || !formData.getLength) {
return;
}
try {
const length = await new Promise((res, rej) => {
formData.getLength((error: Error | null, length: number) => {
if (error) {
rej(error);
return;
}
res(length);
});
});
headers = Object.assign(headers, {
'content-length': length,
});
} catch (error) {
Logger.error('Unable to calculate form data length', { error });
}
}
async function parseRequestObject(requestObject: IDataObject) { async function parseRequestObject(requestObject: IDataObject) {
// This function is a temporary implementation // This function is a temporary implementation
// That translates all http requests done via // That translates all http requests done via
@ -192,6 +223,7 @@ async function parseRequestObject(requestObject: IDataObject) {
delete axiosConfig.headers[contentTypeHeaderKeyName]; delete axiosConfig.headers[contentTypeHeaderKeyName];
const headers = axiosConfig.data.getHeaders(); const headers = axiosConfig.data.getHeaders();
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers); axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
await generateContentLengthHeader(axiosConfig.data, axiosConfig.headers);
} else { } else {
// When using the `form` property it means the content should be x-www-form-urlencoded. // When using the `form` property it means the content should be x-www-form-urlencoded.
if (requestObject.form !== undefined && requestObject.body === undefined) { if (requestObject.form !== undefined && requestObject.body === undefined) {
@ -228,6 +260,7 @@ async function parseRequestObject(requestObject: IDataObject) {
// Mix in headers as FormData creates the boundary. // Mix in headers as FormData creates the boundary.
const headers = axiosConfig.data.getHeaders(); const headers = axiosConfig.data.getHeaders();
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers); axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
await generateContentLengthHeader(axiosConfig.data, axiosConfig.headers);
} else if (requestObject.body !== undefined) { } else if (requestObject.body !== undefined) {
// If we have body and possibly form // If we have body and possibly form
if (requestObject.form !== undefined) { if (requestObject.form !== undefined) {
@ -338,8 +371,64 @@ async function parseRequestObject(requestObject: IDataObject) {
} }
if (requestObject.proxy !== undefined) { if (requestObject.proxy !== undefined) {
// try our best to parse the url provided.
if (typeof requestObject.proxy === 'string') {
try {
const url = new URL(requestObject.proxy);
axiosConfig.proxy = {
host: url.hostname,
port: parseInt(url.port, 10),
protocol: url.protocol,
};
if (!url.port) {
// Sets port to a default if not informed
if (url.protocol === 'http') {
axiosConfig.proxy.port = 80;
} else if (url.protocol === 'https') {
axiosConfig.proxy.port = 443;
}
}
if (url.username || url.password) {
axiosConfig.proxy.auth = {
username: url.username,
password: url.password,
};
}
} catch (error) {
// Not a valid URL. We will try to simply parse stuff
// such as user:pass@host:port without protocol (we'll assume http)
if (requestObject.proxy.includes('@')) {
const [userpass, hostport] = requestObject.proxy.split('@');
const [username, password] = userpass.split(':');
const [hostname, port] = hostport.split(':');
axiosConfig.proxy = {
host: hostname,
port: parseInt(port, 10),
protocol: 'http',
auth: {
username,
password,
},
};
} else if (requestObject.proxy.includes(':')) {
const [hostname, port] = requestObject.proxy.split(':');
axiosConfig.proxy = {
host: hostname,
port: parseInt(port, 10),
protocol: 'http',
};
} else {
axiosConfig.proxy = {
host: requestObject.proxy,
port: 80,
protocol: 'http',
};
}
}
} else {
axiosConfig.proxy = requestObject.proxy as AxiosProxyConfig; axiosConfig.proxy = requestObject.proxy as AxiosProxyConfig;
} }
}
if (requestObject.encoding === null) { if (requestObject.encoding === null) {
// When downloading files, return an arrayBuffer. // When downloading files, return an arrayBuffer.
@ -357,6 +446,7 @@ async function parseRequestObject(requestObject: IDataObject) {
if ( if (
requestObject.json !== false && requestObject.json !== false &&
axiosConfig.data !== undefined && axiosConfig.data !== undefined &&
axiosConfig.data !== '' &&
!(axiosConfig.data instanceof Buffer) && !(axiosConfig.data instanceof Buffer) &&
!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'content-type') !allHeaders.some((headerKey) => headerKey.toLowerCase() === 'content-type')
) { ) {
@ -406,6 +496,11 @@ async function proxyRequestToAxios(
axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject)); axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject));
Logger.debug('Proxying request to axios', {
originalConfig: configObject,
parsedConfig: axiosConfig,
});
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios(axiosConfig) axios(axiosConfig)
.then((response) => { .then((response) => {
@ -438,17 +533,17 @@ async function proxyRequestToAxios(
} }
}) })
.catch((error) => { .catch((error) => {
if (configObject.simple === true && error.response) { if (configObject.simple === false && error.response) {
if (configObject.resolveWithFullResponse) {
resolve({ resolve({
body: error.response.data, body: error.response.data,
headers: error.response.headers, headers: error.response.headers,
statusCode: error.response.status, statusCode: error.response.status,
statusMessage: error.response.statusText, statusMessage: error.response.statusText,
}); });
return; } else {
}
if (configObject.simple === false && error.response) {
resolve(error.response.data); resolve(error.response.data);
}
return; return;
} }
@ -1554,19 +1649,22 @@ export function getExecuteFunctions(
async putExecutionToWait(waitTill: Date): Promise<void> { async putExecutionToWait(waitTill: Date): Promise<void> {
runExecutionData.waitTill = waitTill; runExecutionData.waitTill = waitTill;
}, },
sendMessageToUI(message: any): void { sendMessageToUI(...args: any[]): void {
if (mode !== 'manual') { if (mode !== 'manual') {
return; return;
} }
try { try {
if (additionalData.sendMessageToUI) { if (additionalData.sendMessageToUI) {
additionalData.sendMessageToUI(node.name, message); additionalData.sendMessageToUI(node.name, args);
} }
} catch (error) { } catch (error) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
Logger.warn(`There was a problem sending messsage to UI: ${error.message}`); Logger.warn(`There was a problem sending messsage to UI: ${error.message}`);
} }
}, },
async sendResponse(response: IExecuteResponsePromiseData): Promise<void> {
await additionalData.hooks?.executeHookFunctions('sendResponse', [response]);
},
helpers: { helpers: {
httpRequest, httpRequest,
prepareBinaryData, prepareBinaryData,

View file

@ -12,7 +12,6 @@ export * from './ActiveWorkflows';
export * from './ActiveWebhooks'; export * from './ActiveWebhooks';
export * from './Constants'; export * from './Constants';
export * from './Credentials'; export * from './Credentials';
export * from './DeferredPromise';
export * from './Interfaces'; export * from './Interfaces';
export * from './LoadNodeParameterOptions'; export * from './LoadNodeParameterOptions';
export * from './NodeExecuteFunctions'; export * from './NodeExecuteFunctions';

View file

@ -4,6 +4,7 @@ import {
ICredentialDataDecryptedObject, ICredentialDataDecryptedObject,
ICredentialsHelper, ICredentialsHelper,
IDataObject, IDataObject,
IDeferredPromise,
IExecuteWorkflowInfo, IExecuteWorkflowInfo,
INodeCredentialsDetails, INodeCredentialsDetails,
INodeExecutionData, INodeExecutionData,
@ -20,7 +21,7 @@ import {
WorkflowHooks, WorkflowHooks,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { Credentials, IDeferredPromise, IExecuteFunctions } from '../src'; import { Credentials, IExecuteFunctions } from '../src';
export class CredentialsHelper extends ICredentialsHelper { export class CredentialsHelper extends ICredentialsHelper {
getDecrypted( getDecrypted(
@ -615,10 +616,7 @@ class NodeTypesClass implements INodeTypes {
name: 'dotNotation', name: 'dotNotation',
type: 'boolean', type: 'boolean',
default: true, default: true,
description: `By default does dot-notation get used in property names..<br /> description: `<p>By default, dot-notation is used in property names. This means that "a.b" will set the property "b" underneath "a" so { "a": { "b": value} }.</p><p>If that is not intended this can be deactivated, it will then set { "a.b": value } instead.</p>`,
This means that "a.b" will set the property "b" underneath "a" so { "a": { "b": value} }.<br />
If that is not intended this can be deactivated, it will then set { "a.b": value } instead.
`,
}, },
], ],
}, },
@ -725,7 +723,7 @@ class NodeTypesClass implements INodeTypes {
async init(nodeTypes: INodeTypeData): Promise<void> {} async init(nodeTypes: INodeTypeData): Promise<void> {}
getAll(): INodeType[] { getAll(): INodeType[] {
return Object.values(this.nodeTypes).map((data) => NodeHelpers.getVersionedTypeNode(data.type)); return Object.values(this.nodeTypes).map((data) => NodeHelpers.getVersionedNodeType(data.type));
} }
getByName(nodeType: string): INodeType { getByName(nodeType: string): INodeType {
@ -733,7 +731,7 @@ class NodeTypesClass implements INodeTypes {
} }
getByNameAndVersion(nodeType: string, version?: number): INodeType { getByNameAndVersion(nodeType: string, version?: number): INodeType {
return NodeHelpers.getVersionedTypeNode(this.nodeTypes[nodeType].type, version); return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType].type, version);
} }
} }

View file

@ -1,6 +1,14 @@
import { IConnections, ILogger, INode, IRun, LoggerProxy, Workflow } from 'n8n-workflow'; import {
createDeferredPromise,
IConnections,
ILogger,
INode,
IRun,
LoggerProxy,
Workflow,
} from 'n8n-workflow';
import { createDeferredPromise, WorkflowExecute } from '../src'; import { WorkflowExecute } from '../src';
import * as Helpers from './Helpers'; import * as Helpers from './Helpers';

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-design-system", "name": "n8n-design-system",
"version": "0.5.0", "version": "0.9.0",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
"author": { "author": {

View file

@ -18,7 +18,7 @@ export default {
size: { size: {
control: { control: {
type: 'select', type: 'select',
options: ['small', 'medium', 'large'], options: ['mini', 'small', 'medium', 'large', 'xlarge'],
}, },
}, },
loading: { loading: {
@ -31,12 +31,6 @@ export default {
type: 'text', type: 'text',
}, },
}, },
iconSize: {
control: {
type: 'select',
options: ['small', 'medium', 'large'],
},
},
circle: { circle: {
control: { control: {
type: 'boolean', type: 'boolean',

View file

@ -16,13 +16,13 @@
<component <component
:is="$options.components.N8nSpinner" :is="$options.components.N8nSpinner"
v-if="props.loading" v-if="props.loading"
:size="props.iconSize" :size="props.size"
/> />
<component <component
:is="$options.components.N8nIcon" :is="$options.components.N8nIcon"
v-else-if="props.icon" v-else-if="props.icon"
:icon="props.icon" :icon="props.icon"
:size="props.iconSize" :size="props.size"
/> />
</span> </span>
<span v-if="props.label">{{ props.label }}</span> <span v-if="props.label">{{ props.label }}</span>
@ -58,7 +58,7 @@ export default {
type: String, type: String,
default: 'medium', default: 'medium',
validator: (value: string): boolean => validator: (value: string): boolean =>
['small', 'medium', 'large'].indexOf(value) !== -1, ['mini', 'small', 'medium', 'large', 'xlarge'].indexOf(value) !== -1,
}, },
loading: { loading: {
type: Boolean, type: Boolean,
@ -71,9 +71,6 @@ export default {
icon: { icon: {
type: String, type: String,
}, },
iconSize: {
type: String,
},
round: { round: {
type: Boolean, type: Boolean,
default: true, default: true,

View file

@ -35,9 +35,6 @@ export declare class N8nButton extends N8nComponent {
/** Button icon, accepts an icon name of font awesome icon component */ /** Button icon, accepts an icon name of font awesome icon component */
icon: string; icon: string;
/** Size of icon */
iconSize: N8nComponentSize;
/** Full width */ /** Full width */
fullWidth: boolean; fullWidth: boolean;

View file

@ -13,7 +13,7 @@ export default {
color: { color: {
control: { control: {
type: 'select', type: 'select',
options: ['primary', 'text-dark', 'text-base', 'text-light'], options: ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'],
}, },
}, },
}, },

View file

@ -23,7 +23,7 @@ export default {
}, },
color: { color: {
type: String, type: String,
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light'].includes(value), validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'].includes(value),
}, },
}, },
methods: { methods: {

View file

@ -1,19 +1,27 @@
<template functional> <template functional>
<component
:is="$options.components.N8nText"
:size="props.size"
:compact="true"
>
<component <component
:is="$options.components.FontAwesomeIcon" :is="$options.components.FontAwesomeIcon"
:class="$style[`_${props.size}`]"
:icon="props.icon" :icon="props.icon"
:spin="props.spin" :spin="props.spin"
:class="$style[props.size]"
/> />
</component>
</template> </template>
<script lang="ts"> <script lang="ts">
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import N8nText from '../N8nText';
export default { export default {
name: 'n8n-icon', name: 'n8n-icon',
components: { components: {
FontAwesomeIcon, FontAwesomeIcon,
N8nText,
}, },
props: { props: {
icon: { icon: {
@ -23,9 +31,6 @@ export default {
size: { size: {
type: String, type: String,
default: 'medium', default: 'medium',
validator: function (value: string): boolean {
return ['small', 'medium', 'large'].indexOf(value) !== -1;
},
}, },
spin: { spin: {
type: Boolean, type: Boolean,
@ -35,22 +40,21 @@ export default {
}; };
</script> </script>
<style lang="scss" module> <style lang="scss" module>
._small { .xlarge {
font-size: 0.85em; width: var(--font-size-xl) !important;
height: 0.85em;
width: 0.85em !important;
} }
._medium { .large {
font-size: 0.95em; width: var(--font-size-m) !important;
height: 0.95em;
width: 0.95em !important;
} }
._large { .medium {
font-size: 1.22em; width: var(--font-size-s) !important;
height: 1.22em; }
width: 1.22em !important;
.small {
width: var(--font-size-2xs) !important;
} }
</style> </style>

View file

@ -2,11 +2,10 @@
<component :is="$options.components.N8nButton" <component :is="$options.components.N8nButton"
:type="props.type" :type="props.type"
:disabled="props.disabled" :disabled="props.disabled"
:size="props.size === 'xlarge' ? 'large' : props.size" :size="props.size"
:loading="props.loading" :loading="props.loading"
:title="props.title" :title="props.title"
:icon="props.icon" :icon="props.icon"
:iconSize="$options.iconSizeMap[props.size] || props.size"
:theme="props.theme" :theme="props.theme"
@click="(e) => listeners.click && listeners.click(e)" @click="(e) => listeners.click && listeners.click(e)"
circle circle
@ -16,11 +15,6 @@
<script lang="ts"> <script lang="ts">
import N8nButton from '../N8nButton'; import N8nButton from '../N8nButton';
const iconSizeMap = {
large: 'medium',
xlarge: 'large',
};
export default { export default {
name: 'n8n-icon-button', name: 'n8n-icon-button',
components: { components: {
@ -36,8 +30,6 @@ export default {
size: { size: {
type: String, type: String,
default: 'medium', default: 'medium',
validator: (value: string): boolean =>
['small', 'medium', 'large', 'xlarge'].indexOf(value) !== -1,
}, },
loading: { loading: {
type: Boolean, type: Boolean,
@ -55,6 +47,5 @@ export default {
type: String, type: String,
}, },
}, },
iconSizeMap,
}; };
</script> </script>

View file

@ -12,9 +12,6 @@ export declare class N8nIconButton extends N8nComponent {
/** Button size */ /** Button size */
size: N8nComponentSize | 'xlarge'; size: N8nComponentSize | 'xlarge';
/** icon size */
iconSize: N8nComponentSize;
/** Determine whether it's loading */ /** Determine whether it's loading */
loading: boolean; loading: boolean;

View file

@ -1,13 +1,13 @@
<template functional> <template functional>
<div :class="$style.inputLabel"> <div :class="{[$style.inputLabelContainer]: !props.labelHoverableOnly}">
<div :class="props.label ? $style.label: ''"> <div :class="{[$style.inputLabel]: props.labelHoverableOnly, [$options.methods.getLabelClass(props, $style)]: true}">
<component v-if="props.label" :is="$options.components.N8nText" :bold="true"> <component v-if="props.label" :is="$options.components.N8nText" :bold="props.bold" :size="props.size" :compact="!props.underline">
{{ props.label }} {{ props.label }}
<component :is="$options.components.N8nText" color="primary" :bold="true" v-if="props.required">*</component> <component :is="$options.components.N8nText" color="primary" :bold="props.bold" :size="props.size" v-if="props.required">*</component>
</component> </component>
<span :class="$style.infoIcon" v-if="props.tooltipText"> <span :class="[$style.infoIcon, props.showTooltip ? $style.showIcon: $style.hiddenIcon]" v-if="props.tooltipText">
<component :is="$options.components.N8nTooltip" placement="top" :popper-class="$style.tooltipPopper"> <component :is="$options.components.N8nTooltip" placement="top" :popper-class="$style.tooltipPopper">
<component :is="$options.components.N8nIcon" icon="question-circle" /> <component :is="$options.components.N8nIcon" icon="question-circle" size="small" />
<div slot="content" v-html="$options.methods.addTargetBlank(props.tooltipText)"></div> <div slot="content" v-html="$options.methods.addTargetBlank(props.tooltipText)"></div>
</component> </component>
</span> </span>
@ -40,34 +40,104 @@ export default {
required: { required: {
type: Boolean, type: Boolean,
}, },
bold: {
type: Boolean,
default: true,
},
size: {
type: String,
default: 'medium',
validator: (value: string): boolean =>
['small', 'medium'].includes(value),
},
underline: {
type: Boolean,
},
showTooltip: {
type: Boolean,
},
labelHoverableOnly: {
type: Boolean,
default: false,
},
}, },
methods: { methods: {
addTargetBlank, addTargetBlank,
getLabelClass(props: {label: string, size: string, underline: boolean}, $style: any) {
if (!props.label) {
return '';
}
if (props.underline) {
return $style[`label-${props.size}-underline`];
}
return $style[`label-${props.size}`];
},
}, },
}; };
</script> </script>
<style lang="scss" module> <style lang="scss" module>
.inputLabel { .inputLabelContainer:hover {
&:hover { > div > .infoIcon {
--info-icon-display: inline-block; display: inline-block;
} }
} }
.label { .inputLabel:hover {
margin-bottom: var(--spacing-2xs); > .infoIcon {
display: inline-block;
* {
margin-right: var(--spacing-4xs);
} }
} }
.infoIcon { .infoIcon {
color: var(--color-text-light); color: var(--color-text-light);
display: var(--info-icon-display, none); }
.showIcon {
display: inline-block;
}
.hiddenIcon {
display: none;
}
.label {
* {
margin-right: var(--spacing-5xs);
}
}
.label-small {
composes: label;
margin-bottom: var(--spacing-4xs);
}
.label-medium {
composes: label;
margin-bottom: var(--spacing-2xs);
}
.underline {
border-bottom: var(--border-base);
}
.label-small-underline {
composes: label-small;
composes: underline;
}
.label-medium-underline {
composes: label-medium;
composes: underline;
} }
.tooltipPopper { .tooltipPopper {
max-width: 400px; max-width: 400px;
li {
margin-left: var(--spacing-s);
}
} }
</style> </style>

View file

@ -0,0 +1,27 @@
import N8nSquareButton from './SquareButton.vue';
import { action } from '@storybook/addon-actions';
export default {
title: 'Atoms/SquareButton',
component: N8nSquareButton,
argTypes: {
label: {
control: 'text',
},
},
};
const methods = {
onClick: action('click'),
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nSquareButton,
},
template: '<n8n-square-button v-bind="$props" @click="onClick"></n8n-square-button>',
methods,
});
export const SquareButton = Template.bind({});

View file

@ -0,0 +1,43 @@
<template functional>
<button :class="$style.button" @click="(e) => listeners.click && listeners.click(e)">
<span :class="$style.text" v-text="props.label" />
</button>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'n8n-square-button',
props: {
label: {
type: String,
},
},
});
</script>
<style lang="scss" module>
.button {
width: 28px;
height: 29px;
border-radius: var(--border-radius-base);
border: var(--color-background-xlight);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
.text {
color: var(--color-primary) !important;
}
}
}
.text {
font-size: var(--font-size-s);
font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-loose);
color: var(--color-background-dark);
}
</style>

View file

@ -0,0 +1,3 @@
import N8nSquareButton from './SquareButton.vue';
export default N8nSquareButton;

View file

@ -13,7 +13,7 @@ export default {
color: { color: {
control: { control: {
type: 'select', type: 'select',
options: ['primary', 'text-dark', 'text-base', 'text-light'], options: ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'],
}, },
}, },
}, },

View file

@ -16,26 +16,33 @@ export default Vue.extend({
size: { size: {
type: String, type: String,
default: 'medium', default: 'medium',
validator: (value: string): boolean => ['large', 'medium', 'small'].includes(value), validator: (value: string): boolean => ['mini', 'small', 'medium', 'large', 'xlarge'].includes(value),
}, },
color: { color: {
type: String, type: String,
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light'].includes(value), validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'].includes(value),
}, },
align: { align: {
type: String, type: String,
validator: (value: string): boolean => ['right', 'left', 'center'].includes(value), validator: (value: string): boolean => ['right', 'left', 'center'].includes(value),
}, },
compact: {
type: Boolean,
default: false,
},
}, },
methods: { methods: {
getClass(props: {size: string, bold: boolean}) { getClass(props: {size: string, bold: boolean}) {
return `body-${props.size}${props.bold ? '-bold' : '-regular'}`; return `body-${props.size}${props.bold ? '-bold' : '-regular'}`;
}, },
getStyles(props: {color: string, align: string}) { getStyles(props: {color: string, align: string, compact: false}) {
const styles = {} as any; const styles = {} as any;
if (props.color) { if (props.color) {
styles.color = `var(--color-${props.color})`; styles.color = `var(--color-${props.color})`;
} }
if (props.compact) {
styles['line-height'] = 1;
}
if (props.align) { if (props.align) {
styles['text-align'] = props.align; styles['text-align'] = props.align;
} }
@ -54,6 +61,22 @@ export default Vue.extend({
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
} }
.body-xlarge {
font-size: var(--font-size-xl);
line-height: var(--font-line-height-xloose);
}
.body-xlarge-regular {
composes: regular;
composes: body-xlarge;
}
.body-xlarge-bold {
composes: bold;
composes: body-xlarge;
}
.body-large { .body-large {
font-size: var(--font-size-m); font-size: var(--font-size-m);
line-height: var(--font-line-height-xloose); line-height: var(--font-line-height-xloose);

View file

@ -7,4 +7,4 @@ export declare class N8nComponent extends Vue {
} }
/** Component size definition for button, input, etc */ /** Component size definition for button, input, etc */
export type N8nComponentSize = 'large' | 'medium' | 'small'; export type N8nComponentSize = 'xlarge' | 'large' | 'medium' | 'small' | 'mini';

View file

@ -10,6 +10,7 @@ import N8nMenu from './N8nMenu';
import N8nMenuItem from './N8nMenuItem'; import N8nMenuItem from './N8nMenuItem';
import N8nSelect from './N8nSelect'; import N8nSelect from './N8nSelect';
import N8nSpinner from './N8nSpinner'; import N8nSpinner from './N8nSpinner';
import N8nSquareButton from './N8nSquareButton';
import N8nText from './N8nText'; import N8nText from './N8nText';
import N8nTooltip from './N8nTooltip'; import N8nTooltip from './N8nTooltip';
import N8nOption from './N8nOption'; import N8nOption from './N8nOption';
@ -27,6 +28,7 @@ export {
N8nMenuItem, N8nMenuItem,
N8nSelect, N8nSelect,
N8nSpinner, N8nSpinner,
N8nSquareButton,
N8nText, N8nText,
N8nTooltip, N8nTooltip,
N8nOption, N8nOption,

View file

@ -16,7 +16,7 @@ import VariableTable from './VariableTable.vue';
<Canvas> <Canvas>
<Story name="border-radius"> <Story name="border-radius">
{{ {{
template: `<variable-table :variables="['--border-radius-small','--border-radius-base']" />`, template: `<variable-table :variables="['--border-radius-small','--border-radius-base', '--border-radius-large', '--border-radius-xlarge']" />`,
components: { components: {
VariableTable, VariableTable,
}, },

View file

@ -44,7 +44,7 @@ import ColorCircles from './ColorCircles.vue';
<Canvas> <Canvas>
<Story name="success"> <Story name="success">
{{ {{
template: `<color-circles :colors="['--color-success', '--color-success-tint-1', '--color-success-tint-2']" />`, template: `<color-circles :colors="['--color-success', '--color-success-tint-1', '--color-success-tint-2', '--color-success-light']" />`,
components: { components: {
ColorCircles, ColorCircles,
}, },
@ -109,7 +109,7 @@ import ColorCircles from './ColorCircles.vue';
<Canvas> <Canvas>
<Story name="foreground"> <Story name="foreground">
{{ {{
template: `<color-circles :colors="['--color-foreground-base', '--color-foreground-light', '--color-foreground-xlight']" />`, template: `<color-circles :colors="['--color-foreground-xdark', '--color-foreground-dark', '--color-foreground-base', '--color-foreground-light', '--color-foreground-xlight']" />`,
components: { components: {
ColorCircles, ColorCircles,
}, },
@ -129,3 +129,16 @@ import ColorCircles from './ColorCircles.vue';
}} }}
</Story> </Story>
</Canvas> </Canvas>
## Canvas
<Canvas>
<Story name="canvas">
{{
template: `<color-circles :colors="['--color-canvas-background', '--color-canvas-dot']" />`,
components: {
ColorCircles,
},
}}
</Story>
</Canvas>

View file

@ -75,6 +75,15 @@
var(--color-success-tint-2-l) var(--color-success-tint-2-l)
); );
--color-success-light-h: 150;
--color-success-light-s: 54%;
--color-success-light-l: 70%;
--color-success-light: hsl(
var(--color-success-light-h),
var(--color-success-light-s),
var(--color-success-light-l)
);
--color-warning-h: 36; --color-warning-h: 36;
--color-warning-s: 77%; --color-warning-s: 77%;
--color-warning-l: 57%; --color-warning-l: 57%;
@ -187,6 +196,24 @@
var(--color-text-xlight-l) var(--color-text-xlight-l)
); );
--color-foreground-xdark-h: 220;
--color-foreground-xdark-s: 7.4%;
--color-foreground-xdark-l: 52.5%;
--color-foreground-xdark: hsl(
var(--color-foreground-xdark-h),
var(--color-foreground-xdark-s),
var(--color-foreground-xdark-l)
);
--color-foreground-dark-h: 228;
--color-foreground-dark-s: 9.6%;
--color-foreground-dark-l: 79.6%;
--color-foreground-dark: hsl(
var(--color-foreground-dark-h),
var(--color-foreground-dark-s),
var(--color-foreground-dark-l)
);
--color-foreground-base-h: 220; --color-foreground-base-h: 220;
--color-foreground-base-s: 20%; --color-foreground-base-s: 20%;
--color-foreground-base-l: 88.2%; --color-foreground-base-l: 88.2%;
@ -259,6 +286,25 @@
var(--color-background-xlight-l) var(--color-background-xlight-l)
); );
--color-canvas-dot-h: 204;
--color-canvas-dot-s: 15.6%;
--color-canvas-dot-l: 87.5%;
--color-canvas-dot: hsl(
var(--color-canvas-dot-h),
var(--color-canvas-dot-s),
var(--color-canvas-dot-l)
);
--color-canvas-background-h: 260;
--color-canvas-background-s: 100%;
--color-canvas-background-l: 99.4%;
--color-canvas-background: hsl(
var(--color-canvas-background-h),
var(--color-canvas-background-s),
var(--color-canvas-background-l)
);
--border-radius-xlarge: 12px;
--border-radius-large: 8px; --border-radius-large: 8px;
--border-radius-base: 4px; --border-radius-base: 4px;
--border-radius-small: 2px; --border-radius-small: 2px;

View file

@ -83,6 +83,17 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0.35);
--button-border-radius: 50%; --button-border-radius: 50%;
} }
@include mixins.m(mini) {
--button-padding-vertical: var(--spacing-4xs);
--button-padding-horizontal: var(--spacing-2xs);
--button-font-size: var(--font-size-2xs);
@include mixins.when(circle) {
--button-padding-vertical: var(--spacing-4xs);
--button-padding-horizontal: var(--spacing-4xs);
}
}
@include mixins.m(small) { @include mixins.m(small) {
--button-padding-vertical: var(--spacing-3xs); --button-padding-vertical: var(--spacing-3xs);
--button-padding-horizontal: var(--spacing-xs); --button-padding-horizontal: var(--spacing-xs);
@ -104,4 +115,15 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0.35);
--button-padding-horizontal: var(--spacing-2xs); --button-padding-horizontal: var(--spacing-2xs);
} }
} }
@include mixins.m(xlarge) {
--button-padding-vertical: var(--spacing-xs);
--button-padding-horizontal: var(--spacing-s);
--button-font-size: var(--font-size-m);
@include mixins.when(circle) {
--button-padding-vertical: var(--spacing-xs);
--button-padding-horizontal: var(--spacing-xs);
}
}
} }

View file

@ -68,8 +68,6 @@
@include mixins.e(body) { @include mixins.e(body) {
padding: var.$dialog-padding-primary; padding: var.$dialog-padding-primary;
color: var(--color-text-base); color: var(--color-text-base);
font-size: var.$dialog-content-font-size;
word-break: break-all;
} }
@include mixins.e(footer) { @include mixins.e(footer) {

View file

@ -5,7 +5,7 @@
@include mixins.e(header) { @include mixins.e(header) {
padding: 0; padding: 0;
position: relative; position: relative;
margin: 0 0 15px; margin: 0;
} }
@include mixins.e(active-bar) { @include mixins.e(active-bar) {
position: absolute; position: absolute;

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-editor-ui", "name": "n8n-editor-ui",
"version": "0.113.0", "version": "0.122.1",
"description": "Workflow Editor UI for n8n", "description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
@ -26,11 +26,12 @@
}, },
"dependencies": { "dependencies": {
"@fontsource/open-sans": "^4.5.0", "@fontsource/open-sans": "^4.5.0",
"n8n-design-system": "~0.5.0", "n8n-design-system": "~0.9.0",
"monaco-editor": "^0.29.1", "monaco-editor": "^0.29.1",
"timeago.js": "^4.0.2", "timeago.js": "^4.0.2",
"v-click-outside": "^3.1.2", "v-click-outside": "^3.1.2",
"vue-fragment": "^1.5.2" "vue-fragment": "^1.5.2",
"vue-i18n": "^8.26.7"
}, },
"devDependencies": { "devDependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.35", "@fortawesome/fontawesome-svg-core": "^1.2.35",
@ -40,6 +41,7 @@
"@types/express": "^4.17.6", "@types/express": "^4.17.6",
"@types/file-saver": "^2.0.1", "@types/file-saver": "^2.0.1",
"@types/jest": "^26.0.13", "@types/jest": "^26.0.13",
"@types/lodash.camelcase": "^4.3.6",
"@types/lodash.get": "^4.4.6", "@types/lodash.get": "^4.4.6",
"@types/lodash.set": "^4.3.6", "@types/lodash.set": "^4.3.6",
"@types/node": "14.17.27", "@types/node": "14.17.27",
@ -69,10 +71,11 @@
"jquery": "^3.4.1", "jquery": "^3.4.1",
"jshint": "^2.9.7", "jshint": "^2.9.7",
"jsplumb": "2.15.4", "jsplumb": "2.15.4",
"lodash.camelcase": "^4.3.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.get": "^4.4.2", "lodash.get": "^4.4.2",
"lodash.set": "^4.3.2", "lodash.set": "^4.3.2",
"n8n-workflow": "~0.73.0", "n8n-workflow": "~0.80.0",
"monaco-editor-webpack-plugin": "^5.0.0", "monaco-editor-webpack-plugin": "^5.0.0",
"normalize-wheel": "^1.0.1", "normalize-wheel": "^1.0.1",
"prismjs": "^1.17.1", "prismjs": "^1.17.1",

View file

@ -14,14 +14,20 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue';
import Telemetry from './components/Telemetry.vue'; import Telemetry from './components/Telemetry.vue';
export default { export default Vue.extend({
name: 'App', name: 'App',
components: { components: {
Telemetry, Telemetry,
}, },
}; watch: {
'$route'(route) {
this.$telemetry.page('Editor', route.name);
},
},
});
</script> </script>
<style lang="scss"> <style lang="scss">

View file

@ -22,32 +22,72 @@ import {
WorkflowExecuteMode, WorkflowExecuteMode,
} from 'n8n-workflow'; } from 'n8n-workflow';
import {
PaintStyle,
} from 'jsplumb';
declare module 'jsplumb' { declare module 'jsplumb' {
interface PaintStyle {
stroke?: string;
fill?: string;
strokeWidth?: number;
outlineStroke?: string;
outlineWidth?: number;
}
interface Anchor { interface Anchor {
lastReturnValue: number[]; lastReturnValue: number[];
} }
interface Connection { interface Connection {
__meta?: {
sourceNodeName: string,
sourceOutputIndex: number,
targetNodeName: string,
targetOutputIndex: number,
};
canvas?: HTMLElement;
connector?: {
setTargetEndpoint: (endpoint: Endpoint) => void;
resetTargetEndpoint: () => void;
bounds: {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
};
// bind(event: string, (connection: Connection): void;): void; // tslint:disable-line:no-any // bind(event: string, (connection: Connection): void;): void; // tslint:disable-line:no-any
bind(event: string, callback: Function): void; // tslint:disable-line:no-any bind(event: string, callback: Function): void;
removeOverlay(name: string): void; removeOverlay(name: string): void;
removeOverlays(): void; removeOverlays(): void;
setParameter(name: string, value: any): void; // tslint:disable-line:no-any setParameter(name: string, value: any): void; // tslint:disable-line:no-any
setPaintStyle(arg0: PaintStyle): void; setPaintStyle(arg0: PaintStyle): void;
addOverlay(arg0: any[]): void; // tslint:disable-line:no-any addOverlay(arg0: any[]): void; // tslint:disable-line:no-any
setConnector(arg0: any[]): void; // tslint:disable-line:no-any setConnector(arg0: any[]): void; // tslint:disable-line:no-any
getUuids(): [string, string];
} }
interface Endpoint { interface Endpoint {
endpoint: any; // tslint:disable-line:no-any
elementId: string;
__meta?: {
nodeName: string,
nodeId: string,
index: number,
totalEndpoints: number;
};
getUuid(): string;
getOverlay(name: string): any; // tslint:disable-line:no-any getOverlay(name: string): any; // tslint:disable-line:no-any
repaint(params?: object): void;
}
interface N8nPlusEndpoint extends Endpoint {
setSuccessOutput(message: string): void;
clearSuccessOutput(): void;
} }
interface Overlay { interface Overlay {
setVisible(visible: boolean): void; setVisible(visible: boolean): void;
setLocation(location: number): void;
canvas?: HTMLElement;
} }
interface OnConnectionBindInfo { interface OnConnectionBindInfo {
@ -66,18 +106,15 @@ export interface IEndpointOptions {
dragProxy?: any; // tslint:disable-line:no-any dragProxy?: any; // tslint:disable-line:no-any
endpoint?: string; endpoint?: string;
endpointStyle?: object; endpointStyle?: object;
endpointHoverStyle?: object;
isSource?: boolean; isSource?: boolean;
isTarget?: boolean; isTarget?: boolean;
maxConnections?: number; maxConnections?: number;
overlays?: any; // tslint:disable-line:no-any overlays?: any; // tslint:disable-line:no-any
parameters?: any; // tslint:disable-line:no-any parameters?: any; // tslint:disable-line:no-any
uuid?: string; uuid?: string;
} enabled?: boolean;
cssClass?: string;
export interface IConnectionsUi {
[key: string]: {
[key: string]: IEndpointOptions;
};
} }
export interface IUpdateInformation { export interface IUpdateInformation {
@ -95,20 +132,16 @@ export interface INodeUpdatePropertiesInformation {
}; };
} }
export type XYPositon = [number, number]; export type XYPosition = [number, number];
export type MessageType = 'success' | 'warning' | 'info' | 'error'; export type MessageType = 'success' | 'warning' | 'info' | 'error';
export interface INodeUi extends INode { export interface INodeUi extends INode {
position: XYPositon; position: XYPosition;
color?: string; color?: string;
notes?: string; notes?: string;
issues?: INodeIssues; issues?: INodeIssues;
_jsPlumb?: { name: string;
endpoints?: {
[key: string]: IEndpointOptions[];
};
};
} }
export interface INodeTypesMaxCount { export interface INodeTypesMaxCount {
@ -130,6 +163,7 @@ export interface IRestApi {
getPastExecutions(filter: object, limit: number, lastId?: string | number, firstId?: string | number): Promise<IExecutionsListResponse>; getPastExecutions(filter: object, limit: number, lastId?: string | number, firstId?: string | number): Promise<IExecutionsListResponse>;
stopCurrentExecution(executionId: string): Promise<IExecutionsStopData>; stopCurrentExecution(executionId: string): Promise<IExecutionsStopData>;
makeRestApiRequest(method: string, endpoint: string, data?: any): Promise<any>; // tslint:disable-line:no-any makeRestApiRequest(method: string, endpoint: string, data?: any): Promise<any>; // tslint:disable-line:no-any
getNodeTranslationHeaders(): Promise<INodeTranslationHeaders>;
getNodeTypes(onlyLatest?: boolean): Promise<INodeTypeDescription[]>; getNodeTypes(onlyLatest?: boolean): Promise<INodeTypeDescription[]>;
getNodesInformation(nodeInfos: INodeTypeNameVersion[]): Promise<INodeTypeDescription[]>; getNodesInformation(nodeInfos: INodeTypeNameVersion[]): Promise<INodeTypeDescription[]>;
getNodeParameterOptions(nodeTypeAndVersion: INodeTypeNameVersion, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise<INodePropertyOptions[]>; getNodeParameterOptions(nodeTypeAndVersion: INodeTypeNameVersion, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise<INodePropertyOptions[]>;
@ -147,6 +181,15 @@ export interface IRestApi {
getTimezones(): Promise<IDataObject>; getTimezones(): Promise<IDataObject>;
} }
export interface INodeTranslationHeaders {
data: {
[key: string]: {
displayName: string;
description: string;
},
};
}
export interface IBinaryDisplayData { export interface IBinaryDisplayData {
index: number; index: number;
key: string; key: string;
@ -428,7 +471,7 @@ export interface IPushDataTestWebhook {
export interface IPushDataConsoleMessage { export interface IPushDataConsoleMessage {
source: string; source: string;
message: string; messages: string[];
} }
export interface IVersionNotificationSettings { export interface IVersionNotificationSettings {
@ -437,10 +480,15 @@ export interface IVersionNotificationSettings {
infoUrl: string; infoUrl: string;
} }
export type IPersonalizationSurveyKeys = 'companySize' | 'codingSkill' | 'workArea' | 'otherWorkArea'; export type IPersonalizationSurveyKeys = 'codingSkill' | 'companyIndustry' | 'companySize' | 'otherCompanyIndustry' | 'otherWorkArea' | 'workArea';
export type IPersonalizationSurveyAnswers = { export type IPersonalizationSurveyAnswers = {
[key in IPersonalizationSurveyKeys]: string | null codingSkill: string | null;
companyIndustry: string[];
companySize: string | null;
otherCompanyIndustry: string | null;
otherWorkArea: string | null;
workArea: string[] | string | null;
}; };
export interface IPersonalizationSurvey { export interface IPersonalizationSurvey {
@ -448,6 +496,21 @@ export interface IPersonalizationSurvey {
shouldShow: boolean; shouldShow: boolean;
} }
export interface IN8nPrompts {
message: string;
title: string;
showContactPrompt: boolean;
showValueSurvey: boolean;
}
export interface IN8nValueSurveyData {
[key: string]: string;
}
export interface IN8nPromptResponse {
updated: boolean;
}
export interface IN8nUISettings { export interface IN8nUISettings {
endpointWebhook: string; endpointWebhook: string;
endpointWebhookTest: string; endpointWebhookTest: string;
@ -470,6 +533,7 @@ export interface IN8nUISettings {
instanceId: string; instanceId: string;
personalizationSurvey?: IPersonalizationSurvey; personalizationSurvey?: IPersonalizationSurvey;
telemetry: ITelemetrySettings; telemetry: ITelemetrySettings;
defaultLocale: string;
} }
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow { export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
@ -583,6 +647,8 @@ export interface IRootState {
activeActions: string[]; activeActions: string[];
activeNode: string | null; activeNode: string | null;
baseUrl: string; baseUrl: string;
credentialTextRenderKeys: { nodeType: string; credentialType: string; } | null;
defaultLocale: string;
endpointWebhook: string; endpointWebhook: string;
endpointWebhookTest: string; endpointWebhookTest: string;
executionId: string | null; executionId: string | null;
@ -604,7 +670,7 @@ export interface IRootState {
lastSelectedNodeOutputIndex: number | null; lastSelectedNodeOutputIndex: number | null;
nodeIndex: Array<string | null>; nodeIndex: Array<string | null>;
nodeTypes: INodeTypeDescription[]; nodeTypes: INodeTypeDescription[];
nodeViewOffsetPosition: XYPositon; nodeViewOffsetPosition: XYPosition;
nodeViewMoveInProgress: boolean; nodeViewMoveInProgress: boolean;
selectedNodes: INodeUi[]; selectedNodes: INodeUi[];
sessionId: string; sessionId: string;
@ -652,6 +718,7 @@ export interface IUiState {
export interface ISettingsState { export interface ISettingsState {
settings: IN8nUISettings; settings: IN8nUISettings;
promptsData: IN8nPrompts;
} }
export interface IVersionsState { export interface IVersionsState {
@ -670,5 +737,12 @@ export interface IRestApiContext {
export interface IZoomConfig { export interface IZoomConfig {
scale: number; scale: number;
offset: XYPositon; offset: XYPosition;
}
export interface IBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
} }

View file

@ -93,3 +93,7 @@ export async function makeRestApiRequest(context: IRestApiContext, method: Metho
export async function get(baseURL: string, endpoint: string, params?: IDataObject, headers?: IDataObject) { export async function get(baseURL: string, endpoint: string, params?: IDataObject, headers?: IDataObject) {
return await request({method: 'GET', baseURL, endpoint, headers, data: params}); return await request({method: 'GET', baseURL, endpoint, headers, data: params});
} }
export async function post(baseURL: string, endpoint: string, params?: IDataObject, headers?: IDataObject) {
return await request({method: 'POST', baseURL, endpoint, headers, data: params});
}

View file

@ -1,6 +1,7 @@
import { IDataObject } from 'n8n-workflow'; import { IDataObject } from 'n8n-workflow';
import { IRestApiContext, IN8nUISettings, IPersonalizationSurveyAnswers } from '../Interface'; import { IRestApiContext, IN8nPrompts, IN8nValueSurveyData, IN8nUISettings, IPersonalizationSurveyAnswers } from '../Interface';
import { makeRestApiRequest } from './helpers'; import { makeRestApiRequest, get, post } from './helpers';
import { TEMPLATES_BASE_URL } from '@/constants';
export async function getSettings(context: IRestApiContext): Promise<IN8nUISettings> { export async function getSettings(context: IRestApiContext): Promise<IN8nUISettings> {
return await makeRestApiRequest(context, 'GET', '/settings'); return await makeRestApiRequest(context, 'GET', '/settings');
@ -10,3 +11,15 @@ export async function submitPersonalizationSurvey(context: IRestApiContext, para
await makeRestApiRequest(context, 'POST', '/user-survey', params as unknown as IDataObject); await makeRestApiRequest(context, 'POST', '/user-survey', params as unknown as IDataObject);
} }
export async function getPromptsData(instanceId: string): Promise<IN8nPrompts> {
return await get(TEMPLATES_BASE_URL, '/prompts', {}, {'n8n-instance-id': instanceId});
}
export async function submitContactInfo(instanceId: string, email: string): Promise<void> {
return await post(TEMPLATES_BASE_URL, '/prompt', { email }, {'n8n-instance-id': instanceId});
}
export async function submitValueSurvey(instanceId: string, params: IN8nValueSurveyData): Promise<IN8nPrompts> {
return await post(TEMPLATES_BASE_URL, '/value-survey', params, {'n8n-instance-id': instanceId});
}

View file

@ -1,10 +1,10 @@
<template> <template>
<span> <span>
<el-dialog class="n8n-about" :visible="dialogVisible" append-to-body width="50%" title="About n8n" :before-close="closeDialog"> <el-dialog class="n8n-about" :visible="dialogVisible" append-to-body width="50%" :title="$locale.baseText('about.aboutN8n')" :before-close="closeDialog">
<div> <div>
<el-row> <el-row>
<el-col :span="8" class="info-name"> <el-col :span="8" class="info-name">
n8n Version: {{ $locale.baseText('about.n8nVersion') }}
</el-col> </el-col>
<el-col :span="16"> <el-col :span="16">
{{ versionCli }} {{ versionCli }}
@ -12,7 +12,7 @@
</el-row> </el-row>
<el-row> <el-row>
<el-col :span="8" class="info-name"> <el-col :span="8" class="info-name">
Source Code: {{ $locale.baseText('about.sourceCode') }}
</el-col> </el-col>
<el-col :span="16"> <el-col :span="16">
<a href="https://github.com/n8n-io/n8n" target="_blank">https://github.com/n8n-io/n8n</a> <a href="https://github.com/n8n-io/n8n" target="_blank">https://github.com/n8n-io/n8n</a>
@ -20,15 +20,17 @@
</el-row> </el-row>
<el-row> <el-row>
<el-col :span="8" class="info-name"> <el-col :span="8" class="info-name">
License: {{ $locale.baseText('about.license') }}
</el-col> </el-col>
<el-col :span="16"> <el-col :span="16">
<a href="https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md" target="_blank">Apache 2.0 with Commons Clause</a> <a href="https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md" target="_blank">
{{ $locale.baseText('about.apacheWithCommons20Clause') }}
</a>
</el-col> </el-col>
</el-row> </el-row>
<div class="action-buttons"> <div class="action-buttons">
<n8n-button @click="closeDialog" label="Close" /> <n8n-button @click="closeDialog" :label="$locale.baseText('about.close')" />
</div> </div>
</div> </div>
</el-dialog> </el-dialog>
@ -67,6 +69,7 @@ export default mixins(
<style scoped lang="scss"> <style scoped lang="scss">
.n8n-about { .n8n-about {
font-size: var(--font-size-s);
.el-row { .el-row {
padding: 0.25em 0; padding: 0.25em 0;
} }

View file

@ -4,18 +4,18 @@
@click.stop="closeWindow" @click.stop="closeWindow"
size="small" size="small"
class="binary-data-window-back" class="binary-data-window-back"
title="Back to overview page" :title="$locale.baseText('binaryDataDisplay.backToOverviewPage')"
icon="arrow-left" icon="arrow-left"
label="Back to list" :label="$locale.baseText('binaryDataDisplay.backToList')"
/> />
<div class="binary-data-window-wrapper"> <div class="binary-data-window-wrapper">
<div v-if="!binaryData"> <div v-if="!binaryData">
Data to display did not get found {{ $locale.baseText('binaryDataDisplay.noDataFoundToDisplay') }}
</div> </div>
<video v-else-if="binaryData.mimeType && binaryData.mimeType.startsWith('video/')" controls autoplay> <video v-else-if="binaryData.mimeType && binaryData.mimeType.startsWith('video/')" controls autoplay>
<source :src="'data:' + binaryData.mimeType + ';base64,' + binaryData.data" :type="binaryData.mimeType"> <source :src="'data:' + binaryData.mimeType + ';base64,' + binaryData.data" :type="binaryData.mimeType">
Your browser does not support the video element. Kindly update it to latest version. {{ $locale.baseText('binaryDataDisplay.yourBrowserDoesNotSupport') }}
</video> </video>
<embed v-else :src="'data:' + binaryData.mimeType + ';base64,' + binaryData.data" class="binary-data" :class="embedClass"/> <embed v-else :src="'data:' + binaryData.mimeType + ';base64,' + binaryData.data" class="binary-data" :class="embedClass"/>
</div> </div>

View file

@ -4,7 +4,7 @@
append-to-body append-to-body
:close-on-click-modal="false" :close-on-click-modal="false"
width="80%" width="80%"
:title="`Edit ${parameter.displayName}`" :title="`${$locale.baseText('codeEdit.edit')} ${$locale.nodeText().topParameterDisplayName(parameter)}`"
:before-close="closeDialog" :before-close="closeDialog"
> >
<div class="text-editor-wrapper ignore-key-press"> <div class="text-editor-wrapper ignore-key-press">

View file

@ -2,10 +2,10 @@
<div @keydown.stop class="collection-parameter"> <div @keydown.stop class="collection-parameter">
<div class="collection-parameter-wrapper"> <div class="collection-parameter-wrapper">
<div v-if="getProperties.length === 0" class="no-items-exist"> <div v-if="getProperties.length === 0" class="no-items-exist">
Currently no properties exist <n8n-text size="small">{{ $locale.baseText('collectionParameter.noProperties') }}</n8n-text>
</div> </div>
<parameter-input-list :parameters="getProperties" :nodeValues="nodeValues" :path="path" :hideDelete="hideDelete" @valueChanged="valueChanged" /> <parameter-input-list :parameters="getProperties" :nodeValues="nodeValues" :path="path" :hideDelete="hideDelete" :indent="true" @valueChanged="valueChanged" />
<div v-if="parameterOptions.length > 0 && !isReadOnly" class="param-options"> <div v-if="parameterOptions.length > 0 && !isReadOnly" class="param-options">
<n8n-button <n8n-button
@ -19,7 +19,7 @@
<n8n-option <n8n-option
v-for="item in parameterOptions" v-for="item in parameterOptions"
:key="item.name" :key="item.name"
:label="item.displayName" :label="$locale.nodeText().collectionOptionDisplayName(parameter, item)"
:value="item.name"> :value="item.name">
</n8n-option> </n8n-option>
</n8n-select> </n8n-select>
@ -67,7 +67,8 @@ export default mixins(
}, },
computed: { computed: {
getPlaceholderText (): string { getPlaceholderText (): string {
return this.parameter.placeholder ? this.parameter.placeholder : 'Choose Option To Add'; const placeholder = this.$locale.nodeText().placeholder(this.parameter);
return placeholder ? placeholder : this.$locale.baseText('collectionParameter.choose');
}, },
getProperties (): INodeProperties[] { getProperties (): INodeProperties[] {
const returnProperties = []; const returnProperties = [];
@ -184,14 +185,14 @@ export default mixins(
<style lang="scss"> <style lang="scss">
.collection-parameter { .collection-parameter {
padding-left: 2em; padding-left: var(--spacing-s);
.param-options { .param-options {
padding-top: 0.5em; margin-top: var(--spacing-xs);
} }
.no-items-exist { .no-items-exist {
margin: 0.8em 0 0.4em 0; margin: var(--spacing-xs) 0;
} }
.option { .option {
position: relative; position: relative;

View file

@ -0,0 +1,128 @@
<template>
<Modal
:name="modalName"
:eventBus="modalBus"
:center="true"
:closeOnPressEscape="false"
:beforeClose="closeDialog"
customClass="contact-prompt-modal"
width="460px"
>
<template slot="header">
<n8n-heading tag="h2" size="xlarge" color="text-dark">{{ title }}</n8n-heading>
</template>
<template v-slot:content>
<div :class="$style.description">
<n8n-text size="medium" color="text-base">{{ description }}</n8n-text>
</div>
<div @keyup.enter="send">
<n8n-input v-model="email" placeholder="Your email address" />
</div>
<div :class="$style.disclaimer">
<n8n-text size="small" color="text-base"
>David from our product team will get in touch personally</n8n-text
>
</div>
</template>
<template v-slot:footer>
<div :class="$style.footer">
<n8n-button label="Send" float="right" @click="send" :disabled="!isEmailValid" />
</div>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import { IN8nPromptResponse } from '@/Interface';
import { VALID_EMAIL_REGEX } from '@/constants';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import Modal from './Modal.vue';
export default mixins(workflowHelpers).extend({
components: { Modal },
name: 'ContactPromptModal',
props: ['modalName'],
data() {
return {
email: '',
modalBus: new Vue(),
};
},
computed: {
...mapGetters({
promptsData: 'settings/getPromptsData',
}),
title(): string {
if (this.promptsData && this.promptsData.title) {
return this.promptsData.title;
}
return 'Youre a power user 💪';
},
description(): string {
if (this.promptsData && this.promptsData.message) {
return this.promptsData.message;
}
return 'Your experience with n8n can help us improve — for you and our entire community.';
},
isEmailValid(): boolean {
return VALID_EMAIL_REGEX.test(String(this.email).toLowerCase());
},
},
methods: {
closeDialog(): void {
this.$telemetry.track('User closed email modal', {
instance_id: this.$store.getters.instanceId,
email: null,
});
this.$store.commit('ui/closeTopModal');
},
async send() {
if (this.isEmailValid) {
const response: IN8nPromptResponse = await this.$store.dispatch(
'settings/submitContactInfo',
this.email,
);
if (response.updated) {
this.$telemetry.track('User closed email modal', {
instance_id: this.$store.getters.instanceId,
email: this.email,
});
this.$showMessage({
title: 'Thanks!',
message: "It's people like you that help make n8n better",
type: 'success',
});
}
this.$store.commit('ui/closeTopModal');
}
},
},
});
</script>
<style lang="scss" module>
.description {
margin-bottom: var(--spacing-s);
}
.disclaimer {
margin-top: var(--spacing-4xs);
}
</style>
<style lang="scss">
.dialog-wrapper {
.contact-prompt-modal {
.el-dialog__body {
padding: 16px 24px 24px;
}
}
}
</style>

View file

@ -38,7 +38,7 @@ export default mixins(copyPaste, showMessage).extend({
this.copyToClipboard(this.$props.copyContent); this.copyToClipboard(this.$props.copyContent);
this.$showMessage({ this.$showMessage({
title: 'Copied', title: this.$locale.baseText('credentialsEdit.showMessage.title'),
message: this.$props.successMessage, message: this.$props.successMessage,
type: 'success', type: 'success',
}); });
@ -53,6 +53,7 @@ export default mixins(copyPaste, showMessage).extend({
span { span {
font-family: Monaco, Consolas; font-family: Monaco, Consolas;
line-height: 1.5; line-height: 1.5;
font-size: var(--font-size-s);
} }
padding: var(--spacing-xs); padding: var(--spacing-xs);

View file

@ -3,17 +3,17 @@
<banner <banner
v-show="showValidationWarning" v-show="showValidationWarning"
theme="danger" theme="danger"
message="Please check the errors below" :message="$locale.baseText('credentialEdit.credentialConfig.pleaseCheckTheErrorsBelow')"
/> />
<banner <banner
v-if="authError && !showValidationWarning" v-if="authError && !showValidationWarning"
theme="danger" theme="danger"
message="Couldnt connect with these settings" :message="$locale.baseText('credentialEdit.credentialConfig.couldntConnectWithTheseSettings')"
:details="authError" :details="authError"
buttonLabel="Retry" :buttonLabel="$locale.baseText('credentialEdit.credentialConfig.retry')"
buttonLoadingLabel="Retrying" buttonLoadingLabel="Retrying"
buttonTitle="Retry credentials test" :buttonTitle="$locale.baseText('credentialEdit.credentialConfig.retryCredentialTest')"
:buttonLoading="isRetesting" :buttonLoading="isRetesting"
@click="$emit('retest')" @click="$emit('retest')"
/> />
@ -21,35 +21,37 @@
<banner <banner
v-show="showOAuthSuccessBanner && !showValidationWarning" v-show="showOAuthSuccessBanner && !showValidationWarning"
theme="success" theme="success"
message="Account connected" :message="$locale.baseText('credentialEdit.credentialConfig.accountConnected')"
buttonLabel="Reconnect" :buttonLabel="$locale.baseText('credentialEdit.credentialConfig.reconnect')"
buttonTitle="Reconnect OAuth Credentials" :buttonTitle="$locale.baseText('credentialEdit.credentialConfig.reconnectOAuth2Credential')"
@click="$emit('oauth')" @click="$emit('oauth')"
/> />
<banner <banner
v-show="testedSuccessfully && !showValidationWarning" v-show="testedSuccessfully && !showValidationWarning"
theme="success" theme="success"
message="Connection tested successfully" :message="$locale.baseText('credentialEdit.credentialConfig.connectionTestedSuccessfully')"
buttonLabel="Retry" :buttonLabel="$locale.baseText('credentialEdit.credentialConfig.retry')"
buttonLoadingLabel="Retrying" :buttonLoadingLabel="$locale.baseText('credentialEdit.credentialConfig.retrying')"
buttonTitle="Retry credentials test" :buttonTitle="$locale.baseText('credentialEdit.credentialConfig.retryCredentialTest')"
:buttonLoading="isRetesting" :buttonLoading="isRetesting"
@click="$emit('retest')" @click="$emit('retest')"
/> />
<n8n-info-tip v-if="documentationUrl && credentialProperties.length"> <n8n-info-tip v-if="documentationUrl && credentialProperties.length">
Need help filling out these fields? {{ $locale.baseText('credentialEdit.credentialConfig.needHelpFillingOutTheseFields') }}
<a :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">Open docs</a> <a :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">
{{ $locale.baseText('credentialEdit.credentialConfig.openDocs') }}
</a>
</n8n-info-tip> </n8n-info-tip>
<CopyInput <CopyInput
v-if="isOAuthType && credentialProperties.length" v-if="isOAuthType && credentialProperties.length"
label="OAuth Redirect URL" :label="$locale.baseText('credentialEdit.credentialConfig.oAuthRedirectUrl')"
:copyContent="oAuthCallbackUrl" :copyContent="oAuthCallbackUrl"
copyButtonText="Click to copy" :copyButtonText="$locale.baseText('credentialEdit.credentialConfig.clickToCopy')"
:subtitle="`In ${appName}, use the URL above when prompted to enter an OAuth callback or redirect URL`" :subtitle="$locale.baseText('credentialEdit.credentialConfig.subtitle', { interpolate: { appName } })"
successMessage="Redirect URL copied to clipboard" :successMessage="$locale.baseText('credentialEdit.credentialConfig.redirectUrlCopiedToClipboard')"
/> />
<CredentialInputs <CredentialInputs
@ -70,7 +72,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { ICredentialType } from 'n8n-workflow'; import { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
import { getAppNameFromCredType } from '../helpers'; import { getAppNameFromCredType } from '../helpers';
import Vue from 'vue'; import Vue from 'vue';
@ -78,8 +80,11 @@ import Banner from '../Banner.vue';
import CopyInput from '../CopyInput.vue'; import CopyInput from '../CopyInput.vue';
import CredentialInputs from './CredentialInputs.vue'; import CredentialInputs from './CredentialInputs.vue';
import OauthButton from './OauthButton.vue'; import OauthButton from './OauthButton.vue';
import { restApi } from '@/components/mixins/restApi';
import { addNodeTranslation } from '@/plugins/i18n';
import mixins from 'vue-typed-mixins';
export default Vue.extend({ export default mixins(restApi).extend({
name: 'CredentialConfig', name: 'CredentialConfig',
components: { components: {
Banner, Banner,
@ -89,6 +94,7 @@ export default Vue.extend({
}, },
props: { props: {
credentialType: { credentialType: {
type: Object,
}, },
credentialProperties: { credentialProperties: {
type: Array, type: Array,
@ -121,6 +127,12 @@ export default Vue.extend({
type: Boolean, type: Boolean,
}, },
}, },
async beforeMount() {
if (this.$store.getters.defaultLocale !== 'en') {
await this.findCredentialTextRenderKeys();
await this.addNodeTranslationForCredential();
}
},
computed: { computed: {
appName(): string { appName(): string {
if (!this.credentialType) { if (!this.credentialType) {
@ -131,7 +143,7 @@ export default Vue.extend({
(this.credentialType as ICredentialType).displayName, (this.credentialType as ICredentialType).displayName,
); );
return appName || "the service you're connecting to"; return appName || this.$locale.baseText('credentialEdit.credentialConfig.theServiceYouReConnectingTo');
}, },
credentialTypeName(): string { credentialTypeName(): string {
return (this.credentialType as ICredentialType).name; return (this.credentialType as ICredentialType).name;
@ -165,6 +177,57 @@ export default Vue.extend({
}, },
}, },
methods: { methods: {
/**
* Find the keys needed by the mixin to render credential text, and place them in the Vuex store.
*/
async findCredentialTextRenderKeys() {
const nodeTypes = await this.restApi().getNodeTypes();
// credential type name node type name
const map = nodeTypes.reduce<Record<string, string>>((acc, cur) => {
if (!cur.credentials) return acc;
cur.credentials.forEach(cred => {
if (acc[cred.name]) return;
acc[cred.name] = cur.name;
});
return acc;
}, {});
const renderKeys = {
nodeType: map[this.credentialType.name],
credentialType: this.credentialType.name,
};
this.$store.commit('setCredentialTextRenderKeys', renderKeys);
},
/**
* Add to the translation object the node translation for the credential in the modal.
*/
async addNodeTranslationForCredential() {
const { nodeType }: { nodeType: string } = this.$store.getters.credentialTextRenderKeys;
const version = await this.getCurrentNodeVersion(nodeType);
const nodeToBeFetched = [{ name: nodeType, version }];
const nodesInfo = await this.restApi().getNodesInformation(nodeToBeFetched);
const nodeInfo = nodesInfo.pop();
if (nodeInfo && nodeInfo.translation) {
addNodeTranslation(nodeInfo.translation, this.$store.getters.defaultLocale);
}
},
/**
* Get the current version for a node type.
*/
async getCurrentNodeVersion(targetNodeType: string) {
const { allNodeTypes }: { allNodeTypes: INodeTypeDescription[] } = this.$store.getters;
const found = allNodeTypes.find(nodeType => nodeType.name === targetNodeType);
return found ? found.version : 1;
},
onDataChange (event: { name: string; value: string | number | boolean | Date | null }): void { onDataChange (event: { name: string; value: string | number | boolean | Date | null }): void {
this.$emit('change', event); this.$emit('change', event);
}, },

View file

@ -24,8 +24,8 @@
<div :class="$style.credActions"> <div :class="$style.credActions">
<n8n-icon-button <n8n-icon-button
v-if="currentCredential" v-if="currentCredential"
size="medium" size="small"
title="Delete" :title="$locale.baseText('credentialEdit.credentialEdit.delete')"
icon="trash" icon="trash"
type="text" type="text"
:disabled="isSaving" :disabled="isSaving"
@ -36,7 +36,9 @@
v-if="hasUnsavedChanges || credentialId" v-if="hasUnsavedChanges || credentialId"
:saved="!hasUnsavedChanges && !isTesting" :saved="!hasUnsavedChanges && !isTesting"
:isSaving="isSaving || isTesting" :isSaving="isSaving || isTesting"
:savingLabel="isTesting ? 'Testing' : 'Saving'" :savingLabel="isTesting
? $locale.baseText('credentialEdit.credentialEdit.testing')
: $locale.baseText('credentialEdit.credentialEdit.saving')"
@click="saveCredential" @click="saveCredential"
/> />
</div> </div>
@ -53,10 +55,10 @@
:light="true" :light="true"
> >
<n8n-menu-item index="connection" :class="$style.credTab" <n8n-menu-item index="connection" :class="$style.credTab"
><span slot="title">Connection</span></n8n-menu-item ><span slot="title">{{ $locale.baseText('credentialEdit.credentialEdit.connection') }}</span></n8n-menu-item
> >
<n8n-menu-item index="details" :class="$style.credTab" <n8n-menu-item index="details" :class="$style.credTab"
><span slot="title">Details</span></n8n-menu-item ><span slot="title">{{ $locale.baseText('credentialEdit.credentialEdit.details') }}</span></n8n-menu-item
> >
</n8n-menu> </n8n-menu>
</div> </div>
@ -349,20 +351,20 @@ export default mixins(showMessage, nodeHelpers).extend({
if (this.hasUnsavedChanges) { if (this.hasUnsavedChanges) {
const displayName = this.credentialType ? this.credentialType.displayName : ''; const displayName = this.credentialType ? this.credentialType.displayName : '';
keepEditing = await this.confirmMessage( keepEditing = await this.confirmMessage(
`Are you sure you want to throw away the changes you made to the ${displayName} credential?`, this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose1.message', { interpolate: { credentialDisplayName: displayName } }),
'Close without saving?', this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose1.headline'),
null, null,
'Keep editing', this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose1.cancelButtonText'),
'Close', this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose1.confirmButtonText'),
); );
} }
else if (this.isOAuthType && !this.isOAuthConnected) { else if (this.isOAuthType && !this.isOAuthConnected) {
keepEditing = await this.confirmMessage( keepEditing = await this.confirmMessage(
`You need to connect your credential for it to work`, this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose2.message'),
'Close without connecting?', this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose2.headline'),
null, null,
'Keep editing', this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose2.cancelButtonText'),
'Close', this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose2.confirmButtonText'),
); );
} }
@ -400,7 +402,9 @@ export default mixins(showMessage, nodeHelpers).extend({
this.$store.getters['credentials/getCredentialTypeByName'](name); this.$store.getters['credentials/getCredentialTypeByName'](name);
if (!credentialsData) { if (!credentialsData) {
throw new Error(`Could not find credentials of type: ${name}`); throw new Error(
this.$locale.baseText('credentialEdit.credentialEdit.couldNotFindCredentialOfType') + ':' + name,
);
} }
if (credentialsData.extends === undefined) { if (credentialsData.extends === undefined) {
@ -436,7 +440,7 @@ export default mixins(showMessage, nodeHelpers).extend({
}); });
if (!currentCredentials) { if (!currentCredentials) {
throw new Error( throw new Error(
`Could not find the credentials with the id: ${this.credentialId}`, this.$locale.baseText('credentialEdit.credentialEdit.couldNotFindCredentialWithId') + ':' + this.credentialId,
); );
} }
@ -448,11 +452,11 @@ export default mixins(showMessage, nodeHelpers).extend({
this.nodeAccess[access.nodeType] = access; this.nodeAccess[access.nodeType] = access;
}, },
); );
} catch (e) { } catch (error) {
this.$showError( this.$showError(
e, error,
'Problem loading credentials', this.$locale.baseText('credentialEdit.credentialEdit.showError.loadCredential.title'),
'There was a problem loading the credentials:', this.$locale.baseText('credentialEdit.credentialEdit.showError.loadCredential.message'),
); );
this.closeDialog(); this.closeDialog();
@ -657,8 +661,8 @@ export default mixins(showMessage, nodeHelpers).extend({
} catch (error) { } catch (error) {
this.$showError( this.$showError(
error, error,
'Problem creating credentials', this.$locale.baseText('credentialEdit.credentialEdit.showError.createCredential.title'),
'There was a problem creating the credentials:', this.$locale.baseText('credentialEdit.credentialEdit.showError.createCredential.message'),
); );
return null; return null;
@ -686,8 +690,8 @@ export default mixins(showMessage, nodeHelpers).extend({
} catch (error) { } catch (error) {
this.$showError( this.$showError(
error, error,
'Problem updating credentials', this.$locale.baseText('credentialEdit.credentialEdit.showError.updateCredential.title'),
'There was a problem updating the credentials:', this.$locale.baseText('credentialEdit.credentialEdit.showError.updateCredential.message'),
); );
return null; return null;
@ -708,10 +712,10 @@ export default mixins(showMessage, nodeHelpers).extend({
const savedCredentialName = this.currentCredential.name; const savedCredentialName = this.currentCredential.name;
const deleteConfirmed = await this.confirmMessage( const deleteConfirmed = await this.confirmMessage(
`Are you sure you want to delete "${savedCredentialName}" credentials?`, this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.message', { interpolate: { savedCredentialName } }),
'Delete Credentials?', this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.headline'),
null, null,
'Yes, delete!', this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.confirmButtonText'),
); );
if (deleteConfirmed === false) { if (deleteConfirmed === false) {
@ -727,8 +731,8 @@ export default mixins(showMessage, nodeHelpers).extend({
} catch (error) { } catch (error) {
this.$showError( this.$showError(
error, error,
'Problem deleting credentials', this.$locale.baseText('credentialEdit.credentialEdit.showError.deleteCredential.title'),
'There was a problem deleting the credentials:', this.$locale.baseText('credentialEdit.credentialEdit.showError.deleteCredential.message'),
); );
this.isDeleting = false; this.isDeleting = false;
@ -740,8 +744,11 @@ export default mixins(showMessage, nodeHelpers).extend({
this.updateNodesCredentialsIssues(); this.updateNodesCredentialsIssues();
this.$showMessage({ this.$showMessage({
title: 'Credentials deleted', title: this.$locale.baseText('credentialEdit.credentialEdit.showMessage.title'),
message: `The credential "${savedCredentialName}" was deleted!`, message: this.$locale.baseText(
'credentialEdit.credentialEdit.showMessage.message',
{ interpolate: { savedCredentialName } },
),
type: 'success', type: 'success',
}); });
this.closeDialog(); this.closeDialog();
@ -778,8 +785,8 @@ export default mixins(showMessage, nodeHelpers).extend({
} catch (error) { } catch (error) {
this.$showError( this.$showError(
error, error,
'OAuth Authorization Error', this.$locale.baseText('credentialEdit.credentialEdit.showError.generateAuthorizationUrl.title'),
'Error generating authorization URL:', this.$locale.baseText('credentialEdit.credentialEdit.showError.generateAuthorizationUrl.message'),
); );
return; return;

View file

@ -2,7 +2,9 @@
<div :class="$style.container"> <div :class="$style.container">
<el-row> <el-row>
<el-col :span="8" :class="$style.accessLabel"> <el-col :span="8" :class="$style.accessLabel">
<span>Allow use by</span> <n8n-text :compact="true" :bold="true">
{{ $locale.baseText('credentialEdit.credentialInfo.allowUseBy') }}
</n8n-text>
</el-col> </el-col>
<el-col :span="16"> <el-col :span="16">
<div <div
@ -11,7 +13,10 @@
:class="$style.valueLabel" :class="$style.valueLabel"
> >
<el-checkbox <el-checkbox
:label="node.displayName" :label="$locale.headerText({
key: `headers.${shortNodeType(node)}.displayName`,
fallback: node.displayName,
})"
:value="!!nodeAccess[node.name]" :value="!!nodeAccess[node.name]"
@change="(val) => onNodeAccessChange(node.name, val)" @change="(val) => onNodeAccessChange(node.name, val)"
/> />
@ -20,26 +25,32 @@
</el-row> </el-row>
<el-row v-if="currentCredential"> <el-row v-if="currentCredential">
<el-col :span="8" :class="$style.label"> <el-col :span="8" :class="$style.label">
<span>Created</span> <n8n-text :compact="true" :bold="true">
{{ $locale.baseText('credentialEdit.credentialInfo.created') }}
</n8n-text>
</el-col> </el-col>
<el-col :span="16" :class="$style.valueLabel"> <el-col :span="16" :class="$style.valueLabel">
<TimeAgo :date="currentCredential.createdAt" :capitalize="true" /> <n8n-text :compact="true"><TimeAgo :date="currentCredential.createdAt" :capitalize="true" /></n8n-text>
</el-col> </el-col>
</el-row> </el-row>
<el-row v-if="currentCredential"> <el-row v-if="currentCredential">
<el-col :span="8" :class="$style.label"> <el-col :span="8" :class="$style.label">
<span>Last modified</span> <n8n-text :compact="true" :bold="true">
{{ $locale.baseText('credentialEdit.credentialInfo.lastModified') }}
</n8n-text>
</el-col> </el-col>
<el-col :span="16" :class="$style.valueLabel"> <el-col :span="16" :class="$style.valueLabel">
<TimeAgo :date="currentCredential.updatedAt" :capitalize="true" /> <n8n-text :compact="true"><TimeAgo :date="currentCredential.updatedAt" :capitalize="true" /></n8n-text>
</el-col> </el-col>
</el-row> </el-row>
<el-row v-if="currentCredential"> <el-row v-if="currentCredential">
<el-col :span="8" :class="$style.label"> <el-col :span="8" :class="$style.label">
<span>ID</span> <n8n-text :compact="true" :bold="true">
{{ $locale.baseText('credentialEdit.credentialInfo.id') }}
</n8n-text>
</el-col> </el-col>
<el-col :span="16" :class="$style.valueLabel"> <el-col :span="16" :class="$style.valueLabel">
<span>{{currentCredential.id}}</span> <n8n-text :compact="true">{{ currentCredential.id }}</n8n-text>
</el-col> </el-col>
</el-row> </el-row>
</div> </div>
@ -49,6 +60,7 @@
import Vue from 'vue'; import Vue from 'vue';
import TimeAgo from '../TimeAgo.vue'; import TimeAgo from '../TimeAgo.vue';
import { INodeTypeDescription } from 'n8n-workflow';
export default Vue.extend({ export default Vue.extend({
name: 'CredentialInfo', name: 'CredentialInfo',
@ -63,6 +75,9 @@ export default Vue.extend({
value, value,
}); });
}, },
shortNodeType(nodeType: INodeTypeDescription) {
return this.$locale.shortNodeType(nodeType.name);
},
}, },
}); });
</script> </script>

View file

@ -4,12 +4,12 @@
v-if="isGoogleOAuthType" v-if="isGoogleOAuthType"
:src="basePath + 'google-signin-light.png'" :src="basePath + 'google-signin-light.png'"
:class="$style.googleIcon" :class="$style.googleIcon"
alt="Sign in with Google" :alt="$locale.baseText('credentialEdit.oAuthButton.signInWithGoogle')"
@click.stop="$emit('click')" @click.stop="$emit('click')"
/> />
<n8n-button <n8n-button
v-else v-else
label="Connect my account" :label="$locale.baseText('credentialEdit.oAuthButton.connectMyAccount')"
size="large" size="large"
@click.stop="$emit('click')" @click.stop="$emit('click')"
/> />
@ -18,6 +18,7 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import mixins from 'vue-typed-mixins';
export default Vue.extend({ export default Vue.extend({
props: { props: {

View file

@ -2,32 +2,32 @@
<Modal <Modal
:name="CREDENTIAL_LIST_MODAL_KEY" :name="CREDENTIAL_LIST_MODAL_KEY"
width="80%" width="80%"
title="Credentials" :title="$locale.baseText('credentialsList.credentials')"
> >
<template v-slot:content> <template v-slot:content>
<n8n-heading tag="h3" size="small" color="text-light">Your saved credentials:</n8n-heading> <n8n-heading tag="h3" size="small" color="text-light">{{ $locale.baseText('credentialsList.yourSavedCredentials') + ':' }}</n8n-heading>
<div class="new-credentials-button"> <div class="new-credentials-button">
<n8n-button <n8n-button
title="Create New Credentials" :title="$locale.baseText('credentialsList.createNewCredential')"
icon="plus" icon="plus"
label="Add New" :label="$locale.baseText('credentialsList.addNew')"
size="large" size="large"
@click="createCredential()" @click="createCredential()"
/> />
</div> </div>
<el-table :data="credentialsToDisplay" :default-sort = "{prop: 'name', order: 'ascending'}" stripe max-height="450" @row-click="editCredential"> <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="name" :label="$locale.baseText('credentialsList.name')" class-name="clickable" sortable></el-table-column>
<el-table-column property="type" label="Type" class-name="clickable" sortable></el-table-column> <el-table-column property="type" :label="$locale.baseText('credentialsList.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="createdAt" :label="$locale.baseText('credentialsList.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 property="updatedAt" :label="$locale.baseText('credentialsList.updated')" class-name="clickable" sortable></el-table-column>
<el-table-column <el-table-column
label="Operations" :label="$locale.baseText('credentialsList.operations')"
width="120"> width="120">
<template slot-scope="scope"> <template slot-scope="scope">
<div class="cred-operations"> <div class="cred-operations">
<n8n-icon-button title="Edit Credentials" @click.stop="editCredential(scope.row)" icon="pen" /> <n8n-icon-button :title="$locale.baseText('credentialsList.editCredential')" @click.stop="editCredential(scope.row)" size="small" icon="pen" />
<n8n-icon-button title="Delete Credentials" @click.stop="deleteCredential(scope.row)" icon="trash" /> <n8n-icon-button :title="$locale.baseText('credentialsList.deleteCredential')" @click.stop="deleteCredential(scope.row)" size="small" icon="trash" />
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@ -103,7 +103,16 @@ export default mixins(
}, },
async deleteCredential (credential: ICredentialsResponse) { async deleteCredential (credential: ICredentialsResponse) {
const deleteConfirmed = await this.confirmMessage(`Are you sure you want to delete "${credential.name}" credentials?`, 'Delete Credentials?', null, 'Yes, delete!'); const deleteConfirmed = await this.confirmMessage(
this.$locale.baseText(
'credentialsList.confirmMessage.message',
{ interpolate: { credentialName: credential.name }},
),
this.$locale.baseText('credentialsList.confirmMessage.headline'),
null,
this.$locale.baseText('credentialsList.confirmMessage.confirmButtonText'),
this.$locale.baseText('credentialsList.confirmMessage.cancelButtonText'),
);
if (deleteConfirmed === false) { if (deleteConfirmed === false) {
return; return;
@ -112,7 +121,11 @@ export default mixins(
try { try {
await this.$store.dispatch('credentials/deleteCredential', {id: credential.id}); await this.$store.dispatch('credentials/deleteCredential', {id: credential.id});
} catch (error) { } catch (error) {
this.$showError(error, 'Problem deleting credentials', 'There was a problem deleting the credentials:'); this.$showError(
error,
this.$locale.baseText('credentialsList.showError.deleteCredential.title'),
this.$locale.baseText('credentialsList.showError.deleteCredential.message'),
);
return; return;
} }
@ -121,8 +134,11 @@ export default mixins(
this.updateNodesCredentialsIssues(); this.updateNodesCredentialsIssues();
this.$showMessage({ this.$showMessage({
title: 'Credentials deleted', title: this.$locale.baseText('credentialsList.showMessage.title'),
message: `The credential "${credential.name}" was deleted!`, message: this.$locale.baseText(
'credentialsList.showMessage.message',
{ interpolate: { credentialName: credential.name }},
),
type: 'success', type: 'success',
}); });
}, },

View file

@ -7,15 +7,15 @@
maxWidth="460px" maxWidth="460px"
> >
<template slot="header"> <template slot="header">
<h2 :class="$style.title">Add new credential</h2> <h2 :class="$style.title">{{ $locale.baseText('credentialSelectModal.addNewCredential') }}</h2>
</template> </template>
<template slot="content"> <template slot="content">
<div> <div>
<div :class="$style.subtitle">Select an app or service to connect to</div> <div :class="$style.subtitle">{{ $locale.baseText('credentialSelectModal.selectAnAppOrServiceToConnectTo') }}</div>
<n8n-select <n8n-select
filterable filterable
defaultFirstOption defaultFirstOption
placeholder="Search for app..." :placeholder="$locale.baseText('credentialSelectModal.searchForApp')"
size="xlarge" size="xlarge"
ref="select" ref="select"
:value="selected" :value="selected"
@ -35,7 +35,7 @@
<template slot="footer"> <template slot="footer">
<div :class="$style.footer"> <div :class="$style.footer">
<n8n-button <n8n-button
label="Continue" :label="$locale.baseText('credentialSelectModal.continue')"
float="right" float="right"
size="large" size="large"
:disabled="!selected" :disabled="!selected"

View file

@ -3,7 +3,7 @@
:visible="!!node" :visible="!!node"
:before-close="close" :before-close="close"
:custom-class="`classic data-display-wrapper`" :custom-class="`classic data-display-wrapper`"
width="80%" width="85%"
append-to-body append-to-body
@opened="showDocumentHelp = true" @opened="showDocumentHelp = true"
> >
@ -15,7 +15,7 @@
<transition name="fade"> <transition name="fade">
<div v-if="nodeType && showDocumentHelp" class="doc-help-wrapper"> <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"> <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> <title>{{ $locale.baseText('dataDisplay.nodeDocumentation') }}</title>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero"> <g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
<g transform="translate(1117.000000, 825.000000)"> <g transform="translate(1117.000000, 825.000000)">
@ -31,7 +31,7 @@
</svg> </svg>
<div class="text"> <div class="text">
Need help? <a id="doc-hyperlink" :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">Open {{nodeType.displayName}} documentation</a> {{ $locale.baseText('dataDisplay.needHelp') }} <a id="doc-hyperlink" :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">{{ $locale.baseText('dataDisplay.openDocumentationFor', { interpolate: { nodeTypeDisplayName: nodeType.displayName } }) }}</a>
</div> </div>
</div> </div>
</transition> </transition>

View file

@ -1,12 +1,12 @@
<template> <template>
<span class="static-text-wrapper"> <span class="static-text-wrapper">
<span v-show="!editActive" title="Click to change"> <span v-show="!editActive" :title="$locale.baseText('displayWithChange.clickToChange')">
<span class="static-text" @mousedown="startEdit">{{currentValue}}</span> <span class="static-text" @mousedown="startEdit">{{currentValue}}</span>
</span> </span>
<span v-show="editActive"> <span v-show="editActive">
<input class="edit-field" ref="inputField" type="text" v-model="newValue" @keydown.enter.stop.prevent="setValue" @keydown.escape.stop.prevent="cancelEdit" @keydown.stop="noOp" @blur="cancelEdit" /> <input class="edit-field" ref="inputField" type="text" v-model="newValue" @keydown.enter.stop.prevent="setValue" @keydown.escape.stop.prevent="cancelEdit" @keydown.stop="noOp" @blur="cancelEdit" />
<font-awesome-icon icon="times" @mousedown="cancelEdit" class="icons clickable" title="Cancel Edit" /> <font-awesome-icon icon="times" @mousedown="cancelEdit" class="icons clickable" :title="$locale.baseText('displayWithChange.cancelEdit')" />
<font-awesome-icon icon="check" @mousedown="setValue" class="icons clickable" title="Set Value" /> <font-awesome-icon icon="check" @mousedown="setValue" class="icons clickable" :title="$locale.baseText('displayWithChange.setValue')" />
</span> </span>
</span> </span>
</template> </template>
@ -33,6 +33,15 @@ export default mixins(genericHelpers).extend({
return path.split('.').reduce((acc, part) => acc && acc[part], obj); return path.split('.').reduce((acc, part) => acc && acc[part], obj);
}; };
if (this.keyName === 'name' && this.node.type.startsWith('n8n-nodes-base.')) {
const shortNodeType = this.$locale.shortNodeType(this.node.type);
return this.$locale.headerText({
key: `headers.${shortNodeType}.displayName`,
fallback: getDescendantProp(this.node, this.keyName),
});
}
return getDescendantProp(this.node, this.keyName); return getDescendantProp(this.node, this.keyName);
}, },
}, },

View file

@ -3,7 +3,7 @@
:name="modalName" :name="modalName"
:eventBus="modalBus" :eventBus="modalBus"
@enter="save" @enter="save"
title="Duplicate Workflow" :title="$locale.baseText('duplicateWorkflowDialog.duplicateWorkflow')"
:center="true" :center="true"
minWidth="420px" minWidth="420px"
maxWidth="420px" maxWidth="420px"
@ -13,7 +13,7 @@
<n8n-input <n8n-input
v-model="name" v-model="name"
ref="nameInput" ref="nameInput"
placeholder="Enter workflow name" :placeholder="$locale.baseText('duplicateWorkflowDialog.enterWorkflowName')"
:maxlength="MAX_WORKFLOW_NAME_LENGTH" :maxlength="MAX_WORKFLOW_NAME_LENGTH"
/> />
<TagsDropdown <TagsDropdown
@ -23,15 +23,15 @@
@blur="onTagsBlur" @blur="onTagsBlur"
@esc="onTagsEsc" @esc="onTagsEsc"
@update="onTagsUpdate" @update="onTagsUpdate"
placeholder="Choose or create a tag" :placeholder="$locale.baseText('duplicateWorkflowDialog.chooseOrCreateATag')"
ref="dropdown" ref="dropdown"
/> />
</div> </div>
</template> </template>
<template v-slot:footer="{ close }"> <template v-slot:footer="{ close }">
<div :class="$style.footer"> <div :class="$style.footer">
<n8n-button @click="save" :loading="isSaving" label="Save" float="right" /> <n8n-button @click="save" :loading="isSaving" :label="$locale.baseText('duplicateWorkflowDialog.save')" float="right" />
<n8n-button type="outline" @click="close" :disabled="isSaving" label="Cancel" float="right" /> <n8n-button type="outline" @click="close" :disabled="isSaving" :label="$locale.baseText('duplicateWorkflowDialog.cancel')" float="right" />
</div> </div>
</template> </template>
</Modal> </Modal>
@ -101,8 +101,8 @@ export default mixins(showMessage, workflowHelpers).extend({
const name = this.name.trim(); const name = this.name.trim();
if (!name) { if (!name) {
this.$showMessage({ this.$showMessage({
title: "Name missing", title: this.$locale.baseText('duplicateWorkflowDialog.showMessage.title'),
message: `Please enter a name.`, message: this.$locale.baseText('duplicateWorkflowDialog.showMessage.message'),
type: "error", type: "error",
}); });

View file

@ -1,18 +1,18 @@
<template> <template>
<div> <div>
<div class="error-header"> <div class="error-header">
<div class="error-message">ERROR: {{error.message}}</div> <div class="error-message">{{ $locale.baseText('nodeErrorView.error') + ':' + error.message }}</div>
<div class="error-description" v-if="error.description">{{error.description}}</div> <div class="error-description" v-if="error.description">{{error.description}}</div>
</div> </div>
<details> <details>
<summary class="error-details__summary"> <summary class="error-details__summary">
<font-awesome-icon class="error-details__icon" icon="angle-right" /> Details <font-awesome-icon class="error-details__icon" icon="angle-right" /> {{ $locale.baseText('nodeErrorView.details') }}
</summary> </summary>
<div class="error-details__content"> <div class="error-details__content">
<div v-if="error.timestamp"> <div v-if="error.timestamp">
<el-card class="box-card" shadow="never"> <el-card class="box-card" shadow="never">
<div slot="header" class="clearfix box-card__title"> <div slot="header" class="clearfix box-card__title">
<span>Time</span> <span>{{ $locale.baseText('nodeErrorView.time') }}</span>
</div> </div>
<div> <div>
{{new Date(error.timestamp).toLocaleString()}} {{new Date(error.timestamp).toLocaleString()}}
@ -22,7 +22,7 @@
<div v-if="error.httpCode"> <div v-if="error.httpCode">
<el-card class="box-card" shadow="never"> <el-card class="box-card" shadow="never">
<div slot="header" class="clearfix box-card__title"> <div slot="header" class="clearfix box-card__title">
<span>HTTP-Code</span> <span>{{ $locale.baseText('nodeErrorView.httpCode') }}</span>
</div> </div>
<div> <div>
{{error.httpCode}} {{error.httpCode}}
@ -32,13 +32,13 @@
<div v-if="error.cause"> <div v-if="error.cause">
<el-card class="box-card" shadow="never"> <el-card class="box-card" shadow="never">
<div slot="header" class="clearfix box-card__title"> <div slot="header" class="clearfix box-card__title">
<span>Cause</span> <span>{{ $locale.baseText('nodeErrorView.cause') }}</span>
<br> <br>
<span class="box-card__subtitle">Data below may contain sensitive information. Proceed with caution when sharing.</span> <span class="box-card__subtitle">{{ $locale.baseText('nodeErrorView.dataBelowMayContain') }}</span>
</div> </div>
<div> <div>
<div class="copy-button" v-if="displayCause"> <div class="copy-button" v-if="displayCause">
<n8n-icon-button @click="copyCause" title="Copy to Clipboard" icon="copy" /> <n8n-icon-button @click="copyCause" :title="$locale.baseText('nodeErrorView.copyToClipboard')" icon="copy" />
</div> </div>
<vue-json-pretty <vue-json-pretty
v-if="displayCause" v-if="displayCause"
@ -50,7 +50,7 @@
class="json-data" class="json-data"
/> />
<span v-else> <span v-else>
<font-awesome-icon icon="info-circle" /> The error cause is too large to be displayed. <font-awesome-icon icon="info-circle" />{{ $locale.baseText('nodeErrorView.theErrorCauseIsTooLargeToBeDisplayed') }}
</span> </span>
</div> </div>
</el-card> </el-card>
@ -58,7 +58,7 @@
<div v-if="error.stack"> <div v-if="error.stack">
<el-card class="box-card" shadow="never"> <el-card class="box-card" shadow="never">
<div slot="header" class="clearfix box-card__title"> <div slot="header" class="clearfix box-card__title">
<span>Stack</span> <span>{{ $locale.baseText('nodeErrorView.stack') }}</span>
</div> </div>
<div> <div>
<pre><code>{{error.stack}}</code></pre> <pre><code>{{error.stack}}</code></pre>
@ -103,8 +103,8 @@ export default mixins(
}, },
copySuccess() { copySuccess() {
this.$showMessage({ this.$showMessage({
title: 'Copied to clipboard', title: this.$locale.baseText('nodeErrorView.showMessage.title'),
message: '', message: this.$locale.baseText('nodeErrorView.showMessage.message'),
type: 'info', type: 'info',
}); });
}, },

View file

@ -1,13 +1,13 @@
<template> <template>
<span> <span>
<el-dialog :visible="dialogVisible" append-to-body width="80%" :title="`Workflow Executions ${combinedExecutions.length}/${finishedExecutionsCountEstimated === true ? '~' : ''}${combinedExecutionsCount}`" :before-close="closeDialog"> <el-dialog :visible="dialogVisible" append-to-body width="80%" :title="`${$locale.baseText('executionsList.workflowExecutions')} ${combinedExecutions.length}/${finishedExecutionsCountEstimated === true ? '~' : ''}${combinedExecutionsCount}`" :before-close="closeDialog">
<div class="filters"> <div class="filters">
<el-row> <el-row>
<el-col :span="2" class="filter-headline"> <el-col :span="2" class="filter-headline">
Filters: {{ $locale.baseText('executionsList.filters') }}:
</el-col> </el-col>
<el-col :span="7"> <el-col :span="7">
<n8n-select v-model="filter.workflowId" placeholder="Select Workflow" size="medium" filterable @change="handleFilterChanged"> <n8n-select v-model="filter.workflowId" :placeholder="$locale.baseText('executionsList.selectWorkflow')" size="medium" filterable @change="handleFilterChanged">
<n8n-option <n8n-option
v-for="item in workflows" v-for="item in workflows"
:key="item.id" :key="item.id"
@ -17,7 +17,7 @@
</n8n-select> </n8n-select>
</el-col> </el-col>
<el-col :span="5" :offset="1"> <el-col :span="5" :offset="1">
<n8n-select v-model="filter.status" placeholder="Select Status" size="medium" filterable @change="handleFilterChanged"> <n8n-select v-model="filter.status" :placeholder="$locale.baseText('executionsList.selectStatus')" size="medium" filterable @change="handleFilterChanged">
<n8n-option <n8n-option
v-for="item in statuses" v-for="item in statuses"
:key="item.id" :key="item.id"
@ -27,15 +27,15 @@
</n8n-select> </n8n-select>
</el-col> </el-col>
<el-col :span="4" :offset="5" class="autorefresh"> <el-col :span="4" :offset="5" class="autorefresh">
<el-checkbox v-model="autoRefresh" @change="handleAutoRefreshToggle">Auto refresh</el-checkbox> <el-checkbox v-model="autoRefresh" @change="handleAutoRefreshToggle">{{ $locale.baseText('executionsList.autoRefresh') }}</el-checkbox>
</el-col> </el-col>
</el-row> </el-row>
</div> </div>
<div class="selection-options"> <div class="selection-options">
<span v-if="checkAll === true || isIndeterminate === true"> <span v-if="checkAll === true || isIndeterminate === true">
Selected: {{numSelected}} / <span v-if="finishedExecutionsCountEstimated === true">~</span>{{finishedExecutionsCount}} {{ $locale.baseText('executionsList.selected') }}: {{numSelected}} / <span v-if="finishedExecutionsCountEstimated === true">~</span>{{finishedExecutionsCount}}
<n8n-icon-button title="Delete Selected" icon="trash" size="small" @click="handleDeleteSelected" /> <n8n-icon-button :title="$locale.baseText('executionsList.deleteSelected')" icon="trash" size="mini" @click="handleDeleteSelected" />
</span> </span>
</div> </div>
@ -49,49 +49,47 @@
<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> <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> </template>
</el-table-column> </el-table-column>
<el-table-column property="startedAt" label="Started At / ID" width="205"> <el-table-column property="startedAt" :label="$locale.baseText('executionsList.startedAtId')" width="205">
<template slot-scope="scope"> <template slot-scope="scope">
{{convertToDisplayDate(scope.row.startedAt)}}<br /> {{convertToDisplayDate(scope.row.startedAt)}}<br />
<small v-if="scope.row.id">ID: {{scope.row.id}}</small> <small v-if="scope.row.id">ID: {{scope.row.id}}</small>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column property="workflowName" label="Name"> <el-table-column property="workflowName" :label="$locale.baseText('executionsList.name')">
<template slot-scope="scope"> <template slot-scope="scope">
<span class="workflow-name"> <span class="workflow-name">
{{scope.row.workflowName || '[UNSAVED WORKFLOW]'}} {{ scope.row.workflowName || $locale.baseText('executionsList.unsavedWorkflow') }}
</span> </span>
<span v-if="scope.row.stoppedAt === undefined"> <span v-if="scope.row.stoppedAt === undefined">
(running) ({{ $locale.baseText('executionsList.running') }})
</span> </span>
<span v-if="scope.row.retryOf !== undefined"> <span v-if="scope.row.retryOf !== undefined">
<br /><small>Retry of "{{scope.row.retryOf}}"</small> <br /><small>{{ $locale.baseText('executionsList.retryOf') }} "{{scope.row.retryOf}}"</small>
</span> </span>
<span v-else-if="scope.row.retrySuccessId !== undefined"> <span v-else-if="scope.row.retrySuccessId !== undefined">
<br /><small>Success retry "{{scope.row.retrySuccessId}}"</small> <br /><small>{{ $locale.baseText('executionsList.successRetry') }} "{{scope.row.retrySuccessId}}"</small>
</span> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="Status" width="122" align="center"> <el-table-column :label="$locale.baseText('executionsList.status')" width="122" align="center">
<template slot-scope="scope" align="center"> <template slot-scope="scope" align="center">
<n8n-tooltip placement="top" > <n8n-tooltip placement="top" >
<div slot="content" v-html="statusTooltipText(scope.row)"></div> <div slot="content" v-html="statusTooltipText(scope.row)"></div>
<span class="status-badge running" v-if="scope.row.waitTill"> <span class="status-badge running" v-if="scope.row.waitTill">
Waiting {{ $locale.baseText('executionsList.waiting') }}
</span> </span>
<span class="status-badge running" v-else-if="scope.row.stoppedAt === undefined"> <span class="status-badge running" v-else-if="scope.row.stoppedAt === undefined">
Running {{ $locale.baseText('executionsList.running') }}
</span> </span>
<span class="status-badge success" v-else-if="scope.row.finished"> <span class="status-badge success" v-else-if="scope.row.finished">
Success {{ $locale.baseText('executionsList.success') }}
</span> </span>
<span class="status-badge error" v-else-if="scope.row.stoppedAt !== null"> <span class="status-badge error" v-else-if="scope.row.stoppedAt !== null">
Error {{ $locale.baseText('executionsList.error') }}
</span> </span>
<span class="status-badge warning" v-else> <span class="status-badge warning" v-else>
Unknown {{ $locale.baseText('executionsList.unknown') }}
</span> </span>
</n8n-tooltip> </n8n-tooltip>
@ -101,21 +99,29 @@
v-if="scope.row.stoppedAt !== undefined && !scope.row.finished && scope.row.retryOf === undefined && scope.row.retrySuccessId === undefined && !scope.row.waitTill" v-if="scope.row.stoppedAt !== undefined && !scope.row.finished && scope.row.retryOf === undefined && scope.row.retrySuccessId === undefined && !scope.row.waitTill"
type="light" type="light"
:theme="scope.row.stoppedAt === null ? 'warning': 'danger'" :theme="scope.row.stoppedAt === null ? 'warning': 'danger'"
size="small" size="mini"
title="Retry execution" :title="$locale.baseText('executionsList.retryExecution')"
icon="redo" icon="redo"
/> />
</span> </span>
<el-dropdown-menu slot="dropdown"> <el-dropdown-menu slot="dropdown">
<el-dropdown-item :command="{command: 'currentlySaved', row: scope.row}">Retry with currently saved workflow</el-dropdown-item> <el-dropdown-item :command="{command: 'currentlySaved', row: scope.row}">
<el-dropdown-item :command="{command: 'original', row: scope.row}">Retry with original workflow</el-dropdown-item> {{ $locale.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
</el-dropdown-item>
<el-dropdown-item :command="{command: 'original', row: scope.row}">
{{ $locale.baseText('executionsList.retryWithOriginalworkflow') }}
</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</el-dropdown> </el-dropdown>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column property="mode" label="Mode" width="100" align="center"></el-table-column> <el-table-column property="mode" :label="$locale.baseText('executionsList.mode')" width="100" align="center">
<el-table-column label="Running Time" width="150" align="center"> <template slot-scope="scope">
{{ $locale.baseText(`executionsList.modes.${scope.row.mode}`) }}
</template>
</el-table-column>
<el-table-column :label="$locale.baseText('executionsList.runningTime')" width="150" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<span v-if="scope.row.stoppedAt === undefined"> <span v-if="scope.row.stoppedAt === undefined">
<font-awesome-icon icon="spinner" spin /> <font-awesome-icon icon="spinner" spin />
@ -134,10 +140,10 @@
<template slot-scope="scope"> <template slot-scope="scope">
<div class="actions-container"> <div class="actions-container">
<span v-if="scope.row.stoppedAt === undefined || scope.row.waitTill"> <span v-if="scope.row.stoppedAt === undefined || scope.row.waitTill">
<n8n-icon-button icon="stop" title="Stop Execution" @click.stop="stopExecution(scope.row.id)" :loading="stoppingExecutions.includes(scope.row.id)" /> <n8n-icon-button icon="stop" size="small" :title="$locale.baseText('executionsList.stopExecution')" @click.stop="stopExecution(scope.row.id)" :loading="stoppingExecutions.includes(scope.row.id)" />
</span> </span>
<span v-if="scope.row.stoppedAt !== undefined && scope.row.id" > <span v-if="scope.row.stoppedAt !== undefined && scope.row.id" >
<n8n-icon-button icon="folder-open" title="Open Past Execution" @click.stop="displayExecution(scope.row)" /> <n8n-icon-button icon="folder-open" size="small" :title="$locale.baseText('executionsList.openPastExecution')" @click.stop="(e) => displayExecution(scope.row, e)" />
</span> </span>
</div> </div>
</template> </template>
@ -145,7 +151,7 @@
</el-table> </el-table>
<div class="load-more" v-if="finishedExecutionsCount > finishedExecutions.length || finishedExecutionsCountEstimated === true"> <div class="load-more" v-if="finishedExecutionsCount > finishedExecutions.length || finishedExecutionsCountEstimated === true">
<n8n-button icon="sync" title="Load More" label="Load More" @click="loadMore()" :loading="isDataLoading" /> <n8n-button icon="sync" :title="$locale.baseText('executionsList.loadMore')" :label="$locale.baseText('executionsList.loadMore')" @click="loadMore()" :loading="isDataLoading" />
</div> </div>
</el-dialog> </el-dialog>
@ -224,32 +230,33 @@ export default mixins(
stoppingExecutions: [] as string[], stoppingExecutions: [] as string[],
workflows: [] as IWorkflowShortResponse[], workflows: [] as IWorkflowShortResponse[],
statuses: [
{
id: 'ALL',
name: 'Any Status',
},
{
id: 'error',
name: 'Error',
},
{
id: 'running',
name: 'Running',
},
{
id: 'success',
name: 'Success',
},
{
id: 'waiting',
name: 'Waiting',
},
],
}; };
}, },
computed: { computed: {
statuses () {
return [
{
id: 'ALL',
name: this.$locale.baseText('executionsList.anyStatus'),
},
{
id: 'error',
name: this.$locale.baseText('executionsList.error'),
},
{
id: 'running',
name: this.$locale.baseText('executionsList.running'),
},
{
id: 'success',
name: this.$locale.baseText('executionsList.success'),
},
{
id: 'waiting',
name: this.$locale.baseText('executionsList.waiting'),
},
];
},
activeExecutions (): IExecutionsCurrentSummaryExtended[] { activeExecutions (): IExecutionsCurrentSummaryExtended[] {
return this.$store.getters.getActiveExecutions; return this.$store.getters.getActiveExecutions;
}, },
@ -324,7 +331,14 @@ export default mixins(
return false; return false;
}, },
convertToDisplayDate, convertToDisplayDate,
displayExecution (execution: IExecutionShortResponse) { displayExecution (execution: IExecutionShortResponse, e: PointerEvent) {
if (e.metaKey || e.ctrlKey) {
const route = this.$router.resolve({name: 'ExecutionById', params: {id: execution.id}});
window.open(route.href, '_blank');
return;
}
this.$router.push({ this.$router.push({
name: 'ExecutionById', name: 'ExecutionById',
params: { id: execution.id }, params: { id: execution.id },
@ -356,7 +370,16 @@ export default mixins(
} }
}, },
async handleDeleteSelected () { async handleDeleteSelected () {
const deleteExecutions = await this.confirmMessage(`Are you sure that you want to delete the ${this.numSelected} selected executions?`, 'Delete Executions?', 'warning', 'Yes, delete!'); const deleteExecutions = await this.confirmMessage(
this.$locale.baseText(
'executionsList.confirmMessage.message',
{ interpolate: { numSelected: this.numSelected.toString() }},
),
this.$locale.baseText('executionsList.confirmMessage.headline'),
'warning',
this.$locale.baseText('executionsList.confirmMessage.confirmButtonText'),
this.$locale.baseText('executionsList.confirmMessage.cancelButtonText'),
);
if (deleteExecutions === false) { if (deleteExecutions === false) {
return; return;
@ -377,15 +400,19 @@ export default mixins(
await this.restApi().deleteExecutions(sendData); await this.restApi().deleteExecutions(sendData);
} catch (error) { } catch (error) {
this.isDataLoading = false; this.isDataLoading = false;
this.$showError(error, 'Problem deleting executions', 'There was a problem deleting the executions:'); this.$showError(
error,
this.$locale.baseText('executionsList.showError.handleDeleteSelected.title'),
this.$locale.baseText('executionsList.showError.handleDeleteSelected.message'),
);
return; return;
} }
this.isDataLoading = false; this.isDataLoading = false;
this.$showMessage({ this.$showMessage({
title: 'Execution deleted', title: this.$locale.baseText('executionsList.showMessage.handleDeleteSelected.title'),
message: 'The executions were deleted!', message: this.$locale.baseText('executionsList.showMessage.handleDeleteSelected.message'),
type: 'success', type: 'success',
}); });
@ -536,10 +563,19 @@ export default mixins(
data = await this.restApi().getPastExecutions(filter, this.requestItemsPerRequest, lastId); data = await this.restApi().getPastExecutions(filter, this.requestItemsPerRequest, lastId);
} catch (error) { } catch (error) {
this.isDataLoading = false; this.isDataLoading = false;
this.$showError(error, 'Problem loading workflows', 'There was a problem loading the workflows:'); this.$showError(
error,
this.$locale.baseText('executionsList.showError.loadMore.title'),
this.$locale.baseText('executionsList.showError.loadMore.message') + ':',
);
return; return;
} }
data.results = data.results.map((execution) => {
// @ts-ignore
return { ...execution, mode: execution.mode };
});
this.finishedExecutions.push.apply(this.finishedExecutions, data.results); this.finishedExecutions.push.apply(this.finishedExecutions, data.results);
this.finishedExecutionsCount = data.count; this.finishedExecutionsCount = data.count;
this.finishedExecutionsCountEstimated = data.estimated; this.finishedExecutionsCountEstimated = data.estimated;
@ -562,12 +598,16 @@ export default mixins(
// @ts-ignore // @ts-ignore
workflows.unshift({ workflows.unshift({
id: 'ALL', id: 'ALL',
name: 'All Workflows', name: this.$locale.baseText('executionsList.allWorkflows'),
}); });
Vue.set(this, 'workflows', workflows); Vue.set(this, 'workflows', workflows);
} catch (error) { } catch (error) {
this.$showError(error, 'Problem loading workflows', 'There was a problem loading the workflows:'); this.$showError(
error,
this.$locale.baseText('executionsList.showError.loadWorkflows.title'),
this.$locale.baseText('executionsList.showError.loadWorkflows.message') + ':',
);
} }
}, },
async openDialog () { async openDialog () {
@ -590,21 +630,25 @@ export default mixins(
if (retrySuccessful === true) { if (retrySuccessful === true) {
this.$showMessage({ this.$showMessage({
title: 'Retry successful', title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulTrue.title'),
message: 'The retry was successful!', message: this.$locale.baseText('executionsList.showMessage.retrySuccessfulTrue.message'),
type: 'success', type: 'success',
}); });
} else { } else {
this.$showMessage({ this.$showMessage({
title: 'Retry unsuccessful', title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulFalse.title'),
message: 'The retry was not successful!', message: this.$locale.baseText('executionsList.showMessage.retrySuccessfulFalse.message'),
type: 'error', type: 'error',
}); });
} }
this.isDataLoading = false; this.isDataLoading = false;
} catch (error) { } catch (error) {
this.$showError(error, 'Problem with retry', 'There was a problem with the retry:'); this.$showError(
error,
this.$locale.baseText('executionsList.showError.retryExecution.title'),
this.$locale.baseText('executionsList.showError.retryExecution.message') + ':',
);
this.isDataLoading = false; this.isDataLoading = false;
} }
@ -617,7 +661,11 @@ export default mixins(
const finishedExecutionsPromise = this.loadFinishedExecutions(); const finishedExecutionsPromise = this.loadFinishedExecutions();
await Promise.all([activeExecutionsPromise, finishedExecutionsPromise]); await Promise.all([activeExecutionsPromise, finishedExecutionsPromise]);
} catch (error) { } catch (error) {
this.$showError(error, 'Problem loading', 'There was a problem loading the data:'); this.$showError(
error,
this.$locale.baseText('executionsList.showError.refreshData.title'),
this.$locale.baseText('executionsList.showError.refreshData.message') + ':',
);
} }
this.isDataLoading = false; this.isDataLoading = false;
@ -626,23 +674,41 @@ export default mixins(
if (entry.waitTill) { if (entry.waitTill) {
const waitDate = new Date(entry.waitTill); const waitDate = new Date(entry.waitTill);
if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) { if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) {
return 'The workflow is waiting indefinitely for an incoming webhook call.'; return this.$locale.baseText('executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely');
} }
return `The worklow is waiting till ${waitDate.toLocaleDateString()} ${waitDate.toLocaleTimeString()}.`;
return this.$locale.baseText(
'executionsList.statusTooltipText.theWorkflowIsWaitingTill',
{
interpolate: {
waitDateDate: waitDate.toLocaleDateString(),
waitDateTime: waitDate.toLocaleTimeString(),
},
},
);
} else if (entry.stoppedAt === undefined) { } else if (entry.stoppedAt === undefined) {
return 'The worklow is currently executing.'; return this.$locale.baseText('executionsList.statusTooltipText.theWorkflowIsCurrentlyExecuting');
} else if (entry.finished === true && entry.retryOf !== undefined) { } else if (entry.finished === true && entry.retryOf !== undefined) {
return `The workflow execution was a retry of "${entry.retryOf}" and it was successful.`; return this.$locale.baseText(
'executionsList.statusTooltipText.theWorkflowExecutionWasARetryOfAndItWasSuccessful',
{ interpolate: { entryRetryOf: entry.retryOf }},
);
} else if (entry.finished === true) { } else if (entry.finished === true) {
return 'The worklow execution was successful.'; return this.$locale.baseText('executionsList.statusTooltipText.theWorkflowExecutionWasSuccessful');
} else if (entry.retryOf !== undefined) { } else if (entry.retryOf !== undefined) {
return `The workflow execution was a retry of "${entry.retryOf}" and failed.<br />New retries have to be started from the original execution.`; return this.$locale.baseText(
'executionsList.statusTooltipText.theWorkflowExecutionWasARetryOfAndFailed',
{ interpolate: { entryRetryOf: entry.retryOf }},
);
} else if (entry.retrySuccessId !== undefined) { } else if (entry.retrySuccessId !== undefined) {
return `The workflow execution failed but the retry "${entry.retrySuccessId}" was successful.`; return this.$locale.baseText(
'executionsList.statusTooltipText.theWorkflowExecutionFailedButTheRetryWasSuccessful',
{ interpolate: { entryRetrySuccessId: entry.retrySuccessId }},
);
} else if (entry.stoppedAt === null) { } else if (entry.stoppedAt === null) {
return 'The workflow execution is probably still running but it may have crashed and n8n cannot safely tell. '; return this.$locale.baseText('executionsList.statusTooltipText.theWorkflowExecutionIsProbablyStillRunning');
} else { } else {
return 'The workflow execution failed.'; return this.$locale.baseText('executionsList.statusTooltipText.theWorkflowExecutionFailed');
} }
}, },
async stopExecution (activeExecutionId: string) { async stopExecution (activeExecutionId: string) {
@ -658,14 +724,21 @@ export default mixins(
this.stoppingExecutions.splice(index, 1); this.stoppingExecutions.splice(index, 1);
this.$showMessage({ this.$showMessage({
title: 'Execution stopped', title: this.$locale.baseText('executionsList.showMessage.stopExecution.title'),
message: `The execution with the id "${activeExecutionId}" got stopped!`, message: this.$locale.baseText(
'executionsList.showMessage.stopExecution.message',
{ interpolate: { activeExecutionId } },
),
type: 'success', type: 'success',
}); });
this.refreshData(); this.refreshData();
} catch (error) { } catch (error) {
this.$showError(error, 'Problem stopping execution', 'There was a problem stopping the execuction:'); this.$showError(
error,
this.$locale.baseText('executionsList.showError.stopExecution.title'),
this.$locale.baseText('executionsList.showError.stopExecution.message'),
);
} }
}, },
}, },
@ -711,12 +784,10 @@ export default mixins(
position: relative; position: relative;
display: inline-block; display: inline-block;
padding: 0 10px; padding: 0 10px;
height: 22.6px;
line-height: 22.6px; line-height: 22.6px;
border-radius: 15px; border-radius: 15px;
text-align: center; text-align: center;
font-weight: 400; font-size: var(--font-size-s);
font-size: 12px;
&.error { &.error {
background-color: var(--color-danger-tint-1); background-color: var(--color-danger-tint-1);

View file

@ -1,14 +1,14 @@
<template> <template>
<div v-if="dialogVisible" @keydown.stop> <div v-if="dialogVisible" @keydown.stop>
<el-dialog :visible="dialogVisible" custom-class="expression-dialog classic" 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="$locale.baseText('expressionEdit.editExpression')" :before-close="closeDialog">
<el-row> <el-row>
<el-col :span="8"> <el-col :span="8">
<div class="header-side-menu"> <div class="header-side-menu">
<div class="headline"> <div class="headline">
Edit Expression {{ $locale.baseText('expressionEdit.editExpression') }}
</div> </div>
<div class="sub-headline"> <div class="sub-headline">
Variable Selector {{ $locale.baseText('expressionEdit.variableSelector') }}
</div> </div>
</div> </div>
@ -19,7 +19,7 @@
<el-col :span="16" class="right-side"> <el-col :span="16" class="right-side">
<div class="expression-editor-wrapper"> <div class="expression-editor-wrapper">
<div class="editor-description"> <div class="editor-description">
Expression {{ $locale.baseText('expressionEdit.expression') }}
</div> </div>
<div class="expression-editor"> <div class="expression-editor">
<expression-input :parameter="parameter" ref="inputFieldExpression" rows="8" :value="value" :path="path" @change="valueChanged" @keydown.stop="noOp"></expression-input> <expression-input :parameter="parameter" ref="inputFieldExpression" rows="8" :value="value" :path="path" @change="valueChanged" @keydown.stop="noOp"></expression-input>
@ -28,7 +28,7 @@
<div class="expression-result-wrapper"> <div class="expression-result-wrapper">
<div class="editor-description"> <div class="editor-description">
Result {{ $locale.baseText('expressionEdit.result') }}
</div> </div>
<expression-input :parameter="parameter" resolvedValue="true" ref="expressionResult" rows="8" :value="displayValue" :path="path"></expression-input> <expression-input :parameter="parameter" resolvedValue="true" ref="expressionResult" rows="8" :value="displayValue" :path="path"></expression-input>
</div> </div>
@ -143,6 +143,7 @@ export default mixins(
.el-dialog__body { .el-dialog__body {
padding: 0; padding: 0;
font-size: var(--font-size-s);
} }
.right-side { .right-side {

View file

@ -1,21 +1,24 @@
<template> <template>
<div @keydown.stop class="fixed-collection-parameter"> <div @keydown.stop class="fixed-collection-parameter">
<div v-if="getProperties.length === 0" class="no-items-exist"> <div v-if="getProperties.length === 0" class="no-items-exist">
Currently no items exist <n8n-text size="small">{{ $locale.baseText('fixedCollectionParameter.currentlyNoItemsExist') }}</n8n-text>
</div> </div>
<div v-for="property in getProperties" :key="property.name" class="fixed-collection-parameter-property"> <div v-for="property in getProperties" :key="property.name" class="fixed-collection-parameter-property">
<div v-if="property.displayName === '' || parameter.options.length === 1"></div> <n8n-input-label
<div v-else class="parameter-name" :title="property.displayName">{{property.displayName}}:</div> :label="property.displayName === '' || parameter.options.length === 1 ? '' : $locale.nodeText().topParameterDisplayName(property)"
:underline="true"
:labelHoverableOnly="true"
size="small"
>
<div v-if="multipleValues === true"> <div v-if="multipleValues === true">
<div v-for="(value, index) in values[property.name]" :key="property.name + index" class="parameter-item"> <div v-for="(value, index) in values[property.name]" :key="property.name + index" class="parameter-item">
<div class="parameter-item-wrapper"> <div class="parameter-item-wrapper">
<div class="delete-option" v-if="!isReadOnly"> <div class="delete-option" v-if="!isReadOnly">
<font-awesome-icon icon="trash" class="reset-icon clickable" title="Delete Item" @click="deleteOption(property.name, index)" /> <font-awesome-icon icon="trash" class="reset-icon clickable" :title="$locale.baseText('fixedCollectionParameter.deleteItem')" @click="deleteOption(property.name, index)" />
<div v-if="sortable" class="sort-icon"> <div v-if="sortable" class="sort-icon">
<font-awesome-icon v-if="index !== 0" icon="angle-up" class="clickable" title="Move up" @click="moveOptionUp(property.name, index)" /> <font-awesome-icon v-if="index !== 0" icon="angle-up" class="clickable" :title="$locale.baseText('fixedCollectionParameter.moveUp')" @click="moveOptionUp(property.name, index)" />
<font-awesome-icon v-if="index !== (values[property.name].length -1)" icon="angle-down" class="clickable" title="Move down" @click="moveOptionDown(property.name, index)" /> <font-awesome-icon v-if="index !== (values[property.name].length -1)" icon="angle-down" class="clickable" :title="$locale.baseText('fixedCollectionParameter.moveDown')" @click="moveOptionDown(property.name, index)" />
</div> </div>
</div> </div>
<parameter-input-list :parameters="property.values" :nodeValues="nodeValues" :path="getPropertyPath(property.name, index)" :hideDelete="true" @valueChanged="valueChanged" /> <parameter-input-list :parameters="property.values" :nodeValues="nodeValues" :path="getPropertyPath(property.name, index)" :hideDelete="true" @valueChanged="valueChanged" />
@ -25,11 +28,12 @@
<div v-else class="parameter-item"> <div v-else class="parameter-item">
<div class="parameter-item-wrapper"> <div class="parameter-item-wrapper">
<div class="delete-option" v-if="!isReadOnly"> <div class="delete-option" v-if="!isReadOnly">
<font-awesome-icon icon="trash" class="reset-icon clickable" title="Delete Item" @click="deleteOption(property.name)" /> <font-awesome-icon icon="trash" class="reset-icon clickable" :title="$locale.baseText('fixedCollectionParameter.deleteItem')" @click="deleteOption(property.name)" />
</div> </div>
<parameter-input-list :parameters="property.values" :nodeValues="nodeValues" :path="getPropertyPath(property.name)" class="parameter-item" @valueChanged="valueChanged" :hideDelete="true" /> <parameter-input-list :parameters="property.values" :nodeValues="nodeValues" :path="getPropertyPath(property.name)" class="parameter-item" @valueChanged="valueChanged" :hideDelete="true" />
</div> </div>
</div> </div>
</n8n-input-label>
</div> </div>
<div v-if="parameterOptions.length > 0 && !isReadOnly"> <div v-if="parameterOptions.length > 0 && !isReadOnly">
@ -39,7 +43,7 @@
<n8n-option <n8n-option
v-for="item in parameterOptions" v-for="item in parameterOptions"
:key="item.name" :key="item.name"
:label="item.displayName" :label="$locale.nodeText().collectionOptionDisplayName(parameter, item)"
:value="item.name"> :value="item.name">
</n8n-option> </n8n-option>
</n8n-select> </n8n-select>
@ -81,7 +85,8 @@ export default mixins(genericHelpers)
}, },
computed: { computed: {
getPlaceholderText (): string { getPlaceholderText (): string {
return this.parameter.placeholder ? this.parameter.placeholder : 'Choose Option To Add'; const placeholder = this.$locale.nodeText().placeholder(this.parameter);
return placeholder ? placeholder : this.$locale.baseText('fixedCollectionParameter.choose');
}, },
getProperties (): INodePropertyCollection[] { getProperties (): INodePropertyCollection[] {
const returnProperties = []; const returnProperties = [];
@ -221,16 +226,11 @@ export default mixins(genericHelpers)
<style scoped lang="scss"> <style scoped lang="scss">
.fixed-collection-parameter { .fixed-collection-parameter {
padding: 0 0 0 1em; padding-left: var(--spacing-s);
} }
.fixed-collection-parameter-property { .fixed-collection-parameter-property {
margin: 0.5em 0; margin: var(--spacing-xs) 0;
padding: 0.5em 0;
.parameter-name {
border-bottom: 1px solid #999;
}
} }
.delete-option { .delete-option {
@ -244,28 +244,33 @@ export default mixins(genericHelpers)
height: 100%; height: 100%;
} }
.parameter-item-wrapper:hover > .delete-option { .parameter-item:hover > .parameter-item-wrapper > .delete-option {
display: block; display: block;
} }
.parameter-item { .parameter-item {
position: relative; position: relative;
padding: 0 0 0 1em; padding: 0 0 0 1em;
margin: 0.6em 0 0.5em 0.1em;
+ .parameter-item { + .parameter-item {
.parameter-item-wrapper { .parameter-item-wrapper {
padding-top: 0.5em;
border-top: 1px dashed #999; border-top: 1px dashed #999;
.delete-option {
top: 14px;
}
} }
} }
} }
.no-items-exist { .no-items-exist {
margin: 0.8em 0; margin: var(--spacing-xs) 0;
} }
.sort-icon { .sort-icon {
display: flex;
flex-direction: column;
margin-left: 1px;
margin-top: .5em; margin-top: .5em;
} }
</style> </style>

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="container"> <div class="container">
<span class="title"> <span class="title">
Execution Id: {{ $locale.baseText('executionDetails.executionId') + ':' }}
<span> <span>
<strong>{{ executionId }}</strong <strong>{{ executionId }}</strong
>&nbsp; >&nbsp;
@ -9,23 +9,23 @@
icon="check" icon="check"
class="execution-icon success" class="execution-icon success"
v-if="executionFinished" v-if="executionFinished"
title="Execution was successful" :title="$locale.baseText('executionDetails.executionWasSuccessful')"
/> />
<font-awesome-icon <font-awesome-icon
icon="clock" icon="clock"
class="execution-icon warning" class="execution-icon warning"
v-else-if="executionWaiting" v-else-if="executionWaiting"
title="Execution waiting" :title="$locale.baseText('executionDetails.executionWaiting')"
/> />
<font-awesome-icon <font-awesome-icon
icon="times" icon="times"
class="execution-icon error" class="execution-icon error"
v-else v-else
title="Execution failed" :title="$locale.baseText('executionDetails.executionFailed')"
/> />
</span> </span>
of {{ $locale.baseText('executionDetails.of') }}
<span class="primary-color clickable" title="Open Workflow"> <span class="primary-color clickable" :title="$locale.baseText('executionDetails.openWorkflow')">
<WorkflowNameShort :name="workflowName"> <WorkflowNameShort :name="workflowName">
<template v-slot="{ shortenedName }"> <template v-slot="{ shortenedName }">
<span @click="openWorkflow(workflowExecution.workflowId)"> <span @click="openWorkflow(workflowExecution.workflowId)">
@ -34,7 +34,7 @@
</template> </template>
</WorkflowNameShort> </WorkflowNameShort>
</span> </span>
workflow {{ $locale.baseText('executionDetails.workflow') }}
</span> </span>
<ReadOnly class="read-only" /> <ReadOnly class="read-only" />
</div> </div>
@ -117,4 +117,8 @@ export default mixins(titleChange).extend({
.read-only { .read-only {
align-self: flex-end; align-self: flex-end;
} }
.el-tooltip.read-only div {
max-width: 400px;
}
</style> </style>

View file

@ -1,13 +1,25 @@
<template> <template>
<n8n-tooltip class="primary-color" placement="bottom-end" > <n8n-tooltip class="primary-color" placement="bottom-end" >
<div slot="content"> <div slot="content">
You're viewing the log of a previous execution. You cannot<br /> <span v-html="$locale.baseText('executionDetails.readOnly.youreViewingTheLogOf')"></span>
make changes since this execution already occured. Make changes<br />
to this workflow by clicking on its name on the left.
</div> </div>
<span> <div>
<font-awesome-icon icon="exclamation-triangle" /> <font-awesome-icon icon="exclamation-triangle" />
Read only <span v-html="$locale.baseText('executionDetails.readOnly.readOnly')"></span>
</span> </div>
</n8n-tooltip> </n8n-tooltip>
</template> </template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: "ReadOnly",
});
</script>
<style scoped>
svg {
margin-right: 6px;
}
</style>

View file

@ -33,7 +33,7 @@
@blur="onTagsBlur" @blur="onTagsBlur"
@update="onTagsUpdate" @update="onTagsUpdate"
@esc="onTagsEditEsc" @esc="onTagsEditEsc"
placeholder="Choose or create a tag" :placeholder="$locale.baseText('workflowDetails.chooseOrCreateATag')"
ref="dropdown" ref="dropdown"
class="tags-edit" class="tags-edit"
/> />
@ -46,7 +46,7 @@
class="add-tag clickable" class="add-tag clickable"
@click="onTagsEditEnable" @click="onTagsEditEnable"
> >
+ Add tag + {{ $locale.baseText('workflowDetails.addTag') }}
</span> </span>
</div> </div>
<TagsContainer <TagsContainer
@ -62,7 +62,7 @@
<PushConnectionTracker class="actions"> <PushConnectionTracker class="actions">
<template> <template>
<span class="activator"> <span class="activator">
<span>Active:</span> <span>{{ $locale.baseText('workflowDetails.active') + ':' }}</span>
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" :disabled="!currentWorkflowId"/> <WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" :disabled="!currentWorkflowId"/>
</span> </span>
<SaveButton <SaveButton
@ -140,8 +140,9 @@ export default mixins(workflowHelpers).extend({
}, },
}, },
methods: { methods: {
onSaveButtonClick () { async onSaveButtonClick () {
this.saveCurrentWorkflow(undefined); const saved = await this.saveCurrentWorkflow();
if (saved) this.$store.dispatch('settings/fetchPromptsData');
}, },
onTagsEditEnable() { onTagsEditEnable() {
this.$data.appliedTagIds = this.currentWorkflowTagIds; this.$data.appliedTagIds = this.currentWorkflowTagIds;
@ -196,8 +197,8 @@ export default mixins(workflowHelpers).extend({
const newName = name.trim(); const newName = name.trim();
if (!newName) { if (!newName) {
this.$showMessage({ this.$showMessage({
title: "Name missing", title: this.$locale.baseText('workflowDetails.showMessage.title'),
message: `Please enter a name, or press 'esc' to go back to the old one.`, message: this.$locale.baseText('workflowDetails.showMessage.message'),
type: "error", type: "error",
}); });

View file

@ -22,94 +22,94 @@
<el-submenu index="workflow" title="Workflow" popperClass="sidebar-popper"> <el-submenu index="workflow" title="Workflow" popperClass="sidebar-popper">
<template slot="title"> <template slot="title">
<font-awesome-icon icon="network-wired"/>&nbsp; <font-awesome-icon icon="network-wired"/>&nbsp;
<span slot="title" class="item-title-root">Workflows</span> <span slot="title" class="item-title-root">{{ $locale.baseText('mainSidebar.workflows') }}</span>
</template> </template>
<n8n-menu-item index="workflow-new"> <n8n-menu-item index="workflow-new">
<template slot="title"> <template slot="title">
<font-awesome-icon icon="file"/>&nbsp; <font-awesome-icon icon="file"/>&nbsp;
<span slot="title" class="item-title">New</span> <span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.new') }}</span>
</template> </template>
</n8n-menu-item> </n8n-menu-item>
<n8n-menu-item index="workflow-open"> <n8n-menu-item index="workflow-open">
<template slot="title"> <template slot="title">
<font-awesome-icon icon="folder-open"/>&nbsp; <font-awesome-icon icon="folder-open"/>&nbsp;
<span slot="title" class="item-title">Open</span> <span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.open') }}</span>
</template> </template>
</n8n-menu-item> </n8n-menu-item>
<n8n-menu-item index="workflow-save"> <n8n-menu-item index="workflow-save">
<template slot="title"> <template slot="title">
<font-awesome-icon icon="save"/> <font-awesome-icon icon="save"/>
<span slot="title" class="item-title">Save</span> <span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.save') }}</span>
</template> </template>
</n8n-menu-item> </n8n-menu-item>
<n8n-menu-item index="workflow-duplicate" :disabled="!currentWorkflow"> <n8n-menu-item index="workflow-duplicate" :disabled="!currentWorkflow">
<template slot="title"> <template slot="title">
<font-awesome-icon icon="copy"/> <font-awesome-icon icon="copy"/>
<span slot="title" class="item-title">Duplicate</span> <span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.duplicate') }}</span>
</template> </template>
</n8n-menu-item> </n8n-menu-item>
<n8n-menu-item index="workflow-delete" :disabled="!currentWorkflow"> <n8n-menu-item index="workflow-delete" :disabled="!currentWorkflow">
<template slot="title"> <template slot="title">
<font-awesome-icon icon="trash"/> <font-awesome-icon icon="trash"/>
<span slot="title" class="item-title">Delete</span> <span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.delete') }}</span>
</template> </template>
</n8n-menu-item> </n8n-menu-item>
<n8n-menu-item index="workflow-download"> <n8n-menu-item index="workflow-download">
<template slot="title"> <template slot="title">
<font-awesome-icon icon="file-download"/> <font-awesome-icon icon="file-download"/>
<span slot="title" class="item-title">Download</span> <span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.download') }}</span>
</template> </template>
</n8n-menu-item> </n8n-menu-item>
<n8n-menu-item index="workflow-import-url"> <n8n-menu-item index="workflow-import-url">
<template slot="title"> <template slot="title">
<font-awesome-icon icon="cloud"/> <font-awesome-icon icon="cloud"/>
<span slot="title" class="item-title">Import from URL</span> <span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.importFromUrl') }}</span>
</template> </template>
</n8n-menu-item> </n8n-menu-item>
<n8n-menu-item index="workflow-import-file"> <n8n-menu-item index="workflow-import-file">
<template slot="title"> <template slot="title">
<font-awesome-icon icon="hdd"/> <font-awesome-icon icon="hdd"/>
<span slot="title" class="item-title">Import from File</span> <span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.importFromFile') }}</span>
</template> </template>
</n8n-menu-item> </n8n-menu-item>
<n8n-menu-item index="workflow-settings" :disabled="!currentWorkflow"> <n8n-menu-item index="workflow-settings" :disabled="!currentWorkflow">
<template slot="title"> <template slot="title">
<font-awesome-icon icon="cog"/> <font-awesome-icon icon="cog"/>
<span slot="title" class="item-title">Settings</span> <span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.settings') }}</span>
</template> </template>
</n8n-menu-item> </n8n-menu-item>
</el-submenu> </el-submenu>
<el-submenu index="credentials" title="Credentials" popperClass="sidebar-popper"> <el-submenu index="credentials" :title="$locale.baseText('mainSidebar.credentials')" popperClass="sidebar-popper">
<template slot="title"> <template slot="title">
<font-awesome-icon icon="key"/>&nbsp; <font-awesome-icon icon="key"/>&nbsp;
<span slot="title" class="item-title-root">Credentials</span> <span slot="title" class="item-title-root">{{ $locale.baseText('mainSidebar.credentials') }}</span>
</template> </template>
<n8n-menu-item index="credentials-new"> <n8n-menu-item index="credentials-new">
<template slot="title"> <template slot="title">
<font-awesome-icon icon="file"/> <font-awesome-icon icon="file"/>
<span slot="title" class="item-title">New</span> <span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.new') }}</span>
</template> </template>
</n8n-menu-item> </n8n-menu-item>
<n8n-menu-item index="credentials-open"> <n8n-menu-item index="credentials-open">
<template slot="title"> <template slot="title">
<font-awesome-icon icon="folder-open"/> <font-awesome-icon icon="folder-open"/>
<span slot="title" class="item-title">Open</span> <span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.open') }}</span>
</template> </template>
</n8n-menu-item> </n8n-menu-item>
</el-submenu> </el-submenu>
<n8n-menu-item index="executions"> <n8n-menu-item index="executions">
<font-awesome-icon icon="tasks"/>&nbsp; <font-awesome-icon icon="tasks"/>&nbsp;
<span slot="title" class="item-title-root">Executions</span> <span slot="title" class="item-title-root">{{ $locale.baseText('mainSidebar.executions') }}</span>
</n8n-menu-item> </n8n-menu-item>
<el-submenu index="help" class="help-menu" title="Help" popperClass="sidebar-popper"> <el-submenu index="help" class="help-menu" title="Help" popperClass="sidebar-popper">
<template slot="title"> <template slot="title">
<font-awesome-icon icon="question"/>&nbsp; <font-awesome-icon icon="question"/>&nbsp;
<span slot="title" class="item-title-root">Help</span> <span slot="title" class="item-title-root">{{ $locale.baseText('mainSidebar.help') }}</span>
</template> </template>
<MenuItemsIterator :items="helpMenuItems" :afterItemClick="trackHelpItemClick" /> <MenuItemsIterator :items="helpMenuItems" :afterItemClick="trackHelpItemClick" />
@ -117,7 +117,7 @@
<n8n-menu-item index="help-about"> <n8n-menu-item index="help-about">
<template slot="title"> <template slot="title">
<font-awesome-icon class="about-icon" icon="info"/> <font-awesome-icon class="about-icon" icon="info"/>
<span slot="title" class="item-title">About n8n</span> <span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.aboutN8n') }}</span>
</template> </template>
</n8n-menu-item> </n8n-menu-item>
</el-submenu> </el-submenu>
@ -168,39 +168,6 @@ import { mapGetters } from 'vuex';
import MenuItemsIterator from './MainSidebarMenuItemsIterator.vue'; import MenuItemsIterator from './MainSidebarMenuItemsIterator.vue';
import { CREDENTIAL_LIST_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VERSIONS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY } from '@/constants'; import { CREDENTIAL_LIST_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VERSIONS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY } from '@/constants';
const helpMenuItems: IMenuItem[] = [
{
id: 'docs',
type: 'link',
properties: {
href: 'https://docs.n8n.io',
title: 'Documentation',
icon: 'book',
newWindow: true,
},
},
{
id: 'forum',
type: 'link',
properties: {
href: 'https://community.n8n.io',
title: 'Forum',
icon: 'users',
newWindow: true,
},
},
{
id: 'examples',
type: 'link',
properties: {
href: 'https://n8n.io/workflows',
title: 'Workflows',
icon: 'network-wired',
newWindow: true,
},
},
];
export default mixins( export default mixins(
genericHelpers, genericHelpers,
restApi, restApi,
@ -225,7 +192,6 @@ export default mixins(
basePath: this.$store.getters.getBaseUrl, basePath: this.$store.getters.getBaseUrl,
executionsListDialogVisible: false, executionsListDialogVisible: false,
stopExecutionInProgress: false, stopExecutionInProgress: false,
helpMenuItems,
}; };
}, },
computed: { computed: {
@ -236,6 +202,40 @@ export default mixins(
'hasVersionUpdates', 'hasVersionUpdates',
'nextVersions', 'nextVersions',
]), ]),
helpMenuItems (): object[] {
return [
{
id: 'docs',
type: 'link',
properties: {
href: 'https://docs.n8n.io',
title: this.$locale.baseText('mainSidebar.helpMenuItems.documentation'),
icon: 'book',
newWindow: true,
},
},
{
id: 'forum',
type: 'link',
properties: {
href: 'https://community.n8n.io',
title: this.$locale.baseText('mainSidebar.helpMenuItems.forum'),
icon: 'users',
newWindow: true,
},
},
{
id: 'examples',
type: 'link',
properties: {
href: 'https://n8n.io/workflows',
title: this.$locale.baseText('mainSidebar.helpMenuItems.workflows'),
icon: 'network-wired',
newWindow: true,
},
},
];
},
exeuctionId (): string | undefined { exeuctionId (): string | undefined {
return this.$route.params.id; return this.$route.params.id;
}, },
@ -322,12 +322,19 @@ export default mixins(
this.stopExecutionInProgress = true; this.stopExecutionInProgress = true;
await this.restApi().stopCurrentExecution(executionId); await this.restApi().stopCurrentExecution(executionId);
this.$showMessage({ this.$showMessage({
title: 'Execution stopped', title: this.$locale.baseText('mainSidebar.showMessage.stopExecution.title'),
message: `The execution with the id "${executionId}" got stopped!`, message: this.$locale.baseText(
'mainSidebar.showMessage.stopExecution.message',
{ interpolate: { executionId }},
),
type: 'success', type: 'success',
}); });
} catch (error) { } catch (error) {
this.$showError(error, 'Problem stopping execution', 'There was a problem stopping the execuction:'); this.$showError(
error,
this.$locale.baseText('mainSidebar.showError.stopExecution.title'),
this.$locale.baseText('mainSidebar.showError.stopExecution.message') + ':',
);
} }
this.stopExecutionInProgress = false; this.stopExecutionInProgress = false;
}, },
@ -351,8 +358,8 @@ export default mixins(
worflowData = JSON.parse(data as string); worflowData = JSON.parse(data as string);
} catch (error) { } catch (error) {
this.$showMessage({ this.$showMessage({
title: 'Could not import file', title: this.$locale.baseText('mainSidebar.showMessage.handleFileImport.title'),
message: `The file does not contain valid JSON data.`, message: this.$locale.baseText('mainSidebar.showMessage.handleFileImport.message'),
type: 'error', type: 'error',
}); });
return; return;
@ -374,17 +381,30 @@ export default mixins(
(this.$refs.importFile as HTMLInputElement).click(); (this.$refs.importFile as HTMLInputElement).click();
} else if (key === 'workflow-import-url') { } else if (key === 'workflow-import-url') {
try { try {
const promptResponse = await this.$prompt(`Workflow URL:`, 'Import Workflow from URL:', { const promptResponse = await this.$prompt(
confirmButtonText: 'Import', this.$locale.baseText('mainSidebar.prompt.workflowUrl') + ':',
cancelButtonText: 'Cancel', this.$locale.baseText('mainSidebar.prompt.importWorkflowFromUrl') + ':',
inputErrorMessage: 'Invalid URL', {
confirmButtonText: this.$locale.baseText('mainSidebar.prompt.import'),
cancelButtonText: this.$locale.baseText('mainSidebar.prompt.cancel'),
inputErrorMessage: this.$locale.baseText('mainSidebar.prompt.invalidUrl'),
inputPattern: /^http[s]?:\/\/.*\.json$/i, inputPattern: /^http[s]?:\/\/.*\.json$/i,
}) as MessageBoxInputData; },
) as MessageBoxInputData;
this.$root.$emit('importWorkflowUrl', { url: promptResponse.value }); this.$root.$emit('importWorkflowUrl', { url: promptResponse.value });
} catch (e) {} } catch (e) {}
} else if (key === 'workflow-delete') { } else if (key === 'workflow-delete') {
const deleteConfirmed = await this.confirmMessage(`Are you sure that you want to delete the workflow "${this.workflowName}"?`, 'Delete Workflow?', 'warning', 'Yes, delete!'); const deleteConfirmed = await this.confirmMessage(
this.$locale.baseText(
'mainSidebar.confirmMessage.workflowDelete.message',
{ interpolate: { workflowName: this.workflowName } },
),
this.$locale.baseText('mainSidebar.confirmMessage.workflowDelete.headline'),
'warning',
this.$locale.baseText('mainSidebar.confirmMessage.workflowDelete.confirmButtonText'),
this.$locale.baseText('mainSidebar.confirmMessage.workflowDelete.cancelButtonText'),
);
if (deleteConfirmed === false) { if (deleteConfirmed === false) {
return; return;
@ -393,15 +413,22 @@ export default mixins(
try { try {
await this.restApi().deleteWorkflow(this.currentWorkflow); await this.restApi().deleteWorkflow(this.currentWorkflow);
} catch (error) { } catch (error) {
this.$showError(error, 'Problem deleting the workflow', 'There was a problem deleting the workflow:'); this.$showError(
error,
this.$locale.baseText('mainSidebar.showError.stopExecution.title'),
this.$locale.baseText('mainSidebar.showError.stopExecution.message') + ':',
);
return; return;
} }
this.$store.commit('setStateDirty', false); this.$store.commit('setStateDirty', false);
// Reset tab title since workflow is deleted. // Reset tab title since workflow is deleted.
this.$titleReset(); this.$titleReset();
this.$showMessage({ this.$showMessage({
title: 'Workflow was deleted', title: this.$locale.baseText('mainSidebar.showMessage.handleSelect1.title'),
message: `The workflow "${this.workflowName}" was deleted!`, message: this.$locale.baseText(
'mainSidebar.showMessage.handleSelect1.message',
{ interpolate: { workflowName: this.workflowName }},
),
type: 'success', type: 'success',
}); });
@ -425,7 +452,8 @@ export default mixins(
saveAs(blob, workflowName + '.json'); saveAs(blob, workflowName + '.json');
} else if (key === 'workflow-save') { } else if (key === 'workflow-save') {
this.saveCurrentWorkflow(undefined); const saved = await this.saveCurrentWorkflow();
if (saved) this.$store.dispatch('settings/fetchPromptsData');
} else if (key === 'workflow-duplicate') { } else if (key === 'workflow-duplicate') {
this.$store.dispatch('ui/openModal', DUPLICATE_MODAL_KEY); this.$store.dispatch('ui/openModal', DUPLICATE_MODAL_KEY);
} else if (key === 'help-about') { } else if (key === 'help-about') {
@ -436,7 +464,13 @@ export default mixins(
} else if (key === 'workflow-new') { } else if (key === 'workflow-new') {
const result = this.$store.getters.getStateIsDirty; const result = this.$store.getters.getStateIsDirty;
if(result) { if(result) {
const importConfirm = await this.confirmMessage(`When you switch workflows your current workflow changes will be lost.`, 'Save your Changes?', 'warning', 'Yes, switch workflows and forget changes'); const importConfirm = await this.confirmMessage(
this.$locale.baseText('mainSidebar.confirmMessage.workflowNew.message'),
this.$locale.baseText('mainSidebar.confirmMessage.workflowNew.headline'),
'warning',
this.$locale.baseText('mainSidebar.confirmMessage.workflowNew.confirmButtonText'),
this.$locale.baseText('mainSidebar.confirmMessage.workflowNew.cancelButtonText'),
);
if (importConfirm === true) { if (importConfirm === true) {
this.$store.commit('setStateDirty', false); this.$store.commit('setStateDirty', false);
if (this.$router.currentRoute.name === 'NodeViewNew') { if (this.$router.currentRoute.name === 'NodeViewNew') {
@ -446,8 +480,8 @@ export default mixins(
} }
this.$showMessage({ this.$showMessage({
title: 'Workflow created', title: this.$locale.baseText('mainSidebar.showMessage.handleSelect2.title'),
message: 'A new workflow got created!', message: this.$locale.baseText('mainSidebar.showMessage.handleSelect2.message'),
type: 'success', type: 'success',
}); });
} }
@ -457,8 +491,8 @@ export default mixins(
} }
this.$showMessage({ this.$showMessage({
title: 'Workflow created', title: this.$locale.baseText('mainSidebar.showMessage.handleSelect3.title'),
message: 'A new workflow got created!', message: this.$locale.baseText('mainSidebar.showMessage.handleSelect3.message'),
type: 'success', type: 'success',
}); });
} }

View file

@ -4,12 +4,16 @@
:visible="visible" :visible="visible"
:size="width" :size="width"
:before-close="close" :before-close="close"
:modal="modal"
:wrapperClosable="wrapperClosable"
> >
<template v-slot:title> <template v-slot:title>
<slot name="header" /> <slot name="header" />
</template> </template>
<template> <template>
<span @keydown.stop>
<slot name="content"/> <slot name="content"/>
</span>
</template> </template>
</el-drawer> </el-drawer>
</template> </template>
@ -23,15 +27,26 @@ export default Vue.extend({
name: { name: {
type: String, type: String,
}, },
beforeClose: {
type: Function,
},
eventBus: { eventBus: {
type: Vue, type: Vue,
}, },
direction: { direction: {
type: String, type: String,
}, },
modal: {
type: Boolean,
default: true,
},
width: { width: {
type: String, type: String,
}, },
wrapperClosable: {
type: Boolean,
default: true,
},
}, },
mounted() { mounted() {
window.addEventListener('keydown', this.onWindowKeydown); window.addEventListener('keydown', this.onWindowKeydown);
@ -66,6 +81,10 @@ export default Vue.extend({
} }
}, },
close() { close() {
if (this.beforeClose) {
this.beforeClose();
return;
}
this.$store.commit('ui/closeTopModal'); this.$store.commit('ui/closeTopModal');
}, },
}, },

View file

@ -1,5 +1,13 @@
<template> <template>
<div> <div>
<ModalRoot :name="CONTACT_PROMPT_MODAL_KEY">
<template v-slot:default="{ modalName }">
<ContactPromptModal
:modalName="modalName"
/>
</template>
</ModalRoot>
<ModalRoot :name="CREDENTIAL_EDIT_MODAL_KEY"> <ModalRoot :name="CREDENTIAL_EDIT_MODAL_KEY">
<template v-slot="{ modalName, activeId, mode }"> <template v-slot="{ modalName, activeId, mode }">
<CredentialEdit <CredentialEdit
@ -39,6 +47,12 @@
<UpdatesPanel /> <UpdatesPanel />
</ModalRoot> </ModalRoot>
<ModalRoot :name="VALUE_SURVEY_MODAL_KEY" :keepAlive="true">
<template v-slot:default="{ active }">
<ValueSurvey :isActive="active"/>
</template>
</ModalRoot>
<ModalRoot :name="WORKFLOW_OPEN_MODAL_KEY"> <ModalRoot :name="WORKFLOW_OPEN_MODAL_KEY">
<WorkflowOpen /> <WorkflowOpen />
</ModalRoot> </ModalRoot>
@ -51,8 +65,9 @@
<script lang="ts"> <script lang="ts">
import Vue from "vue"; import Vue from "vue";
import { CREDENTIAL_LIST_MODAL_KEY, DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, PERSONALIZATION_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY, VERSIONS_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants'; import { CONTACT_PROMPT_MODAL_KEY, CREDENTIAL_LIST_MODAL_KEY, DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, PERSONALIZATION_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY, VERSIONS_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, VALUE_SURVEY_MODAL_KEY } from '@/constants';
import ContactPromptModal from './ContactPromptModal.vue';
import CredentialEdit from "./CredentialEdit/CredentialEdit.vue"; import CredentialEdit from "./CredentialEdit/CredentialEdit.vue";
import CredentialsList from "./CredentialsList.vue"; import CredentialsList from "./CredentialsList.vue";
import CredentialsSelectModal from "./CredentialsSelectModal.vue"; import CredentialsSelectModal from "./CredentialsSelectModal.vue";
@ -61,12 +76,14 @@ import ModalRoot from "./ModalRoot.vue";
import PersonalizationModal from "./PersonalizationModal.vue"; import PersonalizationModal from "./PersonalizationModal.vue";
import TagsManager from "./TagsManager/TagsManager.vue"; import TagsManager from "./TagsManager/TagsManager.vue";
import UpdatesPanel from "./UpdatesPanel.vue"; import UpdatesPanel from "./UpdatesPanel.vue";
import ValueSurvey from "./ValueSurvey.vue";
import WorkflowSettings from "./WorkflowSettings.vue"; import WorkflowSettings from "./WorkflowSettings.vue";
import WorkflowOpen from "./WorkflowOpen.vue"; import WorkflowOpen from "./WorkflowOpen.vue";
export default Vue.extend({ export default Vue.extend({
name: "Modals", name: "Modals",
components: { components: {
ContactPromptModal,
CredentialEdit, CredentialEdit,
CredentialsList, CredentialsList,
CredentialsSelectModal, CredentialsSelectModal,
@ -75,10 +92,12 @@ export default Vue.extend({
PersonalizationModal, PersonalizationModal,
TagsManager, TagsManager,
UpdatesPanel, UpdatesPanel,
ValueSurvey,
WorkflowSettings, WorkflowSettings,
WorkflowOpen, WorkflowOpen,
}, },
data: () => ({ data: () => ({
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_LIST_MODAL_KEY, CREDENTIAL_LIST_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY,
@ -88,6 +107,7 @@ export default Vue.extend({
VERSIONS_MODAL_KEY, VERSIONS_MODAL_KEY,
WORKFLOW_OPEN_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY,
VALUE_SURVEY_MODAL_KEY,
}), }),
}); });
</script> </script>

View file

@ -1,20 +1,19 @@
<template> <template>
<div @keydown.stop class="duplicate-parameter"> <div @keydown.stop class="duplicate-parameter">
<n8n-input-label
<div class="parameter-name"> :label="$locale.nodeText().topParameterDisplayName(parameter)"
{{parameter.displayName}}: :tooltipText="$locale.nodeText().topParameterDescription(parameter)"
<n8n-tooltip v-if="parameter.description" class="parameter-info" placement="top" > :underline="true"
<div slot="content" v-html="addTargetBlank(parameter.description)"></div> :labelHoverableOnly="true"
<font-awesome-icon icon="question-circle" /> size="small"
</n8n-tooltip> >
</div>
<div v-for="(value, index) in values" :key="index" class="duplicate-parameter-item" :class="parameter.type"> <div v-for="(value, index) in values" :key="index" class="duplicate-parameter-item" :class="parameter.type">
<div class="delete-item clickable" v-if="!isReadOnly"> <div class="delete-item clickable" v-if="!isReadOnly">
<font-awesome-icon icon="trash" title="Delete Item" @click="deleteItem(index)" /> <font-awesome-icon icon="trash" :title="$locale.baseText('multipleParameter.deleteItem')" @click="deleteItem(index)" />
<div v-if="sortable"> <div v-if="sortable">
<font-awesome-icon v-if="index !== 0" icon="angle-up" class="clickable" title="Move up" @click="moveOptionUp(index)" /> <font-awesome-icon v-if="index !== 0" icon="angle-up" class="clickable" :title="$locale.baseText('multipleParameter.moveUp')" @click="moveOptionUp(index)" />
<font-awesome-icon v-if="index !== (values.length -1)" icon="angle-down" class="clickable" title="Move down" @click="moveOptionDown(index)" /> <font-awesome-icon v-if="index !== (values.length -1)" icon="angle-down" class="clickable" :title="$locale.baseText('multipleParameter.moveDown')" @click="moveOptionDown(index)" />
</div> </div>
</div> </div>
<div v-if="parameter.type === 'collection'"> <div v-if="parameter.type === 'collection'">
@ -27,11 +26,12 @@
<div class="add-item-wrapper"> <div class="add-item-wrapper">
<div v-if="values && Object.keys(values).length === 0 || isReadOnly" class="no-items-exist"> <div v-if="values && Object.keys(values).length === 0 || isReadOnly" class="no-items-exist">
Currently no items exist <n8n-text size="small">{{ $locale.baseText('multipleParameter.currentlyNoItemsExist') }}</n8n-text>
</div> </div>
<n8n-button v-if="!isReadOnly" fullWidth @click="addItem()" :label="addButtonText" /> <n8n-button v-if="!isReadOnly" fullWidth @click="addItem()" :label="addButtonText" />
</div> </div>
</n8n-input-label>
</div> </div>
</template> </template>
@ -48,7 +48,6 @@ import { get } from 'lodash';
import { genericHelpers } from '@/components/mixins/genericHelpers'; import { genericHelpers } from '@/components/mixins/genericHelpers';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import { addTargetBlank } from './helpers';
export default mixins(genericHelpers) export default mixins(genericHelpers)
.extend({ .extend({
@ -65,7 +64,14 @@ export default mixins(genericHelpers)
], ],
computed: { computed: {
addButtonText (): string { addButtonText (): string {
return (this.parameter.typeOptions && this.parameter.typeOptions.multipleValueButtonText) ? this.parameter.typeOptions.multipleValueButtonText : 'Add item'; if (
!this.parameter.typeOptions &&
!this.parameter.typeOptions.multipleValueButtonText
) {
return this.$locale.baseText('multipleParameter.addItem');
}
return this.$locale.nodeText().multipleValueButtonText(this.parameter);
}, },
hideDelete (): boolean { hideDelete (): boolean {
return this.parameter.options.length === 1; return this.parameter.options.length === 1;
@ -75,7 +81,6 @@ export default mixins(genericHelpers)
}, },
}, },
methods: { methods: {
addTargetBlank,
addItem () { addItem () {
const name = this.getPath(); const name = this.getPath();
let currentValue = get(this.nodeValues, name); let currentValue = get(this.nodeValues, name);
@ -134,11 +139,7 @@ export default mixins(genericHelpers)
<style scoped lang="scss"> <style scoped lang="scss">
.duplicate-parameter-item ~.add-item-wrapper { .duplicate-parameter-item ~.add-item-wrapper {
margin: 1.5em 0 0em 0em; margin-top: var(--spacing-xs);
}
.add-item-wrapper {
margin: 0.5em 0 0em 2em;
} }
.delete-item { .delete-item {
@ -149,23 +150,15 @@ export default mixins(genericHelpers)
z-index: 999; z-index: 999;
color: #f56c6c; color: #f56c6c;
width: 15px; width: 15px;
font-size: var(--font-size-2xs);
:hover { :hover {
color: #ff0000; color: #ff0000;
} }
} }
.duplicate-parameter {
margin-top: 0.5em;
.parameter-name {
border-bottom: 1px solid #999;
}
}
::v-deep .duplicate-parameter-item { ::v-deep .duplicate-parameter-item {
position: relative; position: relative;
margin-top: 0.5em;
padding-top: 0.5em;
.multi > .delete-item{ .multi > .delete-item{
top: 0.1em; top: 0.1em;
@ -179,12 +172,12 @@ export default mixins(genericHelpers)
::v-deep .duplicate-parameter-item + .duplicate-parameter-item { ::v-deep .duplicate-parameter-item + .duplicate-parameter-item {
.collection-parameter-wrapper { .collection-parameter-wrapper {
border-top: 1px dashed #999; border-top: 1px dashed #999;
padding-top: 0.5em; margin-top: var(--spacing-xs);
} }
} }
.no-items-exist { .no-items-exist {
margin: 0 0 1em 0; margin: var(--spacing-xs) 0;
} }
</style> </style>

View file

@ -1,47 +1,69 @@
<template> <template>
<div class="node-wrapper" :style="nodePosition"> <div class="node-wrapper" :style="nodePosition">
<div class="node-default" :ref="data.name" :style="nodeStyle" :class="nodeClass" @dblclick="setNodeActive" @click.left="mouseLeftClick" v-touch:start="touchStart" v-touch:end="touchEnd"> <div class="select-background" v-show="isSelected"></div>
<div v-if="hasIssues" class="node-info-icon node-issues"> <div :class="{'node-default': true, 'touch-active': isTouchActive, 'is-touch-device': isTouchDevice}" :data-name="data.name" :ref="data.name">
<n8n-tooltip placement="top" > <div :class="nodeClass" :style="nodeStyle" @dblclick="setNodeActive" @click.left="mouseLeftClick" v-touch:start="touchStart" v-touch:end="touchEnd">
<div v-if="!data.disabled" :class="{'node-info-icon': true, 'shift-icon': shiftOutputCount}">
<div v-if="hasIssues" class="node-issues">
<n8n-tooltip placement="bottom" >
<div slot="content" v-html="nodeIssues"></div> <div slot="content" v-html="nodeIssues"></div>
<font-awesome-icon icon="exclamation-triangle" /> <font-awesome-icon icon="exclamation-triangle" />
</n8n-tooltip> </n8n-tooltip>
</div> </div>
<el-badge v-else :hidden="workflowDataItems === 0" class="node-info-icon data-count" :value="workflowDataItems"></el-badge> <div v-else-if="waiting" class="waiting">
<n8n-tooltip placement="bottom">
<div v-if="waiting" class="node-info-icon waiting">
<n8n-tooltip placement="top">
<div slot="content" v-html="waiting"></div> <div slot="content" v-html="waiting"></div>
<font-awesome-icon icon="clock" /> <font-awesome-icon icon="clock" />
</n8n-tooltip> </n8n-tooltip>
</div> </div>
<span v-else-if="workflowDataItems" class="data-count">
<font-awesome-icon icon="check" />
<span v-if="workflowDataItems > 1" class="items-count"> {{ workflowDataItems }}</span>
</span>
</div>
<div class="node-executing-info" title="Node is executing"> <div class="node-executing-info" :title="$locale.baseText('node.nodeIsExecuting')">
<font-awesome-icon icon="sync-alt" spin /> <font-awesome-icon icon="sync-alt" spin />
</div> </div>
<div class="node-options no-select-on-click" v-if="!isReadOnly">
<div v-touch:tap="deleteNode" class="option" title="Delete Node" > <div class="node-trigger-tooltip__wrapper">
<n8n-tooltip placement="top" :manual="true" :value="showTriggerNodeTooltip" popper-class="node-trigger-tooltip__wrapper--item">
<div slot="content" v-text="getTriggerNodeTooltip"></div>
<span />
</n8n-tooltip>
</div>
<NodeIcon class="node-icon" :nodeType="nodeType" :size="40" :shrink="false" :disabled="this.data.disabled"/>
</div>
<div class="node-options no-select-on-click" v-if="!isReadOnly" v-show="!hideActions">
<div v-touch:tap="deleteNode" class="option" :title="$locale.baseText('node.deleteNode')" >
<font-awesome-icon icon="trash" /> <font-awesome-icon icon="trash" />
</div> </div>
<div v-touch:tap="disableNode" class="option" title="Activate/Deactivate Node" > <div v-touch:tap="disableNode" class="option" :title="$locale.baseText('node.activateDeactivateNode')">
<font-awesome-icon :icon="nodeDisabledIcon" /> <font-awesome-icon :icon="nodeDisabledIcon" />
</div> </div>
<div v-touch:tap="duplicateNode" class="option" title="Duplicate Node" > <div v-touch:tap="duplicateNode" class="option" :title="$locale.baseText('node.duplicateNode')">
<font-awesome-icon icon="clone" /> <font-awesome-icon icon="clone" />
</div> </div>
<div v-touch:tap="setNodeActive" class="option touch" title="Edit Node" v-if="!isReadOnly"> <div v-touch:tap="setNodeActive" class="option touch" :title="$locale.baseText('node.editNode')" v-if="!isReadOnly">
<font-awesome-icon class="execute-icon" icon="cog" /> <font-awesome-icon class="execute-icon" icon="cog" />
</div> </div>
<div v-touch:tap="executeNode" class="option" title="Execute Node" v-if="!isReadOnly && !workflowRunning"> <div v-touch:tap="executeNode" class="option" :title="$locale.baseText('node.executeNode')" v-if="!isReadOnly && !workflowRunning">
<font-awesome-icon class="execute-icon" icon="play-circle" /> <font-awesome-icon class="execute-icon" icon="play-circle" />
</div> </div>
</div> </div>
<div :class="{'disabled-linethrough': true, success: workflowDataItems > 0}" v-if="showDisabledLinethrough"></div>
<NodeIcon class="node-icon" :nodeType="nodeType" size="60" :circle="true" :shrink="true" :disabled="this.data.disabled"/>
</div> </div>
<div class="node-description"> <div class="node-description">
<div class="node-name" :title="data.name"> <div class="node-name" :title="nodeTitle">
{{data.name}} <p>
{{ nodeTitle }}
</p>
<p v-if="data.disabled">
({{ $locale.baseText('node.disabled') }})
</p>
</div> </div>
<div v-if="nodeSubtitle !== undefined" class="node-subtitle" :title="nodeSubtitle"> <div v-if="nodeSubtitle !== undefined" class="node-subtitle" :title="nodeSubtitle">
{{ nodeSubtitle }} {{ nodeSubtitle }}
@ -61,6 +83,7 @@ import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { import {
INodeTypeDescription, INodeTypeDescription,
ITaskData,
NodeHelpers, NodeHelpers,
} from 'n8n-workflow'; } from 'n8n-workflow';
@ -69,6 +92,8 @@ import NodeIcon from '@/components/NodeIcon.vue';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import { get } from 'lodash'; import { get } from 'lodash';
import { getStyleTokenValue } from './helpers';
import { INodeUi, XYPosition } from '@/Interface';
export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).extend({ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).extend({
name: 'Node', name: 'Node',
@ -76,48 +101,68 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
NodeIcon, NodeIcon,
}, },
computed: { computed: {
workflowDataItems () { nodeRunData(): ITaskData[] {
const workflowResultDataNode = this.$store.getters.getWorkflowResultDataByNodeName(this.data.name); return this.$store.getters.getWorkflowResultDataByNodeName(this.data.name);
},
hasIssues (): boolean {
if (this.data.issues !== undefined && Object.keys(this.data.issues).length) {
return true;
}
return false;
},
workflowDataItems (): number {
const workflowResultDataNode = this.nodeRunData;
if (workflowResultDataNode === null) { if (workflowResultDataNode === null) {
return 0; return 0;
} }
return workflowResultDataNode.length; return workflowResultDataNode.length;
}, },
canvasOffsetPosition() {
return this.$store.getters.getNodeViewOffsetPosition;
},
getTriggerNodeTooltip (): string | undefined {
if (this.nodeType !== null && this.nodeType.hasOwnProperty('eventTriggerDescription')) {
return this.nodeType.eventTriggerDescription;
} else {
return `Waiting for you to create an event in ${this.nodeType && this.nodeType.displayName.replace(/Trigger/, "")}`;
}
},
isPollingTypeNode (): boolean {
return !!(this.nodeType && this.nodeType.polling);
},
isExecuting (): boolean { isExecuting (): boolean {
return this.$store.getters.executingNode === this.data.name; return this.$store.getters.executingNode === this.data.name;
}, },
nodeType (): INodeTypeDescription | null { isSingleActiveTriggerNode (): boolean {
return this.$store.getters.nodeType(this.data.type); const nodes = this.$store.getters.workflowTriggerNodes.filter((node: INodeUi) => {
const nodeType = this.$store.getters.nodeType(node.type) as INodeTypeDescription | null;
return nodeType && nodeType.eventTriggerDescription !== '' && !node.disabled;
});
return nodes.length === 1;
}, },
nodeClass () { isTriggerNode (): boolean {
const classes = []; return !!(this.nodeType && this.nodeType.group.includes('trigger'));
},
if (this.data.disabled) { isTriggerNodeTooltipEmpty () : boolean {
classes.push('disabled'); return this.nodeType !== null ? this.nodeType.eventTriggerDescription === '' : false;
} },
isNodeDisabled (): boolean | undefined {
if (this.isExecuting) { return this.node && this.node.disabled;
classes.push('executing'); },
} nodeType (): INodeTypeDescription | null {
return this.data && this.$store.getters.nodeType(this.data.type);
if (this.workflowDataItems !== 0) { },
classes.push('has-data'); node (): INodeUi | undefined { // same as this.data but reactive..
} return this.$store.getters.nodesByName[this.name] as INodeUi | undefined;
},
if (this.hasIssues) { nodeClass (): object {
classes.push('has-issues'); return {
} 'node-box': true,
disabled: this.data.disabled,
if (this.isTouchDevice) { executing: this.isExecuting,
classes.push('is-touch-device'); };
}
if (this.isTouchActive) {
classes.push('touch-active');
}
return classes;
}, },
nodeIssues (): string { nodeIssues (): string {
if (this.data.issues === undefined) { if (this.data.issues === undefined) {
@ -126,7 +171,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
const nodeIssues = NodeHelpers.nodeIssuesToString(this.data.issues, this.data); const nodeIssues = NodeHelpers.nodeIssuesToString(this.data.issues, this.data);
return 'Issues:<br />&nbsp;&nbsp;- ' + nodeIssues.join('<br />&nbsp;&nbsp;- '); return `${this.$locale.baseText('node.issues')}:<br />&nbsp;&nbsp;- ` + nodeIssues.join('<br />&nbsp;&nbsp;- ');
}, },
nodeDisabledIcon (): string { nodeDisabledIcon (): string {
if (this.data.disabled === false) { if (this.data.disabled === false) {
@ -135,6 +180,35 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
return 'play'; return 'play';
} }
}, },
position (): XYPosition {
return this.node ? this.node.position : [0, 0];
},
showDisabledLinethrough(): boolean {
return !!(this.data.disabled && this.nodeType && this.nodeType.inputs.length === 1 && this.nodeType.outputs.length === 1);
},
nodePosition (): object {
const returnStyles: {
[key: string]: string;
} = {
left: this.position[0] + 'px',
top: this.position[1] + 'px',
};
return returnStyles;
},
shortNodeType (): string {
return this.$locale.shortNodeType(this.data.type);
},
nodeTitle (): string {
if (this.data.name === 'Start') {
return this.$locale.headerText({
key: `headers.start.displayName`,
fallback: 'Start',
});
}
return this.data.name;
},
waiting (): string | undefined { waiting (): string | undefined {
const workflowExecution = this.$store.getters.getWorkflowExecution; const workflowExecution = this.$store.getters.getWorkflowExecution;
@ -143,9 +217,17 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
if (this.name === lastNodeExecuted) { if (this.name === lastNodeExecuted) {
const waitDate = new Date(workflowExecution.waitTill); const waitDate = new Date(workflowExecution.waitTill);
if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) { if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) {
return 'The node is waiting indefinitely for an incoming webhook call.'; return this.$locale.baseText('node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall');
} }
return `Node is waiting till ${waitDate.toLocaleDateString()} ${waitDate.toLocaleTimeString()}`; return this.$locale.baseText(
'node.nodeIsWaitingTill',
{
interpolate: {
date: waitDate.toLocaleDateString(),
time: waitDate.toLocaleTimeString(),
},
},
);
} }
} }
@ -154,6 +236,50 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
workflowRunning (): boolean { workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning'); return this.$store.getters.isActionActive('workflowRunning');
}, },
nodeStyle (): object {
let borderColor = getStyleTokenValue('--color-foreground-xdark');
if (this.data.disabled) {
borderColor = getStyleTokenValue('--color-foreground-base');
}
else if (!this.isExecuting) {
if (this.hasIssues) {
borderColor = getStyleTokenValue('--color-danger');
}
else if (this.waiting) {
borderColor = getStyleTokenValue('--color-secondary');
}
else if (this.workflowDataItems) {
borderColor = getStyleTokenValue('--color-success');
}
}
const returnStyles: {
[key: string]: string;
} = {
'border-color': borderColor,
};
return returnStyles;
},
isSelected (): boolean {
return this.$store.getters.getSelectedNodes.find((node: INodeUi) => node.name === this.data.name);
},
shiftOutputCount (): boolean {
return !!(this.nodeType && this.nodeType.outputs.length > 2);
},
shouldShowTriggerTooltip () : boolean {
return !!this.node &&
this.isTriggerNode &&
!this.isPollingTypeNode &&
!this.isNodeDisabled &&
this.workflowRunning &&
this.workflowDataItems === 0 &&
this.isSingleActiveTriggerNode &&
!this.isTriggerNodeTooltipEmpty &&
!this.hasIssues &&
!this.dragging;
},
}, },
watch: { watch: {
isActive(newValue, oldValue) { isActive(newValue, oldValue) {
@ -161,14 +287,39 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
this.setSubtitle(); this.setSubtitle();
} }
}, },
canvasOffsetPosition() {
if (this.showTriggerNodeTooltip) {
this.showTriggerNodeTooltip = false;
setTimeout(() => {
this.showTriggerNodeTooltip = this.shouldShowTriggerTooltip;
}, 200);
}
},
shouldShowTriggerTooltip(shouldShowTriggerTooltip) {
if (shouldShowTriggerTooltip) {
setTimeout(() => {
this.showTriggerNodeTooltip = this.shouldShowTriggerTooltip;
}, 2500);
} else {
this.showTriggerNodeTooltip = false;
}
},
nodeRunData(newValue) {
this.$emit('run', {name: this.data.name, data: newValue, waiting: !!this.waiting});
},
}, },
mounted() { mounted() {
this.setSubtitle(); this.setSubtitle();
setTimeout(() => {
this.$emit('run', {name: this.data.name, data: this.nodeRunData, waiting: !!this.waiting});
}, 0);
}, },
data () { data () {
return { return {
isTouchActive: false, isTouchActive: false,
nodeSubtitle: '', nodeSubtitle: '',
showTriggerNodeTooltip: false,
dragging: false,
}; };
}, },
methods: { methods: {
@ -197,6 +348,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
this.$emit('duplicateNode', this.data.name); this.$emit('duplicateNode', this.data.name);
}); });
}, },
setNodeActive () { setNodeActive () {
this.$store.commit('setActiveNode', this.data.name); this.$store.commit('setActiveNode', this.data.name);
}, },
@ -213,7 +365,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
.node-wrapper { .node-wrapper {
position: absolute; position: absolute;
@ -221,20 +373,25 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
height: 100px; height: 100px;
.node-description { .node-description {
line-height: 1.5;
position: absolute; position: absolute;
bottom: -55px; top: 100px;
left: -50px; left: -50px;
width: 200px; line-height: 1.5;
height: 50px;
text-align: center; text-align: center;
cursor: default; cursor: default;
padding: 8px;
width: 200px;
pointer-events: none; // prevent container from being draggable
.node-name { .node-name > p { // must be paragraph tag to have two lines in safari
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
font-weight: 500; display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-compact);
} }
.node-subtitle { .node-subtitle {
@ -248,35 +405,26 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
} }
.node-default { .node-default {
position: absolute;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: #fff;
border-radius: 25px;
text-align: center;
z-index: 24;
cursor: pointer; cursor: pointer;
color: #444;
border: 1px dashed grey;
&.has-data { .node-box {
border-style: solid; width: 100%;
} height: 100%;
border: 2px solid var(--color-foreground-xdark);
&.disabled { border-radius: var(--border-radius-large);
color: #a0a0a0; background-color: var(--color-background-xlight);
text-decoration: line-through;
border: 1px solid #eee !important;
background-color: #eee;
}
&.executing { &.executing {
background-color: $--color-primary-light !important; background-color: $--color-primary-light !important;
border-color: $--color-primary !important;
.node-executing-info { .node-executing-info {
display: inline-block; display: inline-block;
} }
} }
}
&.touch-active, &.touch-active,
&:hover { &:hover {
@ -305,39 +453,35 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
.node-icon { .node-icon {
position: absolute; position: absolute;
top: calc(50% - 30px); top: calc(50% - 20px);
left: calc(50% - 30px); left: calc(50% - 20px);
} }
.node-info-icon { .node-info-icon {
position: absolute; position: absolute;
top: -14px; bottom: 6px;
right: 6px;
&.shift-icon {
right: 12px; right: 12px;
z-index: 11; }
&.data-count { .data-count {
font-weight: 600; font-weight: 600;
top: -12px; color: var(--color-success);
}
&.waiting {
left: 10px;
top: -12px;
}
} }
.node-issues { .node-issues {
width: 25px; color: var(--color-danger);
height: 25px; }
font-size: 20px;
color: #ff0000; .items-count {
font-size: var(--font-size-s);
}
} }
.waiting { .waiting {
width: 25px; color: var(--color-secondary);
height: 25px;
font-size: 20px;
color: #5e5efa;
} }
.node-options { .node-options {
@ -346,7 +490,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
top: -25px; top: -25px;
left: -10px; left: -10px;
width: 120px; width: 120px;
height: 45px; height: 26px;
font-size: 0.9em; font-size: 0.9em;
text-align: left; text-align: left;
z-index: 10; z-index: 10;
@ -381,45 +525,189 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
display: initial; display: initial;
} }
} }
&.has-data .node-options,
&.has-issues .node-options {
top: -35px;
}
} }
} }
.select-background {
display: block;
background-color: hsla(var(--color-foreground-base-h), var(--color-foreground-base-s), var(--color-foreground-base-l), 60%);
border-radius: var(--border-radius-xlarge);
overflow: hidden;
position: absolute;
left: -8px !important;
top: -8px !important;
height: 116px;
width: 116px !important;
}
.disabled-linethrough {
border: 1px solid var(--color-foreground-dark);
position: absolute;
top: 49px;
left: -3px;
width: 111px;
pointer-events: none;
&.success {
border-color: var(--color-success-light);
}
}
</style> </style>
<style> <style lang="scss">
.el-badge__content { .jtk-endpoint {
border-width: 2px; z-index: 2;
background-color: #67c23a;
} }
.node-trigger-tooltip {
&__wrapper {
top: -22px;
left: 50px;
position: relative;
&--item {
max-width: 160px;
position: fixed;
z-index: 0!important;
}
}
}
/** connector */
.jtk-connector { .jtk-connector {
z-index: 3;
}
.jtk-connector path {
transition: stroke .1s ease-in-out;
}
.jtk-connector.success {
z-index: 4; z-index: 4;
} }
.jtk-endpoint {
z-index:5; .jtk-connector.jtk-hover {
}
.jtk-overlay {
z-index: 6; z-index: 6;
} }
.jtk-endpoint.dropHover { .jtk-endpoint.plus-endpoint {
border: 2px solid #ff2244; z-index: 6;
} }
.jtk-drag-selected .node-default { .jtk-endpoint.dot-output-endpoint {
/* https://www.cssmatic.com/box-shadow */ z-index: 7;
-webkit-box-shadow: 0px 0px 6px 2px rgba(50, 75, 216, 0.37);
-moz-box-shadow: 0px 0px 6px 2px rgba(50, 75, 216, 0.37);
box-shadow: 0px 0px 6px 2px rgba(50, 75, 216, 0.37);
} }
.disabled .node-icon img { .jtk-overlay {
-webkit-filter: contrast(40%) brightness(1.5) grayscale(100%); z-index: 7;
filter: contrast(40%) brightness(1.5) grayscale(100%); }
.disabled-linethrough {
z-index: 8;
}
.jtk-connector.jtk-dragging {
z-index: 8;
}
.jtk-drag-active.dot-output-endpoint, .jtk-drag-active.rect-input-endpoint {
z-index: 9;
}
.connection-actions {
z-index: 10;
}
.node-options {
z-index: 10;
}
.drop-add-node-label {
z-index: 10;
}
</style>
<style lang="scss">
$--stalklength: 40px;
$--box-size-medium: 24px;
$--box-size-small: 18px;
.plus-endpoint {
cursor: pointer;
.plus-stalk {
border-top: 2px solid var(--color-foreground-dark);
position: absolute;
width: $--stalklength;
height: 0;
right: 100%;
top: calc(50% - 1px);
pointer-events: none;
.connection-run-items-label {
position: relative;
width: 100%;
span {
display: none;
left: calc(50% + 4px);
}
}
}
.plus-container {
color: var(--color-foreground-xdark);
border: 2px solid var(--color-foreground-xdark);
background-color: var(--color-background-xlight);
border-radius: var(--border-radius-base);
height: $--box-size-medium;
width: $--box-size-medium;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-2xs);
position: absolute;
top: 0;
right: 0;
pointer-events: none;
&.small {
height: $--box-size-small;
width: $--box-size-small;
font-size: 8px;
}
.fa-plus {
width: 1em;
}
}
.drop-hover-message {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
line-height: var(--font-line-height-regular);
color: var(--color-text-light);
position: absolute;
top: -6px;
left: calc(100% + 8px);
width: 200px;
display: none;
}
&.hidden > * {
display: none;
}
&.success .plus-stalk {
border-color: var(--color-success-light);
span {
display: inline;
}
}
} }
</style> </style>

View file

@ -1,19 +1,36 @@
<template functional> <template>
<div :class="$style.category"> <div :class="$style.category">
<span :class="$style.name">{{ props.item.category }}</span> <span :class="$style.name">
{{ renderCategoryName(categoryName) }}
</span>
<font-awesome-icon <font-awesome-icon
:class="$style.arrow" :class="$style.arrow"
icon="chevron-down" icon="chevron-down"
v-if="props.item.properties.expanded" v-if="item.properties.expanded"
/> />
<font-awesome-icon :class="$style.arrow" icon="chevron-up" v-else /> <font-awesome-icon :class="$style.arrow" icon="chevron-up" v-else />
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
export default { import Vue from 'vue';
import camelcase from 'lodash.camelcase';
export default Vue.extend({
props: ['item'], props: ['item'],
}; computed: {
categoryName() {
return camelcase(this.item.category);
},
},
methods: {
renderCategoryName(categoryName: string) {
const key = `nodeCreator.categoryNames.${categoryName}`;
return this.$locale.exists(key) ? this.$locale.baseText(key) : categoryName;
},
},
});
</script> </script>

View file

@ -11,9 +11,9 @@
/> />
<div class="type-selector"> <div class="type-selector">
<el-tabs v-model="selectedType" stretch> <el-tabs v-model="selectedType" stretch>
<el-tab-pane label="All" :name="ALL_NODE_FILTER"></el-tab-pane> <el-tab-pane :label="$locale.baseText('nodeCreator.mainPanel.all')" :name="ALL_NODE_FILTER"></el-tab-pane>
<el-tab-pane label="Regular" :name="REGULAR_NODE_FILTER"></el-tab-pane> <el-tab-pane :label="$locale.baseText('nodeCreator.mainPanel.regular')" :name="REGULAR_NODE_FILTER"></el-tab-pane>
<el-tab-pane label="Trigger" :name="TRIGGER_NODE_FILTER"></el-tab-pane> <el-tab-pane :label="$locale.baseText('nodeCreator.mainPanel.trigger')" :name="TRIGGER_NODE_FILTER"></el-tab-pane>
</el-tabs> </el-tabs>
</div> </div>
<div v-if="searchFilter.length === 0" class="scrollable"> <div v-if="searchFilter.length === 0" class="scrollable">

View file

@ -4,27 +4,31 @@
<NoResultsIcon /> <NoResultsIcon />
</div> </div>
<div class="title"> <div class="title">
<div>We didn't make that... yet</div> <div>
{{ $locale.baseText('nodeCreator.noResults.weDidntMakeThatYet') }}
</div>
<div class="action"> <div class="action">
Dont worry, you can probably do it with the {{ $locale.baseText('nodeCreator.noResults.dontWorryYouCanProbablyDoItWithThe') }}
<a @click="selectHttpRequest">HTTP Request</a> or <a @click="selectHttpRequest">{{ $locale.baseText('nodeCreator.noResults.httpRequest') }}</a> or
<a @click="selectWebhook">Webhook</a> node <a @click="selectWebhook">{{ $locale.baseText('nodeCreator.noResults.webhook') }}</a> {{ $locale.baseText('nodeCreator.noResults.node') }}
</div> </div>
</div> </div>
<div class="request"> <div class="request">
<div>Want us to make it faster?</div> <div>
{{ $locale.baseText('nodeCreator.noResults.wantUsToMakeItFaster') }}
</div>
<div> <div>
<a <a
:href="REQUEST_NODE_FORM_URL" :href="REQUEST_NODE_FORM_URL"
target="_blank" target="_blank"
> >
<span>Request the node</span>&nbsp; <span>{{ $locale.baseText('nodeCreator.noResults.requestTheNode') }}</span>&nbsp;
<span> <span>
<font-awesome-icon <font-awesome-icon
class="external" class="external"
icon="external-link-alt" icon="external-link-alt"
title="Request the node" :title="$locale.baseText('nodeCreator.noResults.requestTheNode')"
/> />
</span> </span>
</a> </a>
@ -37,7 +41,6 @@
<script lang="ts"> <script lang="ts">
import { HTTP_REQUEST_NODE_TYPE, REQUEST_NODE_FORM_URL, WEBHOOK_NODE_TYPE } from '@/constants'; import { HTTP_REQUEST_NODE_TYPE, REQUEST_NODE_FORM_URL, WEBHOOK_NODE_TYPE } from '@/constants';
import Vue from 'vue'; import Vue from 'vue';
import NoResultsIcon from './NoResultsIcon.vue'; import NoResultsIcon from './NoResultsIcon.vue';
export default Vue.extend({ export default Vue.extend({

View file

@ -1,15 +1,25 @@
<template functional> <template>
<div :class="{[$style['node-item']]: true, [$style.bordered]: props.bordered}"> <div :class="{[$style['node-item']]: true, [$style.bordered]: bordered}">
<NodeIcon :class="$style['node-icon']" :nodeType="props.nodeType" /> <NodeIcon :class="$style['node-icon']" :nodeType="nodeType" />
<div> <div>
<div :class="$style.details"> <div :class="$style.details">
<span :class="$style.name">{{props.nodeType.displayName}}</span> <span :class="$style.name">
{{ $locale.headerText({
key: `headers.${shortNodeType}.displayName`,
fallback: nodeType.displayName,
})
}}
</span>
<span :class="$style['trigger-icon']"> <span :class="$style['trigger-icon']">
<TriggerIcon v-if="$options.isTrigger(props.nodeType)" /> <TriggerIcon v-if="$options.isTrigger(nodeType)" />
</span> </span>
</div> </div>
<div :class="$style.description"> <div :class="$style.description">
{{props.nodeType.description}} {{ $locale.headerText({
key: `headers.${shortNodeType}.description`,
fallback: nodeType.description,
})
}}
</div> </div>
</div> </div>
</div> </div>
@ -26,17 +36,24 @@ import TriggerIcon from '../TriggerIcon.vue';
Vue.component('NodeIcon', NodeIcon); Vue.component('NodeIcon', NodeIcon);
Vue.component('TriggerIcon', TriggerIcon); Vue.component('TriggerIcon', TriggerIcon);
export default { export default Vue.extend({
name: 'NodeItem',
props: [ props: [
'active', 'active',
'filter', 'filter',
'nodeType', 'nodeType',
'bordered', 'bordered',
], ],
computed: {
shortNodeType() {
return this.$locale.shortNodeType(this.nodeType.name);
},
},
// @ts-ignore
isTrigger (nodeType: INodeTypeDescription): boolean { isTrigger (nodeType: INodeTypeDescription): boolean {
return nodeType.group.includes('trigger'); return nodeType.group.includes('trigger');
}, },
}; });
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -5,7 +5,7 @@
</div> </div>
<div class="text"> <div class="text">
<input <input
placeholder="Search nodes..." :placeholder="$locale.baseText('nodeCreator.searchBar.searchNodes')"
ref="input" ref="input"
:value="value" :value="value"
@input="onInput" @input="onInput"

View file

@ -1,9 +1,11 @@
<template functional> <template>
<div :class="$style.subcategory"> <div :class="$style.subcategory">
<div :class="$style.details"> <div :class="$style.details">
<div :class="$style.title">{{ props.item.properties.subcategory }}</div> <div :class="$style.title">
<div v-if="props.item.properties.description" :class="$style.description"> {{ $locale.baseText(`nodeCreator.subcategoryNames.${subcategoryName}`) }}
{{ props.item.properties.description }} </div>
<div v-if="item.properties.description" :class="$style.description">
{{ $locale.baseText(`nodeCreator.subcategoryDescriptions.${subcategoryDescription}`) }}
</div> </div>
</div> </div>
<div :class="$style.action"> <div :class="$style.action">
@ -13,9 +15,21 @@
</template> </template>
<script lang="ts"> <script lang="ts">
export default { import Vue from 'vue';
import camelcase from 'lodash.camelcase';
export default Vue.extend({
props: ['item'], props: ['item'],
}; computed: {
subcategoryName() {
return camelcase(this.item.properties.subcategory);
},
subcategoryDescription() {
const firstWord = this.item.properties.description.split(' ').shift() || '';
return firstWord.toLowerCase().replace(/,/g, '');
},
},
});
</script> </script>

Some files were not shown because too many files have changed in this diff Show more