🔀 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
run: |
apt update -y
echo 'tzdata tzdata/Areas select Europe' | debconf-set-selections
echo 'tzdata tzdata/Zones/Europe select Paris' | debconf-set-selections
DEBIAN_FRONTEND="noninteractive" apt-get install -y graphicsmagick
sudo apt update -y
echo 'tzdata tzdata/Areas select Europe' | sudo debconf-set-selections
echo 'tzdata tzdata/Zones/Europe select Paris' | sudo debconf-set-selections
DEBIAN_FRONTEND="noninteractive" sudo apt-get install -y graphicsmagick
shell: bash
-
name: npm install and build
@ -75,19 +75,19 @@ jobs:
shell: bash
env:
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
-
name: Export credentials
if: always()
run: n8n/packages/cli/bin/n8n export:credentials --output=test-workflows/credentials.json --all --pretty
shell: bash
env:
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
-
name: Commit and push credential changes
if: always()
run: |
cd test-workflows
git config --global user.name 'n8n test bot'
git config --global user.email 'n8n-test-bot@users.noreply.github.com'
git commit -am "Automated credential update"
git push --force --quiet "https://janober:${{ secrets.TOKEN }}@github.com/n8n-io/test-workflows.git" main:main
# -
# name: Export credentials
# if: always()
# run: n8n/packages/cli/bin/n8n export:credentials --output=test-workflows/credentials.json --all --pretty
# shell: bash
# env:
# N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
# -
# name: Commit and push credential changes
# if: always()
# run: |
# cd test-workflows
# git config --global user.name 'n8n test bot'
# git config --global user.email 'n8n-test-bot@users.noreply.github.com'
# git commit -am "Automated credential update"
# 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
npm-debug.log*
lerna-debug.log
package-lock.json
yarn.lock
google-generated-credentials.json
_START_PACKAGE
@ -13,5 +12,5 @@ _START_PACKAGE
.vscode/*
!.vscode/extensions.json
.idea
vetur.config.js
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/
fi
chown -R node /home/node
if [ "$#" -gt 0 ]; then
# Got started with arguments
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) {
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()

View file

@ -11,6 +11,8 @@ import {
CredentialTypes,
Db,
ExternalHooks,
GenericHelpers,
InternalHooksManager,
IWorkflowBase,
IWorkflowExecutionDataProcess,
LoadNodesAndCredentials,
@ -123,6 +125,10 @@ export class Execute extends Command {
const externalHooks = ExternalHooks();
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
const nodeTypes = NodeTypes();
await nodeTypes.init(loadNodesAndCredentials.nodeTypes);

View file

@ -28,6 +28,8 @@ import {
CredentialTypes,
Db,
ExternalHooks,
GenericHelpers,
InternalHooksManager,
IWorkflowDb,
IWorkflowExecutionDataProcess,
LoadNodesAndCredentials,
@ -55,12 +57,12 @@ export class ExecuteBatch extends Command {
static executionTimeout = 3 * 60 * 1000;
static examples = [
`$ n8n executeAll`,
`$ n8n executeAll --concurrency=10 --skipList=/data/skipList.txt`,
`$ n8n executeAll --debug --output=/data/output.json`,
`$ n8n executeAll --ids=10,13,15 --shortOutput`,
`$ n8n executeAll --snapshot=/data/snapshots --shallow`,
`$ n8n executeAll --compare=/data/previousExecutionData --retries=2`,
`$ n8n executeBatch`,
`$ n8n executeBatch --concurrency=10 --skipList=/data/skipList.txt`,
`$ n8n executeBatch --debug --output=/data/output.json`,
`$ n8n executeBatch --ids=10,13,15 --shortOutput`,
`$ n8n executeBatch --snapshot=/data/snapshots --shallow`,
`$ n8n executeBatch --compare=/data/previousExecutionData --retries=2`,
];
static flags = {
@ -303,6 +305,10 @@ export class ExecuteBatch extends Command {
const externalHooks = ExternalHooks();
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
const nodeTypes = NodeTypes();
await nodeTypes.init(loadNodesAndCredentials.nodeTypes);
@ -813,10 +819,22 @@ export class ExecuteBatch extends Command {
const changes = diff(JSON.parse(contents), data, { keysOnly: true });
if (changes !== undefined) {
// we have structural changes. Report them.
executionResult.error = `Workflow may contain breaking changes`;
executionResult.changes = changes;
executionResult.executionStatus = 'error';
// 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.
executionResult.error = 'Workflow may contain breaking changes';
executionResult.changes = changes;
executionResult.executionStatus = 'error';
} else {
executionResult.error =
'Workflow contains new data that previously did not exist.';
executionResult.changes = changes;
executionResult.executionStatus = 'warning';
}
} else {
executionResult.executionStatus = 'success';
}
@ -838,7 +856,8 @@ export class ExecuteBatch extends Command {
}
}
} 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';
}
clearTimeout(timeoutTimer);

View file

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

View file

@ -153,17 +153,6 @@ export class Start extends Command {
LoggerProxy.init(logger);
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
const startDbInitPromise = Db.init().catch((error: Error) => {
logger.error(`There was an error initializing DB: "${error.message}"`);
@ -313,7 +302,8 @@ export class Start extends Command {
}
const instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId);
const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli);
await Server.start();

View file

@ -149,7 +149,8 @@ export class Webhook extends Command {
await startDbInitPromise;
const instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId);
const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli);
if (config.get('executions.mode') === 'queue') {
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 { 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';
@ -25,11 +25,13 @@ import {
GenericHelpers,
IBullJobData,
IBullJobResponse,
IBullWebhookResponse,
IExecutionFlattedDb,
InternalHooksManager,
LoadNodesAndCredentials,
NodeTypes,
ResponseHelper,
WebhookHelpers,
WorkflowExecuteAdditionalData,
} from '../src';
@ -172,6 +174,16 @@ export class Worker extends Command {
currentExecutionDb.workflowData,
{ 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;
let workflowExecute: WorkflowExecute;
@ -259,10 +271,10 @@ export class Worker extends Command {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
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 instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId, versions.cli);
console.info('\nn8n worker is now ready');
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

View file

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

View file

@ -5,11 +5,15 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { IRun } from 'n8n-workflow';
import { createDeferredPromise } from 'n8n-core';
import {
createDeferredPromise,
IDeferredPromise,
IExecuteResponsePromiseData,
IRun,
} from 'n8n-workflow';
import { ChildProcess } from 'child_process';
import { stringify } from 'flatted';
// eslint-disable-next-line import/no-extraneous-dependencies
import * as PCancelable from 'p-cancelable';
// eslint-disable-next-line import/no-cycle
@ -79,6 +83,7 @@ export class ActiveExecutions {
const execution = {
id: executionId,
data: stringify(executionData.executionData!),
waitTill: null,
};
@ -116,6 +121,28 @@ export class ActiveExecutions {
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
*
@ -193,6 +220,7 @@ export class ActiveExecutions {
this.activeExecutions[executionId].postExecutePromises.push(waitPromise);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
return waitPromise.promise();
}

View file

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

View file

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

View file

@ -9,9 +9,16 @@ import {
import { Telemetry } from './telemetry';
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 = {
version_cli: diagnosticInfo.versionCli,
db_type: diagnosticInfo.databaseType,
@ -25,7 +32,10 @@ export class InternalHooksClass implements IInternalHooksClass {
return Promise.all([
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,
work_area: answers.workArea,
other_work_area: answers.otherWorkArea,
company_industry: answers.companyIndustry,
other_company_industry: answers.otherCompanyIndustry,
});
}
async onWorkflowCreated(workflow: IWorkflowBase): Promise<void> {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow);
return this.telemetry.track('User created workflow', {
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> {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow);
return this.telemetry.track('User saved workflow', {
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 = {
workflow_id: workflow.id,
is_manual: false,
version_cli: this.versionCli,
};
if (runData !== undefined) {
@ -92,6 +111,8 @@ export class InternalHooksClass implements IInternalHooksClass {
if (properties.is_manual) {
const nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow);
properties.node_graph = nodeGraphResult.nodeGraph;
properties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph);
if (errorNodeName) {
properties.error_node_id = nodeGraphResult.nameIndices[errorNodeName];
}

View file

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

View file

@ -5,6 +5,7 @@
import {
INodeType,
INodeTypeData,
INodeTypeDescription,
INodeTypes,
INodeVersionedType,
NodeHelpers,
@ -18,7 +19,7 @@ class NodeTypesClass implements INodeTypes {
// polling nodes the polling times
// eslint-disable-next-line no-restricted-syntax
for (const nodeTypeData of Object.values(nodeTypes)) {
const nodeType = NodeHelpers.getVersionedTypeNode(nodeTypeData.type);
const nodeType = NodeHelpers.getVersionedNodeType(nodeTypeData.type);
const applyParameters = NodeHelpers.getSpecialNodeParameters(nodeType);
if (applyParameters.length) {
@ -39,8 +40,29 @@ class NodeTypesClass implements INodeTypes {
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 {
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 config from '../config';
// 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 {
private activeExecutions: ActiveExecutions.ActiveExecutions;
private jobQueue: Bull.Queue;
constructor() {
this.activeExecutions = ActiveExecutions.getInstance();
const prefix = config.get('queue.bull.prefix') as string;
const redisOptions = config.get('queue.bull.redis') as object;
// 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
// @ts-ignore
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> {

View file

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

View file

@ -24,8 +24,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-restricted-syntax */
/* 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 { 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 { FindManyOptions, getConnectionManager, In, IsNull, LessThanOrEqual, Not } from 'typeorm';
import * as bodyParser from 'body-parser';
@ -144,6 +148,7 @@ import { InternalHooksManager } from './InternalHooksManager';
import { TagEntity } from './databases/entities/TagEntity';
import { WorkflowEntity } from './databases/entities/WorkflowEntity';
import { NameRequest } from './WorkflowHelpers';
import { getNodeTranslationPath } from './TranslationHelpers';
require('body-parser-xml')(bodyParser);
@ -280,6 +285,7 @@ class App {
personalizationSurvey: {
shouldShow: false,
},
defaultLocale: config.get('defaultLocale'),
};
}
@ -679,6 +685,7 @@ class App {
// @ts-ignore
savedWorkflow.id = savedWorkflow.id.toString();
await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]);
void InternalHooksManager.getInstance().onWorkflowCreated(newWorkflow as IWorkflowBase);
return savedWorkflow;
},
@ -1150,13 +1157,13 @@ class App {
if (onlyLatest) {
allNodes.forEach((nodeData) => {
const nodeType = NodeHelpers.getVersionedTypeNode(nodeData);
const nodeType = NodeHelpers.getVersionedNodeType(nodeData);
const nodeInfo: INodeTypeDescription = getNodeDescription(nodeType);
returnData.push(nodeInfo);
});
} else {
allNodes.forEach((nodeData) => {
const allNodeTypes = NodeHelpers.getVersionedTypeNodeAll(nodeData);
const allNodeTypes = NodeHelpers.getVersionedNodeTypeAll(nodeData);
allNodeTypes.forEach((element) => {
const nodeInfo: INodeTypeDescription = getNodeDescription(element);
returnData.push(nodeInfo);
@ -1175,17 +1182,60 @@ class App {
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<INodeTypeDescription[]> => {
const nodeInfos = _.get(req, 'body.nodeInfos', []) as INodeTypeNameVersion[];
const nodeTypes = NodeTypes();
const returnData: INodeTypeDescription[] = [];
nodeInfos.forEach((nodeInfo) => {
const nodeType = nodeTypes.getByNameAndVersion(nodeInfo.name, nodeInfo.version);
if (nodeType?.description) {
returnData.push(nodeType.description);
const { defaultLocale } = this.frontendSettings;
if (defaultLocale === 'en') {
return nodeInfos.reduce<INodeTypeDescription[]>((acc, { name, version }) => {
const { description } = NodeTypes().getByNameAndVersion(name, version);
acc.push(description);
return acc;
}, []);
}
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
}
});
return returnData;
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[]> => {
const findQuery = {} as FindManyOptions;
if (req.query.filter) {
findQuery.where = JSON.parse(req.query.filter as string);
if ((findQuery.where! as IDataObject).id !== undefined) {
findQuery.where = JSON.parse(req.query.filter as string) as IDataObject;
if (findQuery.where.id !== undefined) {
// No idea if multiple where parameters make db search
// slower but to be sure that that is not the case we
// 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;
}
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;
}
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;
}
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(`Version: ${versions.cli}`);
const defaultLocale = config.get('defaultLocale');
if (defaultLocale !== 'en') {
console.log(`Locale: ${defaultLocale}`);
}
await app.externalHooks.run('n8n.ready', [app]);
const cpus = os.cpus();
const diagnosticInfo: IDiagnosticInfo = {
@ -2877,7 +2952,23 @@ export async function start(): Promise<void> {
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 @typescript-eslint/prefer-optional-chain */
/* eslint-disable @typescript-eslint/no-shadow */
@ -18,9 +19,13 @@ import { get } from 'lodash';
import { BINARY_ENCODING, NodeExecuteFunctions } from 'n8n-core';
import {
createDeferredPromise,
IBinaryKeyData,
IDataObject,
IDeferredPromise,
IExecuteData,
IExecuteResponsePromiseData,
IN8nHttpFullResponse,
INode,
IRunExecutionData,
IWebhookData,
@ -34,20 +39,20 @@ import {
} from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
ActiveExecutions,
GenericHelpers,
IExecutionDb,
IResponseCallbackData,
IWorkflowDb,
IWorkflowExecutionDataProcess,
ResponseHelper,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
WorkflowCredentials,
WorkflowExecuteAdditionalData,
WorkflowHelpers,
WorkflowRunner,
} from '.';
// eslint-disable-next-line import/no-cycle
import * as ActiveExecutions from './ActiveExecutions';
const activeExecutions = ActiveExecutions.getInstance();
/**
@ -91,6 +96,35 @@ export function getWorkflowWebhooks(
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
*
@ -169,7 +203,7 @@ export async function executeWebhook(
200,
) 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
// the default that people know as early as possible (probably already testing phase)
// that something does not resolve properly.
@ -356,9 +390,52 @@ export async function executeWebhook(
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
const workflowRunner = new WorkflowRunner();
executionId = await workflowRunner.run(runData, true, !didSendResponse, executionId);
executionId = await workflowRunner.run(
runData,
true,
!didSendResponse,
executionId,
responsePromise,
);
Logger.verbose(
`Started execution of workflow "${workflow.name}" from webhook with execution ID ${executionId}`,
@ -398,6 +475,20 @@ export async function executeWebhook(
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 (!didSendResponse) {
responseCallback(null, {

View file

@ -64,7 +64,13 @@ export function registerProductionWebhooks() {
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;
}
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;
}
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;
}
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;
}
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;
}
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,
fullRunData,
this.mode,
undefined,
this.executionId,
this.retryOf,
);
}
@ -585,7 +585,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
this.workflowData,
fullRunData,
this.mode,
undefined,
this.executionId,
this.retryOf,
);
}
@ -635,7 +635,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
this.workflowData,
fullRunData,
this.mode,
undefined,
this.executionId,
this.retryOf,
);
}
@ -676,7 +676,13 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
});
}
} 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
export function sendMessageToUI(source: string, message: any) {
export function sendMessageToUI(source: string, messages: any[]) {
if (this.sessionId === undefined) {
return;
}
@ -936,7 +942,7 @@ export function sendMessageToUI(source: string, message: any) {
'sendConsoleMessage',
{
source: `Node: "${source}"`,
message,
messages,
},
this.sessionId,
);

View file

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

View file

@ -10,6 +10,7 @@ import { IProcessMessage, UserSettings, WorkflowExecute } from 'n8n-core';
import {
ExecutionError,
IDataObject,
IExecuteResponsePromiseData,
IExecuteWorkflowInfo,
ILogger,
INodeExecutionData,
@ -30,9 +31,11 @@ import {
CredentialTypes,
Db,
ExternalHooks,
GenericHelpers,
IWorkflowExecuteProcess,
IWorkflowExecutionDataProcessWithExecution,
NodeTypes,
WebhookHelpers,
WorkflowExecuteAdditionalData,
WorkflowHelpers,
} from '.';
@ -135,7 +138,8 @@ export class WorkflowRunnerProcess {
await externalHooks.init();
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.
// 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,
);
additionalData.hooks = this.getProcessForwardHooks();
additionalData.hooks.hookFunctions.sendResponse = [
async (response: IExecuteResponsePromiseData): Promise<void> => {
await sendToParentProcess('sendResponse', {
response: WebhookHelpers.encodeWebhookResponse(response),
});
},
];
additionalData.executionId = inputData.executionId;
// 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 config = require('../../../../config');
import { MigrationHelpers } from '../../MigrationHelpers';
// replacing the credentials in workflows and execution
// `nodeType: name` changes to `nodeType: { id, name }`
@ -8,58 +9,100 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
name = 'UpdateWorkflowCredentials1630451444017';
public async up(queryRunner: QueryRunner): Promise<void> {
console.log('Start migration', this.name);
console.time(this.name);
const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM ${tablePrefix}credentials_entity
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
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}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
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) {
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(`
SELECT id, workflowData
@ -68,8 +111,8 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
ORDER BY startedAt DESC
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
@ -78,7 +121,6 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
@ -92,77 +134,124 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
console.timeEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM ${tablePrefix}credentials_entity
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
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;
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
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;
}
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);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
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) {
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(`
SELECT id, workflowData
@ -171,8 +260,8 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
ORDER BY startedAt DESC
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
@ -200,15 +289,15 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
}

View file

@ -1,5 +1,6 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config');
import { MigrationHelpers } from '../../MigrationHelpers';
// replacing the credentials in workflows and execution
// `nodeType: name` changes to `nodeType: { id, name }`
@ -8,62 +9,104 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
name = 'UpdateWorkflowCredentials1630419189837';
public async up(queryRunner: QueryRunner): Promise<void> {
console.log('Start migration', this.name);
console.time(this.name);
let tablePrefix = config.get('database.tablePrefix');
const schema = config.get('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM ${tablePrefix}credentials_entity
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
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}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
const waitingExecutionsQuery = `
SELECT id, "workflowData"
FROM ${tablePrefix}execution_entity
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(`
SELECT id, "workflowData"
@ -73,7 +116,8 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @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> {
@ -115,62 +160,109 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM ${tablePrefix}credentials_entity
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
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(
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @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;
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;
}
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);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
const waitingExecutionsQuery = `
SELECT id, "workflowData"
FROM ${tablePrefix}execution_entity
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(`
SELECT id, "workflowData"
@ -179,8 +271,8 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
ORDER BY "startedAt" DESC
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
@ -208,15 +300,15 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
UPDATE ${tablePrefix}execution_entity
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
}

View file

@ -1,5 +1,6 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config');
import { MigrationHelpers } from '../../MigrationHelpers';
// replacing the credentials in workflows and execution
// `nodeType: name` changes to `nodeType: { id, name }`
@ -8,58 +9,101 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
name = 'UpdateWorkflowCredentials1630330987096';
public async up(queryRunner: QueryRunner): Promise<void> {
console.log('Start migration', this.name);
console.time(this.name);
const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM "${tablePrefix}credentials_entity"
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM "${tablePrefix}workflow_entity"
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes);
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id || null, name };
credentialsUpdated = true;
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes);
let credentialsUpdated = false;
// @ts-ignore
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}workflow_entity"
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}workflow_entity"
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
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 = 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;
}
}
}
});
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"
@ -68,8 +112,8 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
ORDER BY "startedAt" DESC
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = JSON.parse(execution.workflowData);
let credentialsUpdated = false;
// @ts-ignore
@ -78,12 +122,11 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(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;
}
}
@ -92,77 +135,127 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
console.timeEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM "${tablePrefix}credentials_entity"
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM "${tablePrefix}workflow_entity"
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes);
let credentialsUpdated = false;
await helpers.runChunked(workflowsQuery, (workflows) => {
// @ts-ignore
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;
workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes);
let credentialsUpdated = false;
// @ts-ignore
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;
}
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);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}workflow_entity"
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
const waitingExecutionsQuery = `
SELECT id, "workflowData"
FROM "${tablePrefix}execution_entity"
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(`
SELECT id, "workflowData"
@ -172,7 +265,8 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = JSON.parse(execution.workflowData);
let credentialsUpdated = false;
// @ts-ignore
@ -181,10 +275,9 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
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,
// @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;
@ -200,15 +293,15 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
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 { getLogger } from '../Logger';
interface IExecutionCountsBufferItem {
manual_success_count: number;
manual_error_count: number;
prod_success_count: number;
prod_error_count: number;
}
type CountBufferItemKey =
| 'manual_success_count'
| 'manual_error_count'
| 'prod_success_count'
| '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 {
[workflowId: string]: IExecutionCountsBufferItem;
}
type IFirstExecutions = {
[key in FirstExecutionItemKey]: Date | undefined;
};
interface IExecutionsBuffer {
counts: IExecutionCountsBuffer;
firstExecutions: IFirstExecutions;
}
export class Telemetry {
private client?: TelemetryClient;
private instanceId: string;
private versionCli: string;
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.versionCli = versionCli;
const enabled = config.get('diagnostics.enabled') as boolean;
if (enabled) {
@ -53,33 +82,41 @@ export class Telemetry {
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', {
version_cli: this.versionCli,
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[workflowId].prod_error_count = 0;
this.executionCountsBuffer[workflowId].prod_success_count = 0;
this.executionCountsBuffer.counts[workflowId].manual_error_count = 0;
this.executionCountsBuffer.counts[workflowId].manual_success_count = 0;
this.executionCountsBuffer.counts[workflowId].prod_error_count = 0;
this.executionCountsBuffer.counts[workflowId].prod_success_count = 0;
return promise;
});
allPromises.push(this.track('pulse'));
allPromises.push(this.track('pulse', { version_cli: this.versionCli }));
return Promise.all(allPromises);
}
async trackWorkflowExecution(properties: IDataObject): Promise<void> {
if (this.client) {
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_success_count: 0,
prod_error_count: 0,
prod_success_count: 0,
};
let countKey: CountBufferItemKey;
let firstExecKey: FirstExecutionItemKey;
if (
properties.success === false &&
properties.error_node_type &&
@ -89,15 +126,28 @@ export class Telemetry {
void this.track('Workflow execution errored', properties);
if (properties.is_manual) {
this.executionCountsBuffer[workflowId].manual_error_count++;
firstExecKey = 'first_manual_error';
countKey = 'manual_error_count';
} else {
this.executionCountsBuffer[workflowId].prod_error_count++;
firstExecKey = 'first_prod_error';
countKey = 'prod_error_count';
}
} else if (properties.is_manual) {
this.executionCountsBuffer[workflowId].manual_success_count++;
countKey = 'manual_success_count';
firstExecKey = 'first_manual_success';
} 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(
{
userId: this.instanceId,
anonymousId: '000000000000',
traits: {
...traits,
instanceId: this.instanceId,
@ -139,6 +190,7 @@ export class Telemetry {
this.client.track(
{
userId: this.instanceId,
anonymousId: '000000000000',
event: eventName,
// @ts-ignore
properties,

View file

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

View file

@ -22,6 +22,7 @@ import {
ICredentialsExpressionResolveValues,
IDataObject,
IExecuteFunctions,
IExecuteResponsePromiseData,
IExecuteSingleFunctions,
IExecuteWorkflowInfo,
IHttpRequestOptions,
@ -71,7 +72,7 @@ import { fromBuffer } from 'file-type';
import { lookup } from 'mime-types';
import axios, { AxiosProxyConfig, AxiosRequestConfig, Method } from 'axios';
import { URLSearchParams } from 'url';
import { URL, URLSearchParams } from 'url';
// eslint-disable-next-line import/no-cycle
import {
BINARY_ENCODING,
@ -86,6 +87,14 @@ import {
axios.defaults.timeout = 300000;
// Prevent axios from adding x-form-www-urlencoded headers by default
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({
timeout: 300000, // 5 minutes
@ -128,6 +137,28 @@ function searchForHeader(headers: IDataObject, headerName: string) {
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) {
// This function is a temporary implementation
// That translates all http requests done via
@ -192,6 +223,7 @@ async function parseRequestObject(requestObject: IDataObject) {
delete axiosConfig.headers[contentTypeHeaderKeyName];
const headers = axiosConfig.data.getHeaders();
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
await generateContentLengthHeader(axiosConfig.data, axiosConfig.headers);
} else {
// When using the `form` property it means the content should be x-www-form-urlencoded.
if (requestObject.form !== undefined && requestObject.body === undefined) {
@ -228,6 +260,7 @@ async function parseRequestObject(requestObject: IDataObject) {
// Mix in headers as FormData creates the boundary.
const headers = axiosConfig.data.getHeaders();
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
await generateContentLengthHeader(axiosConfig.data, axiosConfig.headers);
} else if (requestObject.body !== undefined) {
// If we have body and possibly form
if (requestObject.form !== undefined) {
@ -338,7 +371,63 @@ async function parseRequestObject(requestObject: IDataObject) {
}
if (requestObject.proxy !== undefined) {
axiosConfig.proxy = requestObject.proxy as AxiosProxyConfig;
// 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;
}
}
if (requestObject.encoding === null) {
@ -357,6 +446,7 @@ async function parseRequestObject(requestObject: IDataObject) {
if (
requestObject.json !== false &&
axiosConfig.data !== undefined &&
axiosConfig.data !== '' &&
!(axiosConfig.data instanceof Buffer) &&
!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'content-type')
) {
@ -406,6 +496,11 @@ async function proxyRequestToAxios(
axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject));
Logger.debug('Proxying request to axios', {
originalConfig: configObject,
parsedConfig: axiosConfig,
});
return new Promise((resolve, reject) => {
axios(axiosConfig)
.then((response) => {
@ -438,17 +533,17 @@ async function proxyRequestToAxios(
}
})
.catch((error) => {
if (configObject.simple === true && error.response) {
resolve({
body: error.response.data,
headers: error.response.headers,
statusCode: error.response.status,
statusMessage: error.response.statusText,
});
return;
}
if (configObject.simple === false && error.response) {
resolve(error.response.data);
if (configObject.resolveWithFullResponse) {
resolve({
body: error.response.data,
headers: error.response.headers,
statusCode: error.response.status,
statusMessage: error.response.statusText,
});
} else {
resolve(error.response.data);
}
return;
}
@ -1554,19 +1649,22 @@ export function getExecuteFunctions(
async putExecutionToWait(waitTill: Date): Promise<void> {
runExecutionData.waitTill = waitTill;
},
sendMessageToUI(message: any): void {
sendMessageToUI(...args: any[]): void {
if (mode !== 'manual') {
return;
}
try {
if (additionalData.sendMessageToUI) {
additionalData.sendMessageToUI(node.name, message);
additionalData.sendMessageToUI(node.name, args);
}
} catch (error) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
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: {
httpRequest,
prepareBinaryData,

View file

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

View file

@ -4,6 +4,7 @@ import {
ICredentialDataDecryptedObject,
ICredentialsHelper,
IDataObject,
IDeferredPromise,
IExecuteWorkflowInfo,
INodeCredentialsDetails,
INodeExecutionData,
@ -20,7 +21,7 @@ import {
WorkflowHooks,
} from 'n8n-workflow';
import { Credentials, IDeferredPromise, IExecuteFunctions } from '../src';
import { Credentials, IExecuteFunctions } from '../src';
export class CredentialsHelper extends ICredentialsHelper {
getDecrypted(
@ -615,10 +616,7 @@ class NodeTypesClass implements INodeTypes {
name: 'dotNotation',
type: 'boolean',
default: true,
description: `By default does dot-notation get used in property names..<br />
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.
`,
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>`,
},
],
},
@ -725,7 +723,7 @@ class NodeTypesClass implements INodeTypes {
async init(nodeTypes: INodeTypeData): Promise<void> {}
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 {
@ -733,7 +731,7 @@ class NodeTypesClass implements INodeTypes {
}
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';

View file

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

View file

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

View file

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

View file

@ -13,7 +13,7 @@ export default {
color: {
control: {
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: {
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: {

View file

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

View file

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

View file

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

View file

@ -1,13 +1,13 @@
<template functional>
<div :class="$style.inputLabel">
<div :class="props.label ? $style.label: ''">
<component v-if="props.label" :is="$options.components.N8nText" :bold="true">
<div :class="{[$style.inputLabelContainer]: !props.labelHoverableOnly}">
<div :class="{[$style.inputLabel]: props.labelHoverableOnly, [$options.methods.getLabelClass(props, $style)]: true}">
<component v-if="props.label" :is="$options.components.N8nText" :bold="props.bold" :size="props.size" :compact="!props.underline">
{{ 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>
<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.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>
</component>
</span>
@ -40,34 +40,104 @@ export default {
required: {
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: {
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>
<style lang="scss" module>
.inputLabel {
&:hover {
--info-icon-display: inline-block;
.inputLabelContainer:hover {
> div > .infoIcon {
display: inline-block;
}
}
.label {
margin-bottom: var(--spacing-2xs);
* {
margin-right: var(--spacing-4xs);
.inputLabel:hover {
> .infoIcon {
display: inline-block;
}
}
.infoIcon {
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 {
max-width: 400px;
li {
margin-left: var(--spacing-s);
}
}
</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: {
control: {
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: {
type: String,
default: 'medium',
validator: (value: string): boolean => ['large', 'medium', 'small'].includes(value),
validator: (value: string): boolean => ['mini', 'small', 'medium', 'large', 'xlarge'].includes(value),
},
color: {
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: {
type: String,
validator: (value: string): boolean => ['right', 'left', 'center'].includes(value),
},
compact: {
type: Boolean,
default: false,
},
},
methods: {
getClass(props: {size: string, bold: boolean}) {
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;
if (props.color) {
styles.color = `var(--color-${props.color})`;
}
if (props.compact) {
styles['line-height'] = 1;
}
if (props.align) {
styles['text-align'] = props.align;
}
@ -54,6 +61,22 @@ export default Vue.extend({
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 {
font-size: var(--font-size-m);
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 */
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 N8nSelect from './N8nSelect';
import N8nSpinner from './N8nSpinner';
import N8nSquareButton from './N8nSquareButton';
import N8nText from './N8nText';
import N8nTooltip from './N8nTooltip';
import N8nOption from './N8nOption';
@ -27,6 +28,7 @@ export {
N8nMenuItem,
N8nSelect,
N8nSpinner,
N8nSquareButton,
N8nText,
N8nTooltip,
N8nOption,

View file

@ -16,7 +16,7 @@ import VariableTable from './VariableTable.vue';
<Canvas>
<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: {
VariableTable,
},

View file

@ -44,7 +44,7 @@ import ColorCircles from './ColorCircles.vue';
<Canvas>
<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: {
ColorCircles,
},
@ -109,7 +109,7 @@ import ColorCircles from './ColorCircles.vue';
<Canvas>
<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: {
ColorCircles,
},
@ -129,3 +129,16 @@ import ColorCircles from './ColorCircles.vue';
}}
</Story>
</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)
);
--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-s: 77%;
--color-warning-l: 57%;
@ -187,6 +196,24 @@
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-s: 20%;
--color-foreground-base-l: 88.2%;
@ -259,6 +286,25 @@
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-base: 4px;
--border-radius-small: 2px;

View file

@ -83,6 +83,17 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0.35);
--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) {
--button-padding-vertical: var(--spacing-3xs);
--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);
}
}
@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) {
padding: var.$dialog-padding-primary;
color: var(--color-text-base);
font-size: var.$dialog-content-font-size;
word-break: break-all;
}
@include mixins.e(footer) {

View file

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

View file

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

View file

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

View file

@ -22,32 +22,72 @@ import {
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
PaintStyle,
} from 'jsplumb';
declare module 'jsplumb' {
interface PaintStyle {
stroke?: string;
fill?: string;
strokeWidth?: number;
outlineStroke?: string;
outlineWidth?: number;
}
interface Anchor {
lastReturnValue: number[];
}
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, callback: Function): void; // tslint:disable-line:no-any
bind(event: string, callback: Function): void;
removeOverlay(name: string): void;
removeOverlays(): void;
setParameter(name: string, value: any): void; // tslint:disable-line:no-any
setPaintStyle(arg0: PaintStyle): void;
addOverlay(arg0: any[]): void; // tslint:disable-line:no-any
setConnector(arg0: any[]): void; // tslint:disable-line:no-any
getUuids(): [string, string];
}
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
repaint(params?: object): void;
}
interface N8nPlusEndpoint extends Endpoint {
setSuccessOutput(message: string): void;
clearSuccessOutput(): void;
}
interface Overlay {
setVisible(visible: boolean): void;
setLocation(location: number): void;
canvas?: HTMLElement;
}
interface OnConnectionBindInfo {
@ -66,18 +106,15 @@ export interface IEndpointOptions {
dragProxy?: any; // tslint:disable-line:no-any
endpoint?: string;
endpointStyle?: object;
endpointHoverStyle?: object;
isSource?: boolean;
isTarget?: boolean;
maxConnections?: number;
overlays?: any; // tslint:disable-line:no-any
parameters?: any; // tslint:disable-line:no-any
uuid?: string;
}
export interface IConnectionsUi {
[key: string]: {
[key: string]: IEndpointOptions;
};
enabled?: boolean;
cssClass?: string;
}
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 interface INodeUi extends INode {
position: XYPositon;
position: XYPosition;
color?: string;
notes?: string;
issues?: INodeIssues;
_jsPlumb?: {
endpoints?: {
[key: string]: IEndpointOptions[];
};
};
name: string;
}
export interface INodeTypesMaxCount {
@ -130,6 +163,7 @@ export interface IRestApi {
getPastExecutions(filter: object, limit: number, lastId?: string | number, firstId?: string | number): Promise<IExecutionsListResponse>;
stopCurrentExecution(executionId: string): Promise<IExecutionsStopData>;
makeRestApiRequest(method: string, endpoint: string, data?: any): Promise<any>; // tslint:disable-line:no-any
getNodeTranslationHeaders(): Promise<INodeTranslationHeaders>;
getNodeTypes(onlyLatest?: boolean): Promise<INodeTypeDescription[]>;
getNodesInformation(nodeInfos: INodeTypeNameVersion[]): Promise<INodeTypeDescription[]>;
getNodeParameterOptions(nodeTypeAndVersion: INodeTypeNameVersion, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise<INodePropertyOptions[]>;
@ -147,6 +181,15 @@ export interface IRestApi {
getTimezones(): Promise<IDataObject>;
}
export interface INodeTranslationHeaders {
data: {
[key: string]: {
displayName: string;
description: string;
},
};
}
export interface IBinaryDisplayData {
index: number;
key: string;
@ -428,7 +471,7 @@ export interface IPushDataTestWebhook {
export interface IPushDataConsoleMessage {
source: string;
message: string;
messages: string[];
}
export interface IVersionNotificationSettings {
@ -437,10 +480,15 @@ export interface IVersionNotificationSettings {
infoUrl: string;
}
export type IPersonalizationSurveyKeys = 'companySize' | 'codingSkill' | 'workArea' | 'otherWorkArea';
export type IPersonalizationSurveyKeys = 'codingSkill' | 'companyIndustry' | 'companySize' | 'otherCompanyIndustry' | 'otherWorkArea' | 'workArea';
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 {
@ -448,6 +496,21 @@ export interface IPersonalizationSurvey {
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 {
endpointWebhook: string;
endpointWebhookTest: string;
@ -470,6 +533,7 @@ export interface IN8nUISettings {
instanceId: string;
personalizationSurvey?: IPersonalizationSurvey;
telemetry: ITelemetrySettings;
defaultLocale: string;
}
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
@ -583,6 +647,8 @@ export interface IRootState {
activeActions: string[];
activeNode: string | null;
baseUrl: string;
credentialTextRenderKeys: { nodeType: string; credentialType: string; } | null;
defaultLocale: string;
endpointWebhook: string;
endpointWebhookTest: string;
executionId: string | null;
@ -604,7 +670,7 @@ export interface IRootState {
lastSelectedNodeOutputIndex: number | null;
nodeIndex: Array<string | null>;
nodeTypes: INodeTypeDescription[];
nodeViewOffsetPosition: XYPositon;
nodeViewOffsetPosition: XYPosition;
nodeViewMoveInProgress: boolean;
selectedNodes: INodeUi[];
sessionId: string;
@ -652,6 +718,7 @@ export interface IUiState {
export interface ISettingsState {
settings: IN8nUISettings;
promptsData: IN8nPrompts;
}
export interface IVersionsState {
@ -670,5 +737,12 @@ export interface IRestApiContext {
export interface IZoomConfig {
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) {
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 { IRestApiContext, IN8nUISettings, IPersonalizationSurveyAnswers } from '../Interface';
import { makeRestApiRequest } from './helpers';
import { IRestApiContext, IN8nPrompts, IN8nValueSurveyData, IN8nUISettings, IPersonalizationSurveyAnswers } from '../Interface';
import { makeRestApiRequest, get, post } from './helpers';
import { TEMPLATES_BASE_URL } from '@/constants';
export async function getSettings(context: IRestApiContext): Promise<IN8nUISettings> {
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);
}
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,18 +1,18 @@
<template>
<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>
<el-row>
<el-col :span="8" class="info-name">
n8n Version:
{{ $locale.baseText('about.n8nVersion') }}
</el-col>
<el-col :span="16">
{{versionCli}}
{{ versionCli }}
</el-col>
</el-row>
<el-row>
<el-col :span="8" class="info-name">
Source Code:
{{ $locale.baseText('about.sourceCode') }}
</el-col>
<el-col :span="16">
<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-col :span="8" class="info-name">
License:
{{ $locale.baseText('about.license') }}
</el-col>
<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-row>
<div class="action-buttons">
<n8n-button @click="closeDialog" label="Close" />
<n8n-button @click="closeDialog" :label="$locale.baseText('about.close')" />
</div>
</div>
</el-dialog>
@ -67,6 +69,7 @@ export default mixins(
<style scoped lang="scss">
.n8n-about {
font-size: var(--font-size-s);
.el-row {
padding: 0.25em 0;
}

View file

@ -4,18 +4,18 @@
@click.stop="closeWindow"
size="small"
class="binary-data-window-back"
title="Back to overview page"
:title="$locale.baseText('binaryDataDisplay.backToOverviewPage')"
icon="arrow-left"
label="Back to list"
:label="$locale.baseText('binaryDataDisplay.backToList')"
/>
<div class="binary-data-window-wrapper">
<div v-if="!binaryData">
Data to display did not get found
{{ $locale.baseText('binaryDataDisplay.noDataFoundToDisplay') }}
</div>
<video v-else-if="binaryData.mimeType && binaryData.mimeType.startsWith('video/')" controls autoplay>
<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>
<embed v-else :src="'data:' + binaryData.mimeType + ';base64,' + binaryData.data" class="binary-data" :class="embedClass"/>
</div>

View file

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

View file

@ -2,10 +2,10 @@
<div @keydown.stop class="collection-parameter">
<div class="collection-parameter-wrapper">
<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>
<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">
<n8n-button
@ -19,7 +19,7 @@
<n8n-option
v-for="item in parameterOptions"
:key="item.name"
:label="item.displayName"
:label="$locale.nodeText().collectionOptionDisplayName(parameter, item)"
:value="item.name">
</n8n-option>
</n8n-select>
@ -67,7 +67,8 @@ export default mixins(
},
computed: {
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[] {
const returnProperties = [];
@ -184,14 +185,14 @@ export default mixins(
<style lang="scss">
.collection-parameter {
padding-left: 2em;
padding-left: var(--spacing-s);
.param-options {
padding-top: 0.5em;
margin-top: var(--spacing-xs);
}
.no-items-exist {
margin: 0.8em 0 0.4em 0;
margin: var(--spacing-xs) 0;
}
.option {
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.$showMessage({
title: 'Copied',
title: this.$locale.baseText('credentialsEdit.showMessage.title'),
message: this.$props.successMessage,
type: 'success',
});
@ -53,6 +53,7 @@ export default mixins(copyPaste, showMessage).extend({
span {
font-family: Monaco, Consolas;
line-height: 1.5;
font-size: var(--font-size-s);
}
padding: var(--spacing-xs);

View file

@ -3,17 +3,17 @@
<banner
v-show="showValidationWarning"
theme="danger"
message="Please check the errors below"
:message="$locale.baseText('credentialEdit.credentialConfig.pleaseCheckTheErrorsBelow')"
/>
<banner
v-if="authError && !showValidationWarning"
theme="danger"
message="Couldnt connect with these settings"
:message="$locale.baseText('credentialEdit.credentialConfig.couldntConnectWithTheseSettings')"
:details="authError"
buttonLabel="Retry"
:buttonLabel="$locale.baseText('credentialEdit.credentialConfig.retry')"
buttonLoadingLabel="Retrying"
buttonTitle="Retry credentials test"
:buttonTitle="$locale.baseText('credentialEdit.credentialConfig.retryCredentialTest')"
:buttonLoading="isRetesting"
@click="$emit('retest')"
/>
@ -21,35 +21,37 @@
<banner
v-show="showOAuthSuccessBanner && !showValidationWarning"
theme="success"
message="Account connected"
buttonLabel="Reconnect"
buttonTitle="Reconnect OAuth Credentials"
:message="$locale.baseText('credentialEdit.credentialConfig.accountConnected')"
:buttonLabel="$locale.baseText('credentialEdit.credentialConfig.reconnect')"
:buttonTitle="$locale.baseText('credentialEdit.credentialConfig.reconnectOAuth2Credential')"
@click="$emit('oauth')"
/>
<banner
v-show="testedSuccessfully && !showValidationWarning"
theme="success"
message="Connection tested successfully"
buttonLabel="Retry"
buttonLoadingLabel="Retrying"
buttonTitle="Retry credentials test"
:message="$locale.baseText('credentialEdit.credentialConfig.connectionTestedSuccessfully')"
:buttonLabel="$locale.baseText('credentialEdit.credentialConfig.retry')"
:buttonLoadingLabel="$locale.baseText('credentialEdit.credentialConfig.retrying')"
:buttonTitle="$locale.baseText('credentialEdit.credentialConfig.retryCredentialTest')"
:buttonLoading="isRetesting"
@click="$emit('retest')"
/>
<n8n-info-tip v-if="documentationUrl && credentialProperties.length">
Need help filling out these fields?
<a :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">Open docs</a>
{{ $locale.baseText('credentialEdit.credentialConfig.needHelpFillingOutTheseFields') }}
<a :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">
{{ $locale.baseText('credentialEdit.credentialConfig.openDocs') }}
</a>
</n8n-info-tip>
<CopyInput
v-if="isOAuthType && credentialProperties.length"
label="OAuth Redirect URL"
:label="$locale.baseText('credentialEdit.credentialConfig.oAuthRedirectUrl')"
:copyContent="oAuthCallbackUrl"
copyButtonText="Click to copy"
:subtitle="`In ${appName}, use the URL above when prompted to enter an OAuth callback or redirect URL`"
successMessage="Redirect URL copied to clipboard"
:copyButtonText="$locale.baseText('credentialEdit.credentialConfig.clickToCopy')"
:subtitle="$locale.baseText('credentialEdit.credentialConfig.subtitle', { interpolate: { appName } })"
:successMessage="$locale.baseText('credentialEdit.credentialConfig.redirectUrlCopiedToClipboard')"
/>
<CredentialInputs
@ -70,7 +72,7 @@
</template>
<script lang="ts">
import { ICredentialType } from 'n8n-workflow';
import { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
import { getAppNameFromCredType } from '../helpers';
import Vue from 'vue';
@ -78,8 +80,11 @@ import Banner from '../Banner.vue';
import CopyInput from '../CopyInput.vue';
import CredentialInputs from './CredentialInputs.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',
components: {
Banner,
@ -89,6 +94,7 @@ export default Vue.extend({
},
props: {
credentialType: {
type: Object,
},
credentialProperties: {
type: Array,
@ -121,6 +127,12 @@ export default Vue.extend({
type: Boolean,
},
},
async beforeMount() {
if (this.$store.getters.defaultLocale !== 'en') {
await this.findCredentialTextRenderKeys();
await this.addNodeTranslationForCredential();
}
},
computed: {
appName(): string {
if (!this.credentialType) {
@ -131,7 +143,7 @@ export default Vue.extend({
(this.credentialType as ICredentialType).displayName,
);
return appName || "the service you're connecting to";
return appName || this.$locale.baseText('credentialEdit.credentialConfig.theServiceYouReConnectingTo');
},
credentialTypeName(): string {
return (this.credentialType as ICredentialType).name;
@ -165,6 +177,57 @@ export default Vue.extend({
},
},
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 {
this.$emit('change', event);
},

View file

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

View file

@ -2,7 +2,9 @@
<div :class="$style.container">
<el-row>
<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 :span="16">
<div
@ -11,7 +13,10 @@
:class="$style.valueLabel"
>
<el-checkbox
:label="node.displayName"
:label="$locale.headerText({
key: `headers.${shortNodeType(node)}.displayName`,
fallback: node.displayName,
})"
:value="!!nodeAccess[node.name]"
@change="(val) => onNodeAccessChange(node.name, val)"
/>
@ -20,26 +25,32 @@
</el-row>
<el-row v-if="currentCredential">
<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 :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-row>
<el-row v-if="currentCredential">
<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 :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-row>
<el-row v-if="currentCredential">
<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 :span="16" :class="$style.valueLabel">
<span>{{currentCredential.id}}</span>
<n8n-text :compact="true">{{ currentCredential.id }}</n8n-text>
</el-col>
</el-row>
</div>
@ -49,6 +60,7 @@
import Vue from 'vue';
import TimeAgo from '../TimeAgo.vue';
import { INodeTypeDescription } from 'n8n-workflow';
export default Vue.extend({
name: 'CredentialInfo',
@ -63,6 +75,9 @@ export default Vue.extend({
value,
});
},
shortNodeType(nodeType: INodeTypeDescription) {
return this.$locale.shortNodeType(nodeType.name);
},
},
});
</script>

View file

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

View file

@ -2,32 +2,32 @@
<Modal
:name="CREDENTIAL_LIST_MODAL_KEY"
width="80%"
title="Credentials"
:title="$locale.baseText('credentialsList.credentials')"
>
<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">
<n8n-button
title="Create New Credentials"
:title="$locale.baseText('credentialsList.createNewCredential')"
icon="plus"
label="Add New"
:label="$locale.baseText('credentialsList.addNew')"
size="large"
@click="createCredential()"
/>
</div>
<el-table :data="credentialsToDisplay" :default-sort = "{prop: 'name', order: 'ascending'}" stripe max-height="450" @row-click="editCredential">
<el-table-column property="name" label="Name" class-name="clickable" sortable></el-table-column>
<el-table-column property="type" label="Type" class-name="clickable" sortable></el-table-column>
<el-table-column property="createdAt" label="Created" class-name="clickable" sortable></el-table-column>
<el-table-column property="updatedAt" label="Updated" class-name="clickable" sortable></el-table-column>
<el-table-column property="name" :label="$locale.baseText('credentialsList.name')" 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="$locale.baseText('credentialsList.created')" 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
label="Operations"
:label="$locale.baseText('credentialsList.operations')"
width="120">
<template slot-scope="scope">
<div class="cred-operations">
<n8n-icon-button title="Edit Credentials" @click.stop="editCredential(scope.row)" icon="pen" />
<n8n-icon-button title="Delete Credentials" @click.stop="deleteCredential(scope.row)" icon="trash" />
<n8n-icon-button :title="$locale.baseText('credentialsList.editCredential')" @click.stop="editCredential(scope.row)" size="small" icon="pen" />
<n8n-icon-button :title="$locale.baseText('credentialsList.deleteCredential')" @click.stop="deleteCredential(scope.row)" size="small" icon="trash" />
</div>
</template>
</el-table-column>
@ -103,7 +103,16 @@ export default mixins(
},
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) {
return;
@ -112,7 +121,11 @@ export default mixins(
try {
await this.$store.dispatch('credentials/deleteCredential', {id: credential.id});
} 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;
}
@ -121,8 +134,11 @@ export default mixins(
this.updateNodesCredentialsIssues();
this.$showMessage({
title: 'Credentials deleted',
message: `The credential "${credential.name}" was deleted!`,
title: this.$locale.baseText('credentialsList.showMessage.title'),
message: this.$locale.baseText(
'credentialsList.showMessage.message',
{ interpolate: { credentialName: credential.name }},
),
type: 'success',
});
},

View file

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

View file

@ -3,7 +3,7 @@
:visible="!!node"
:before-close="close"
:custom-class="`classic data-display-wrapper`"
width="80%"
width="85%"
append-to-body
@opened="showDocumentHelp = true"
>
@ -15,7 +15,7 @@
<transition name="fade">
<div v-if="nodeType && showDocumentHelp" class="doc-help-wrapper">
<svg id="help-logo" :href="documentationUrl" target="_blank" width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Node Documentation</title>
<title>{{ $locale.baseText('dataDisplay.nodeDocumentation') }}</title>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
<g transform="translate(1117.000000, 825.000000)">
@ -31,7 +31,7 @@
</svg>
<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>
</transition>

View file

@ -1,12 +1,12 @@
<template >
<template>
<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>
<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" />
<font-awesome-icon icon="times" @mousedown="cancelEdit" class="icons clickable" title="Cancel Edit" />
<font-awesome-icon icon="check" @mousedown="setValue" class="icons clickable" title="Set Value" />
<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="$locale.baseText('displayWithChange.setValue')" />
</span>
</span>
</template>
@ -33,6 +33,15 @@ export default mixins(genericHelpers).extend({
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);
},
},

View file

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

View file

@ -1,18 +1,18 @@
<template>
<div>
<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>
<details>
<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>
<div class="error-details__content">
<div v-if="error.timestamp">
<el-card class="box-card" shadow="never">
<div slot="header" class="clearfix box-card__title">
<span>Time</span>
<span>{{ $locale.baseText('nodeErrorView.time') }}</span>
</div>
<div>
{{new Date(error.timestamp).toLocaleString()}}
@ -22,7 +22,7 @@
<div v-if="error.httpCode">
<el-card class="box-card" shadow="never">
<div slot="header" class="clearfix box-card__title">
<span>HTTP-Code</span>
<span>{{ $locale.baseText('nodeErrorView.httpCode') }}</span>
</div>
<div>
{{error.httpCode}}
@ -32,13 +32,13 @@
<div v-if="error.cause">
<el-card class="box-card" shadow="never">
<div slot="header" class="clearfix box-card__title">
<span>Cause</span>
<span>{{ $locale.baseText('nodeErrorView.cause') }}</span>
<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 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>
<vue-json-pretty
v-if="displayCause"
@ -50,7 +50,7 @@
class="json-data"
/>
<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>
</div>
</el-card>
@ -58,7 +58,7 @@
<div v-if="error.stack">
<el-card class="box-card" shadow="never">
<div slot="header" class="clearfix box-card__title">
<span>Stack</span>
<span>{{ $locale.baseText('nodeErrorView.stack') }}</span>
</div>
<div>
<pre><code>{{error.stack}}</code></pre>
@ -103,8 +103,8 @@ export default mixins(
},
copySuccess() {
this.$showMessage({
title: 'Copied to clipboard',
message: '',
title: this.$locale.baseText('nodeErrorView.showMessage.title'),
message: this.$locale.baseText('nodeErrorView.showMessage.message'),
type: 'info',
});
},

View file

@ -1,13 +1,13 @@
<template>
<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">
<el-row>
<el-col :span="2" class="filter-headline">
Filters:
{{ $locale.baseText('executionsList.filters') }}:
</el-col>
<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
v-for="item in workflows"
:key="item.id"
@ -17,7 +17,7 @@
</n8n-select>
</el-col>
<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
v-for="item in statuses"
:key="item.id"
@ -27,15 +27,15 @@
</n8n-select>
</el-col>
<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-row>
</div>
<div class="selection-options">
<span v-if="checkAll === true || isIndeterminate === true">
Selected: {{numSelected}} / <span v-if="finishedExecutionsCountEstimated === true">~</span>{{finishedExecutionsCount}}
<n8n-icon-button title="Delete Selected" icon="trash" size="small" @click="handleDeleteSelected" />
{{ $locale.baseText('executionsList.selected') }}: {{numSelected}} / <span v-if="finishedExecutionsCountEstimated === true">~</span>{{finishedExecutionsCount}}
<n8n-icon-button :title="$locale.baseText('executionsList.deleteSelected')" icon="trash" size="mini" @click="handleDeleteSelected" />
</span>
</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>
</template>
</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">
{{convertToDisplayDate(scope.row.startedAt)}}<br />
<small v-if="scope.row.id">ID: {{scope.row.id}}</small>
</template>
</el-table-column>
<el-table-column property="workflowName" label="Name">
<el-table-column property="workflowName" :label="$locale.baseText('executionsList.name')">
<template slot-scope="scope">
<span class="workflow-name">
{{scope.row.workflowName || '[UNSAVED WORKFLOW]'}}
{{ scope.row.workflowName || $locale.baseText('executionsList.unsavedWorkflow') }}
</span>
<span v-if="scope.row.stoppedAt === undefined">
(running)
({{ $locale.baseText('executionsList.running') }})
</span>
<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 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>
</template>
</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">
<n8n-tooltip placement="top" >
<div slot="content" v-html="statusTooltipText(scope.row)"></div>
<span class="status-badge running" v-if="scope.row.waitTill">
Waiting
{{ $locale.baseText('executionsList.waiting') }}
</span>
<span class="status-badge running" v-else-if="scope.row.stoppedAt === undefined">
Running
{{ $locale.baseText('executionsList.running') }}
</span>
<span class="status-badge success" v-else-if="scope.row.finished">
Success
{{ $locale.baseText('executionsList.success') }}
</span>
<span class="status-badge error" v-else-if="scope.row.stoppedAt !== null">
Error
{{ $locale.baseText('executionsList.error') }}
</span>
<span class="status-badge warning" v-else>
Unknown
{{ $locale.baseText('executionsList.unknown') }}
</span>
</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"
type="light"
:theme="scope.row.stoppedAt === null ? 'warning': 'danger'"
size="small"
title="Retry execution"
size="mini"
:title="$locale.baseText('executionsList.retryExecution')"
icon="redo"
/>
</span>
<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: 'original', row: scope.row}">Retry with original workflow</el-dropdown-item>
<el-dropdown-item :command="{command: 'currentlySaved', row: scope.row}">
{{ $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>
</template>
</el-table-column>
<el-table-column property="mode" label="Mode" width="100" align="center"></el-table-column>
<el-table-column label="Running Time" width="150" align="center">
<el-table-column property="mode" :label="$locale.baseText('executionsList.mode')" width="100" 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">
<span v-if="scope.row.stoppedAt === undefined">
<font-awesome-icon icon="spinner" spin />
@ -134,10 +140,10 @@
<template slot-scope="scope">
<div class="actions-container">
<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 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>
</div>
</template>
@ -145,7 +151,7 @@
</el-table>
<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>
</el-dialog>
@ -224,32 +230,33 @@ export default mixins(
stoppingExecutions: [] as string[],
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: {
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[] {
return this.$store.getters.getActiveExecutions;
},
@ -324,7 +331,14 @@ export default mixins(
return false;
},
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({
name: 'ExecutionById',
params: { id: execution.id },
@ -356,7 +370,16 @@ export default mixins(
}
},
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) {
return;
@ -377,15 +400,19 @@ export default mixins(
await this.restApi().deleteExecutions(sendData);
} catch (error) {
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;
}
this.isDataLoading = false;
this.$showMessage({
title: 'Execution deleted',
message: 'The executions were deleted!',
title: this.$locale.baseText('executionsList.showMessage.handleDeleteSelected.title'),
message: this.$locale.baseText('executionsList.showMessage.handleDeleteSelected.message'),
type: 'success',
});
@ -536,10 +563,19 @@ export default mixins(
data = await this.restApi().getPastExecutions(filter, this.requestItemsPerRequest, lastId);
} catch (error) {
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;
}
data.results = data.results.map((execution) => {
// @ts-ignore
return { ...execution, mode: execution.mode };
});
this.finishedExecutions.push.apply(this.finishedExecutions, data.results);
this.finishedExecutionsCount = data.count;
this.finishedExecutionsCountEstimated = data.estimated;
@ -562,12 +598,16 @@ export default mixins(
// @ts-ignore
workflows.unshift({
id: 'ALL',
name: 'All Workflows',
name: this.$locale.baseText('executionsList.allWorkflows'),
});
Vue.set(this, 'workflows', workflows);
} 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 () {
@ -590,21 +630,25 @@ export default mixins(
if (retrySuccessful === true) {
this.$showMessage({
title: 'Retry successful',
message: 'The retry was successful!',
title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulTrue.title'),
message: this.$locale.baseText('executionsList.showMessage.retrySuccessfulTrue.message'),
type: 'success',
});
} else {
this.$showMessage({
title: 'Retry unsuccessful',
message: 'The retry was not successful!',
title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulFalse.title'),
message: this.$locale.baseText('executionsList.showMessage.retrySuccessfulFalse.message'),
type: 'error',
});
}
this.isDataLoading = false;
} 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;
}
@ -617,7 +661,11 @@ export default mixins(
const finishedExecutionsPromise = this.loadFinishedExecutions();
await Promise.all([activeExecutionsPromise, finishedExecutionsPromise]);
} 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;
@ -626,23 +674,41 @@ export default mixins(
if (entry.waitTill) {
const waitDate = new Date(entry.waitTill);
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) {
return 'The worklow is currently executing.';
return this.$locale.baseText('executionsList.statusTooltipText.theWorkflowIsCurrentlyExecuting');
} 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) {
return 'The worklow execution was successful.';
return this.$locale.baseText('executionsList.statusTooltipText.theWorkflowExecutionWasSuccessful');
} 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) {
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) {
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 {
return 'The workflow execution failed.';
return this.$locale.baseText('executionsList.statusTooltipText.theWorkflowExecutionFailed');
}
},
async stopExecution (activeExecutionId: string) {
@ -658,14 +724,21 @@ export default mixins(
this.stoppingExecutions.splice(index, 1);
this.$showMessage({
title: 'Execution stopped',
message: `The execution with the id "${activeExecutionId}" got stopped!`,
title: this.$locale.baseText('executionsList.showMessage.stopExecution.title'),
message: this.$locale.baseText(
'executionsList.showMessage.stopExecution.message',
{ interpolate: { activeExecutionId } },
),
type: 'success',
});
this.refreshData();
} 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;
display: inline-block;
padding: 0 10px;
height: 22.6px;
line-height: 22.6px;
border-radius: 15px;
text-align: center;
font-weight: 400;
font-size: 12px;
font-size: var(--font-size-s);
&.error {
background-color: var(--color-danger-tint-1);

View file

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

View file

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

View file

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

View file

@ -1,13 +1,25 @@
<template>
<n8n-tooltip class="primary-color" placement="bottom-end" >
<div slot="content">
You're viewing the log of a previous execution. You cannot<br />
make changes since this execution already occured. Make changes<br />
to this workflow by clicking on its name on the left.
<span v-html="$locale.baseText('executionDetails.readOnly.youreViewingTheLogOf')"></span>
</div>
<span>
<div>
<font-awesome-icon icon="exclamation-triangle" />
Read only
</span>
<span v-html="$locale.baseText('executionDetails.readOnly.readOnly')"></span>
</div>
</n8n-tooltip>
</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"
@update="onTagsUpdate"
@esc="onTagsEditEsc"
placeholder="Choose or create a tag"
:placeholder="$locale.baseText('workflowDetails.chooseOrCreateATag')"
ref="dropdown"
class="tags-edit"
/>
@ -46,7 +46,7 @@
class="add-tag clickable"
@click="onTagsEditEnable"
>
+ Add tag
+ {{ $locale.baseText('workflowDetails.addTag') }}
</span>
</div>
<TagsContainer
@ -62,7 +62,7 @@
<PushConnectionTracker class="actions">
<template>
<span class="activator">
<span>Active:</span>
<span>{{ $locale.baseText('workflowDetails.active') + ':' }}</span>
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" :disabled="!currentWorkflowId"/>
</span>
<SaveButton
@ -140,8 +140,9 @@ export default mixins(workflowHelpers).extend({
},
},
methods: {
onSaveButtonClick () {
this.saveCurrentWorkflow(undefined);
async onSaveButtonClick () {
const saved = await this.saveCurrentWorkflow();
if (saved) this.$store.dispatch('settings/fetchPromptsData');
},
onTagsEditEnable() {
this.$data.appliedTagIds = this.currentWorkflowTagIds;
@ -172,7 +173,7 @@ export default mixins(workflowHelpers).extend({
const saved = await this.saveCurrentWorkflow({ tags });
this.$telemetry.track('User edited workflow tags', { workflow_id: this.currentWorkflowId as string, new_tag_count: tags.length });
this.$data.tagsSaving = false;
if (saved) {
this.$data.isTagsEditEnabled = false;
@ -196,8 +197,8 @@ export default mixins(workflowHelpers).extend({
const newName = name.trim();
if (!newName) {
this.$showMessage({
title: "Name missing",
message: `Please enter a name, or press 'esc' to go back to the old one.`,
title: this.$locale.baseText('workflowDetails.showMessage.title'),
message: this.$locale.baseText('workflowDetails.showMessage.message'),
type: "error",
});

View file

@ -22,94 +22,94 @@
<el-submenu index="workflow" title="Workflow" popperClass="sidebar-popper">
<template slot="title">
<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>
<n8n-menu-item index="workflow-new">
<template slot="title">
<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>
</n8n-menu-item>
<n8n-menu-item index="workflow-open">
<template slot="title">
<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>
</n8n-menu-item>
<n8n-menu-item index="workflow-save">
<template slot="title">
<font-awesome-icon icon="save"/>
<span slot="title" class="item-title">Save</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.save') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-duplicate" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="copy"/>
<span slot="title" class="item-title">Duplicate</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.duplicate') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-delete" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="trash"/>
<span slot="title" class="item-title">Delete</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.delete') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-download">
<template slot="title">
<font-awesome-icon icon="file-download"/>
<span slot="title" class="item-title">Download</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.download') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-import-url">
<template slot="title">
<font-awesome-icon icon="cloud"/>
<span slot="title" class="item-title">Import from URL</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.importFromUrl') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-import-file">
<template slot="title">
<font-awesome-icon icon="hdd"/>
<span slot="title" class="item-title">Import from File</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.importFromFile') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-settings" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="cog"/>
<span slot="title" class="item-title">Settings</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.settings') }}</span>
</template>
</n8n-menu-item>
</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">
<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>
<n8n-menu-item index="credentials-new">
<template slot="title">
<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>
</n8n-menu-item>
<n8n-menu-item index="credentials-open">
<template slot="title">
<font-awesome-icon icon="folder-open"/>
<span slot="title" class="item-title">Open</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.open') }}</span>
</template>
</n8n-menu-item>
</el-submenu>
<n8n-menu-item index="executions">
<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>
<el-submenu index="help" class="help-menu" title="Help" popperClass="sidebar-popper">
<template slot="title">
<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>
<MenuItemsIterator :items="helpMenuItems" :afterItemClick="trackHelpItemClick" />
@ -117,7 +117,7 @@
<n8n-menu-item index="help-about">
<template slot="title">
<font-awesome-icon class="about-icon" icon="info"/>
<span slot="title" class="item-title">About n8n</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.aboutN8n') }}</span>
</template>
</n8n-menu-item>
</el-submenu>
@ -168,39 +168,6 @@ import { mapGetters } from 'vuex';
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';
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(
genericHelpers,
restApi,
@ -225,7 +192,6 @@ export default mixins(
basePath: this.$store.getters.getBaseUrl,
executionsListDialogVisible: false,
stopExecutionInProgress: false,
helpMenuItems,
};
},
computed: {
@ -236,6 +202,40 @@ export default mixins(
'hasVersionUpdates',
'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 {
return this.$route.params.id;
},
@ -322,12 +322,19 @@ export default mixins(
this.stopExecutionInProgress = true;
await this.restApi().stopCurrentExecution(executionId);
this.$showMessage({
title: 'Execution stopped',
message: `The execution with the id "${executionId}" got stopped!`,
title: this.$locale.baseText('mainSidebar.showMessage.stopExecution.title'),
message: this.$locale.baseText(
'mainSidebar.showMessage.stopExecution.message',
{ interpolate: { executionId }},
),
type: 'success',
});
} 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;
},
@ -351,8 +358,8 @@ export default mixins(
worflowData = JSON.parse(data as string);
} catch (error) {
this.$showMessage({
title: 'Could not import file',
message: `The file does not contain valid JSON data.`,
title: this.$locale.baseText('mainSidebar.showMessage.handleFileImport.title'),
message: this.$locale.baseText('mainSidebar.showMessage.handleFileImport.message'),
type: 'error',
});
return;
@ -374,17 +381,30 @@ export default mixins(
(this.$refs.importFile as HTMLInputElement).click();
} else if (key === 'workflow-import-url') {
try {
const promptResponse = await this.$prompt(`Workflow URL:`, 'Import Workflow from URL:', {
confirmButtonText: 'Import',
cancelButtonText: 'Cancel',
inputErrorMessage: 'Invalid URL',
inputPattern: /^http[s]?:\/\/.*\.json$/i,
}) as MessageBoxInputData;
const promptResponse = await this.$prompt(
this.$locale.baseText('mainSidebar.prompt.workflowUrl') + ':',
this.$locale.baseText('mainSidebar.prompt.importWorkflowFromUrl') + ':',
{
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,
},
) as MessageBoxInputData;
this.$root.$emit('importWorkflowUrl', { url: promptResponse.value });
} catch (e) {}
} 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) {
return;
@ -393,15 +413,22 @@ export default mixins(
try {
await this.restApi().deleteWorkflow(this.currentWorkflow);
} 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;
}
this.$store.commit('setStateDirty', false);
// Reset tab title since workflow is deleted.
this.$titleReset();
this.$showMessage({
title: 'Workflow was deleted',
message: `The workflow "${this.workflowName}" was deleted!`,
title: this.$locale.baseText('mainSidebar.showMessage.handleSelect1.title'),
message: this.$locale.baseText(
'mainSidebar.showMessage.handleSelect1.message',
{ interpolate: { workflowName: this.workflowName }},
),
type: 'success',
});
@ -425,7 +452,8 @@ export default mixins(
saveAs(blob, workflowName + '.json');
} 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') {
this.$store.dispatch('ui/openModal', DUPLICATE_MODAL_KEY);
} else if (key === 'help-about') {
@ -436,7 +464,13 @@ export default mixins(
} else if (key === 'workflow-new') {
const result = this.$store.getters.getStateIsDirty;
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) {
this.$store.commit('setStateDirty', false);
if (this.$router.currentRoute.name === 'NodeViewNew') {
@ -446,8 +480,8 @@ export default mixins(
}
this.$showMessage({
title: 'Workflow created',
message: 'A new workflow got created!',
title: this.$locale.baseText('mainSidebar.showMessage.handleSelect2.title'),
message: this.$locale.baseText('mainSidebar.showMessage.handleSelect2.message'),
type: 'success',
});
}
@ -457,8 +491,8 @@ export default mixins(
}
this.$showMessage({
title: 'Workflow created',
message: 'A new workflow got created!',
title: this.$locale.baseText('mainSidebar.showMessage.handleSelect3.title'),
message: this.$locale.baseText('mainSidebar.showMessage.handleSelect3.message'),
type: 'success',
});
}

View file

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

View file

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

View file

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

View file

@ -1,50 +1,72 @@
<template>
<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 v-if="hasIssues" class="node-info-icon node-issues">
<n8n-tooltip placement="top" >
<div slot="content" v-html="nodeIssues"></div>
<font-awesome-icon icon="exclamation-triangle" />
</n8n-tooltip>
</div>
<el-badge v-else :hidden="workflowDataItems === 0" class="node-info-icon data-count" :value="workflowDataItems"></el-badge>
<div class="select-background" v-show="isSelected"></div>
<div :class="{'node-default': true, 'touch-active': isTouchActive, 'is-touch-device': isTouchDevice}" :data-name="data.name" :ref="data.name">
<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>
<font-awesome-icon icon="exclamation-triangle" />
</n8n-tooltip>
</div>
<div v-else-if="waiting" class="waiting">
<n8n-tooltip placement="bottom">
<div slot="content" v-html="waiting"></div>
<font-awesome-icon icon="clock" />
</n8n-tooltip>
</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 v-if="waiting" class="node-info-icon waiting">
<n8n-tooltip placement="top">
<div slot="content" v-html="waiting"></div>
<font-awesome-icon icon="clock" />
</n8n-tooltip>
<div class="node-executing-info" :title="$locale.baseText('node.nodeIsExecuting')">
<font-awesome-icon icon="sync-alt" spin />
</div>
<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-executing-info" title="Node is executing">
<font-awesome-icon icon="sync-alt" spin />
</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-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" />
</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" />
</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" />
</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" />
</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" />
</div>
</div>
<NodeIcon class="node-icon" :nodeType="nodeType" size="60" :circle="true" :shrink="true" :disabled="this.data.disabled"/>
<div :class="{'disabled-linethrough': true, success: workflowDataItems > 0}" v-if="showDisabledLinethrough"></div>
</div>
<div class="node-description">
<div class="node-name" :title="data.name">
{{data.name}}
<div class="node-name" :title="nodeTitle">
<p>
{{ nodeTitle }}
</p>
<p v-if="data.disabled">
({{ $locale.baseText('node.disabled') }})
</p>
</div>
<div v-if="nodeSubtitle !== undefined" class="node-subtitle" :title="nodeSubtitle">
{{nodeSubtitle}}
{{ nodeSubtitle }}
</div>
</div>
</div>
@ -61,6 +83,7 @@ import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import {
INodeTypeDescription,
ITaskData,
NodeHelpers,
} from 'n8n-workflow';
@ -69,6 +92,8 @@ import NodeIcon from '@/components/NodeIcon.vue';
import mixins from 'vue-typed-mixins';
import { get } from 'lodash';
import { getStyleTokenValue } from './helpers';
import { INodeUi, XYPosition } from '@/Interface';
export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).extend({
name: 'Node',
@ -76,48 +101,68 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
NodeIcon,
},
computed: {
workflowDataItems () {
const workflowResultDataNode = this.$store.getters.getWorkflowResultDataByNodeName(this.data.name);
nodeRunData(): ITaskData[] {
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) {
return 0;
}
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 {
return this.$store.getters.executingNode === this.data.name;
},
nodeType (): INodeTypeDescription | null {
return this.$store.getters.nodeType(this.data.type);
isSingleActiveTriggerNode (): boolean {
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 () {
const classes = [];
if (this.data.disabled) {
classes.push('disabled');
}
if (this.isExecuting) {
classes.push('executing');
}
if (this.workflowDataItems !== 0) {
classes.push('has-data');
}
if (this.hasIssues) {
classes.push('has-issues');
}
if (this.isTouchDevice) {
classes.push('is-touch-device');
}
if (this.isTouchActive) {
classes.push('touch-active');
}
return classes;
isTriggerNode (): boolean {
return !!(this.nodeType && this.nodeType.group.includes('trigger'));
},
isTriggerNodeTooltipEmpty () : boolean {
return this.nodeType !== null ? this.nodeType.eventTriggerDescription === '' : false;
},
isNodeDisabled (): boolean | undefined {
return this.node && this.node.disabled;
},
nodeType (): INodeTypeDescription | null {
return this.data && this.$store.getters.nodeType(this.data.type);
},
node (): INodeUi | undefined { // same as this.data but reactive..
return this.$store.getters.nodesByName[this.name] as INodeUi | undefined;
},
nodeClass (): object {
return {
'node-box': true,
disabled: this.data.disabled,
executing: this.isExecuting,
};
},
nodeIssues (): string {
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);
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 {
if (this.data.disabled === false) {
@ -135,6 +180,35 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
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 {
const workflowExecution = this.$store.getters.getWorkflowExecution;
@ -143,9 +217,17 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
if (this.name === lastNodeExecuted) {
const waitDate = new Date(workflowExecution.waitTill);
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,21 +236,90 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
workflowRunning (): boolean {
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: {
isActive(newValue, oldValue) {
if (!newValue && oldValue) {
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() {
this.setSubtitle();
setTimeout(() => {
this.$emit('run', {name: this.data.name, data: this.nodeRunData, waiting: !!this.waiting});
}, 0);
},
data () {
return {
isTouchActive: false,
nodeSubtitle: '',
showTriggerNodeTooltip: false,
dragging: false,
};
},
methods: {
@ -197,6 +348,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
this.$emit('duplicateNode', this.data.name);
});
},
setNodeActive () {
this.$store.commit('setActiveNode', this.data.name);
},
@ -213,7 +365,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
</script>
<style lang="scss">
<style lang="scss" scoped>
.node-wrapper {
position: absolute;
@ -221,20 +373,25 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
height: 100px;
.node-description {
line-height: 1.5;
position: absolute;
bottom: -55px;
top: 100px;
left: -50px;
width: 200px;
height: 50px;
line-height: 1.5;
text-align: center;
cursor: default;
padding: 8px;
width: 200px;
pointer-events: none; // prevent container from being draggable
.node-name {
white-space: nowrap;
overflow: hidden;
.node-name > p { // must be paragraph tag to have two lines in safari
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 {
@ -248,33 +405,24 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
}
.node-default {
position: absolute;
width: 100%;
height: 100%;
background-color: #fff;
border-radius: 25px;
text-align: center;
z-index: 24;
cursor: pointer;
color: #444;
border: 1px dashed grey;
&.has-data {
border-style: solid;
}
.node-box {
width: 100%;
height: 100%;
border: 2px solid var(--color-foreground-xdark);
border-radius: var(--border-radius-large);
background-color: var(--color-background-xlight);
&.disabled {
color: #a0a0a0;
text-decoration: line-through;
border: 1px solid #eee !important;
background-color: #eee;
}
&.executing {
background-color: $--color-primary-light !important;
&.executing {
background-color: $--color-primary-light !important;
border-color: $--color-primary !important;
.node-executing-info {
display: inline-block;
.node-executing-info {
display: inline-block;
}
}
}
@ -305,39 +453,35 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
.node-icon {
position: absolute;
top: calc(50% - 30px);
left: calc(50% - 30px);
top: calc(50% - 20px);
left: calc(50% - 20px);
}
.node-info-icon {
position: absolute;
top: -14px;
right: 12px;
z-index: 11;
bottom: 6px;
right: 6px;
&.data-count {
&.shift-icon {
right: 12px;
}
.data-count {
font-weight: 600;
top: -12px;
color: var(--color-success);
}
&.waiting {
left: 10px;
top: -12px;
.node-issues {
color: var(--color-danger);
}
}
.node-issues {
width: 25px;
height: 25px;
font-size: 20px;
color: #ff0000;
.items-count {
font-size: var(--font-size-s);
}
}
.waiting {
width: 25px;
height: 25px;
font-size: 20px;
color: #5e5efa;
color: var(--color-secondary);
}
.node-options {
@ -346,7 +490,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
top: -25px;
left: -10px;
width: 120px;
height: 45px;
height: 26px;
font-size: 0.9em;
text-align: left;
z-index: 10;
@ -381,45 +525,189 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
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 lang="scss">
.jtk-endpoint {
z-index: 2;
}
.node-trigger-tooltip {
&__wrapper {
top: -22px;
left: 50px;
position: relative;
&--item {
max-width: 160px;
position: fixed;
z-index: 0!important;
}
}
}
</style>
<style>
.el-badge__content {
border-width: 2px;
background-color: #67c23a;
}
/** connector */
.jtk-connector {
z-index:4;
z-index: 3;
}
.jtk-endpoint {
z-index:5;
.jtk-connector path {
transition: stroke .1s ease-in-out;
}
.jtk-connector.success {
z-index: 4;
}
.jtk-connector.jtk-hover {
z-index: 6;
}
.jtk-endpoint.plus-endpoint {
z-index: 6;
}
.jtk-endpoint.dot-output-endpoint {
z-index: 7;
}
.jtk-overlay {
z-index:6;
z-index: 7;
}
.jtk-endpoint.dropHover {
border: 2px solid #ff2244;
.disabled-linethrough {
z-index: 8;
}
.jtk-drag-selected .node-default {
/* https://www.cssmatic.com/box-shadow */
-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);
.jtk-connector.jtk-dragging {
z-index: 8;
}
.disabled .node-icon img {
-webkit-filter: contrast(40%) brightness(1.5) grayscale(100%);
filter: contrast(40%) brightness(1.5) grayscale(100%);
.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>

View file

@ -1,19 +1,36 @@
<template functional>
<template>
<div :class="$style.category">
<span :class="$style.name">{{ props.item.category }}</span>
<span :class="$style.name">
{{ renderCategoryName(categoryName) }}
</span>
<font-awesome-icon
:class="$style.arrow"
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 />
</div>
</template>
<script lang="ts">
export default {
import Vue from 'vue';
import camelcase from 'lodash.camelcase';
export default Vue.extend({
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>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,11 @@
<template functional>
<template>
<div :class="$style.subcategory">
<div :class="$style.details">
<div :class="$style.title">{{ props.item.properties.subcategory }}</div>
<div v-if="props.item.properties.description" :class="$style.description">
{{ props.item.properties.description }}
<div :class="$style.title">
{{ $locale.baseText(`nodeCreator.subcategoryNames.${subcategoryName}`) }}
</div>
<div v-if="item.properties.description" :class="$style.description">
{{ $locale.baseText(`nodeCreator.subcategoryDescriptions.${subcategoryDescription}`) }}
</div>
</div>
<div :class="$style.action">
@ -13,9 +15,21 @@
</template>
<script lang="ts">
export default {
import Vue from 'vue';
import camelcase from 'lodash.camelcase';
export default Vue.extend({
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>

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