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

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

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>

View File

@@ -4,7 +4,9 @@
<div class="clickable" @click="onBackArrowClick">
<font-awesome-icon class="back-arrow" icon="arrow-left" />
</div>
<span>{{ title }}</span>
<span>
{{ $locale.baseText(`nodeCreator.subcategoryNames.${subcategoryName}`) }}
</span>
</div>
<div class="scrollable">
@@ -18,6 +20,7 @@
</template>
<script lang="ts">
import camelcase from 'lodash.camelcase';
import { INodeCreateElement } from '@/Interface';
import Vue from 'vue';
@@ -29,6 +32,11 @@ export default Vue.extend({
ItemIterator,
},
props: ['title', 'elements', 'activeIndex'],
computed: {
subcategoryName() {
return camelcase(this.title);
},
},
methods: {
selected(element: INodeCreateElement) {
this.$emit('selected', element);

View File

@@ -1,51 +1,53 @@
<template>
<div v-if="credentialTypesNodeDescriptionDisplayed.length" class="node-credentials">
<div class="headline">
Credentials
</div>
<div v-if="credentialTypesNodeDescriptionDisplayed.length" :class="$style.container">
<div v-for="credentialTypeDescription in credentialTypesNodeDescriptionDisplayed" :key="credentialTypeDescription.name">
<n8n-input-label
:label="$locale.baseText(
'nodeCredentials.credentialFor',
{
interpolate: {
credentialType: credentialTypeNames[credentialTypeDescription.name]
}
}
)"
:bold="false"
size="small"
<div v-for="credentialTypeDescription in credentialTypesNodeDescriptionDisplayed" :key="credentialTypeDescription.name" class="credential-data">
<el-row class="credential-parameter-wrapper">
<el-col :span="10" class="parameter-name">
{{credentialTypeNames[credentialTypeDescription.name]}}:
</el-col>
:set="issues = getIssues(credentialTypeDescription.name)"
>
<div v-if="isReadOnly">
<n8n-input disabled :value="selected && selected[credentialTypeDescription.name] && selected[credentialTypeDescription.name].name" size="small" />
</div>
<el-col v-if="!isReadOnly" :span="12" class="parameter-value" :class="getIssues(credentialTypeDescription.name).length?'has-issues':''">
<div :style="credentialInputWrapperStyle(credentialTypeDescription.name)">
<n8n-select :value="getSelectedId(credentialTypeDescription.name)" @change="(value) => onCredentialSelected(credentialTypeDescription.name, value)" placeholder="Select Credential" size="small">
<n8n-option
v-for="(item) in credentialOptions[credentialTypeDescription.name]"
:key="item.id"
:label="item.name"
:value="item.id">
</n8n-option>
<n8n-option
:key="NEW_CREDENTIALS_TEXT"
:value="NEW_CREDENTIALS_TEXT"
:label="NEW_CREDENTIALS_TEXT"
>
</n8n-option>
</n8n-select>
</div>
<div :class="issues.length ? $style.hasIssues : $style.input" v-else >
<n8n-select :value="getSelectedId(credentialTypeDescription.name)" @change="(value) => onCredentialSelected(credentialTypeDescription.name, value)" :placeholder="$locale.baseText('nodeCredentials.selectCredential')" size="small">
<n8n-option
v-for="(item) in credentialOptions[credentialTypeDescription.name]"
:key="item.id"
:label="item.name"
:value="item.id">
</n8n-option>
<n8n-option
:key="NEW_CREDENTIALS_TEXT"
:value="NEW_CREDENTIALS_TEXT"
:label="NEW_CREDENTIALS_TEXT"
>
</n8n-option>
</n8n-select>
<div class="credential-issues">
<div :class="$style.warning" v-if="issues.length">
<n8n-tooltip placement="top" >
<div slot="content" v-html="'Issues:<br />&nbsp;&nbsp;- ' + getIssues(credentialTypeDescription.name).join('<br />&nbsp;&nbsp;- ')"></div>
<div slot="content" v-html="`${$locale.baseText('nodeCredentials.issues')}:<br />&nbsp;&nbsp;- ` + issues.join('<br />&nbsp;&nbsp;- ')"></div>
<font-awesome-icon icon="exclamation-triangle" />
</n8n-tooltip>
</div>
</el-col>
<el-col v-if="!isReadOnly" :span="2" class="parameter-value credential-action">
<font-awesome-icon v-if="isCredentialExisting(credentialTypeDescription.name)" icon="pen" @click="editCredential(credentialTypeDescription.name)" class="update-credentials clickable" title="Update Credentials" />
</el-col>
<el-col v-if="isReadOnly" :span="14" class="readonly-container" >
<n8n-input disabled :value="selected && selected[credentialTypeDescription.name] && selected[credentialTypeDescription.name].name" size="small" />
</el-col>
</el-row>
<div :class="$style.edit" v-if="selected[credentialTypeDescription.name] && isCredentialExisting(credentialTypeDescription.name)">
<font-awesome-icon icon="pen" @click="editCredential(credentialTypeDescription.name)" class="clickable" :title="$locale.baseText('nodeCredentials.updateCredential')" />
</div>
</div>
</n8n-input-label>
</div>
</div>
</template>
@@ -71,8 +73,6 @@ import { mapGetters } from "vuex";
import mixins from 'vue-typed-mixins';
const NEW_CREDENTIALS_TEXT = '- Create New -';
export default mixins(
genericHelpers,
nodeHelpers,
@@ -85,7 +85,7 @@ export default mixins(
],
data () {
return {
NEW_CREDENTIALS_TEXT,
NEW_CREDENTIALS_TEXT: `- ${this.$locale.baseText('nodeCredentials.createNew')} -`,
newCredentialUnsubscribe: null as null | (() => void),
};
},
@@ -106,7 +106,7 @@ export default mixins(
credentialTypesNodeDescription (): INodeCredentialDescription[] {
const node = this.node as INodeUi;
const activeNodeType = this.$store.getters.nodeType(node.type) as INodeTypeDescription;
const activeNodeType = this.$store.getters.nodeType(node.type) as INodeTypeDescription | null;
if (activeNodeType && activeNodeType.credentials) {
return activeNodeType.credentials;
}
@@ -191,7 +191,7 @@ export default mixins(
},
onCredentialSelected (credentialType: string, credentialId: string | null | undefined) {
if (credentialId === NEW_CREDENTIALS_TEXT) {
if (credentialId === this.NEW_CREDENTIALS_TEXT) {
this.listenForNewCredentials(credentialType);
this.$store.dispatch('ui/openNewCredential', { type: credentialType });
this.$telemetry.track('User opened Credential modal', { credential_type: credentialType, source: 'node', new_credential: true, workflow_id: this.$store.getters.workflowId });
@@ -215,8 +215,16 @@ export default mixins(
});
this.updateNodesCredentialsIssues();
this.$showMessage({
title: 'Node credentials updated',
message: `Nodes that used credentials "${oldCredentials.name}" have been updated to use "${selected.name}"`,
title: this.$locale.baseText('nodeCredentials.showMessage.title'),
message: this.$locale.baseText(
'nodeCredentials.showMessage.message',
{
interpolate: {
oldCredentialName: oldCredentials.name,
newCredentialName: selected.name,
},
},
),
type: 'success',
});
}
@@ -285,62 +293,39 @@ export default mixins(
});
</script>
<style lang="scss">
<style lang="scss" module>
.container {
margin: var(--spacing-xs) 0;
.node-credentials {
padding-bottom: 1em;
margin: 0.5em;
border-bottom: 1px solid #ccc;
.credential-issues {
display: none;
width: 20px;
text-align: right;
float: right;
color: #ff8080;
font-size: 1.2em;
margin-top: 3px;
}
.credential-data + .credential-data {
margin-top: 1em;
}
.has-issues {
.credential-issues {
display: inline-block;
}
}
.headline {
font-weight: bold;
margin-bottom: 0.7em;
}
.credential-parameter-wrapper {
display: flex;
align-items: center;
}
.parameter-name {
font-weight: 400;
}
.parameter-value {
display: flex;
align-items: center;
}
.credential-action {
display: flex;
justify-content: center;
align-items: center;
color: var(--color-text-base);
}
.readonly-container {
padding-right: 0.5em;
> * {
margin-bottom: var(--spacing-xs);
}
}
.warning {
min-width: 20px;
margin-left: 5px;
color: #ff8080;
font-size: var(--font-size-s);
}
.edit {
display: flex;
justify-content: center;
align-items: center;
color: var(--color-text-base);
min-width: 20px;
margin-left: 5px;
font-size: var(--font-size-s);
}
.input {
display: flex;
align-items: center;
}
.hasIssues {
composes: input;
--input-border-color: var(--color-danger);
}
</style>

View File

@@ -1,8 +1,8 @@
<template>
<div class="node-icon-wrapper" :style="iconStyleData" :class="{shrink: isSvgIcon && shrink, full: !shrink}">
<div class="node-icon-wrapper" :style="iconStyleData">
<div v-if="nodeIconData !== null" class="icon">
<img v-if="nodeIconData.type === 'file'" :src="nodeIconData.fileBuffer || nodeIconData.path" style="max-width: 100%; max-height: 100%;" />
<font-awesome-icon v-else :icon="nodeIconData.icon || nodeIconData.path" />
<img v-if="nodeIconData.type === 'file'" :src="nodeIconData.fileBuffer || nodeIconData.path" :style="imageStyleData" />
<font-awesome-icon v-else :icon="nodeIconData.icon || nodeIconData.path" :style="fontStyleData" />
</div>
<div v-else class="node-icon-placeholder">
{{nodeType !== null ? nodeType.displayName.charAt(0) : '?' }}
@@ -12,39 +12,65 @@
<script lang="ts">
import { IVersionNode } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow';
import Vue from 'vue';
interface NodeIconData {
type: string;
path: string;
path?: string;
fileExtension?: string;
fileBuffer?: string;
}
export default Vue.extend({
name: 'NodeIcon',
props: [
'nodeType',
'size',
'shrink',
'disabled',
'circle',
],
props: {
nodeType: {},
size: {
type: Number,
},
disabled: {
type: Boolean,
default: false,
},
circle: {
type: Boolean,
default: false,
},
},
computed: {
iconStyleData (): object {
const color = this.disabled ? '#ccc' : this.nodeType.defaults && this.nodeType.defaults.color;
const nodeType = this.nodeType as INodeTypeDescription | IVersionNode | null;
const color = nodeType ? nodeType.defaults && nodeType!.defaults.color : '';
if (!this.size) {
return {color};
}
const size = parseInt(this.size, 10);
return {
color,
width: size + 'px',
height: size + 'px',
'font-size': Math.floor(parseInt(this.size, 10) * 0.6) + 'px',
'line-height': size + 'px',
'border-radius': this.circle ? '50%': '4px',
width: this.size + 'px',
height: this.size + 'px',
'font-size': this.size + 'px',
'line-height': this.size + 'px',
'border-radius': this.circle ? '50%': '2px',
...(this.disabled && {
color: '#ccc',
'-webkit-filter': 'contrast(40%) brightness(1.5) grayscale(100%)',
'filter': 'contrast(40%) brightness(1.5) grayscale(100%)',
}),
};
},
fontStyleData (): object {
return {
'max-width': this.size + 'px',
};
},
imageStyleData (): object {
return {
width: '100%',
'max-width': '100%',
'max-height': '100%',
};
},
isSvgIcon (): boolean {
@@ -54,26 +80,27 @@ export default Vue.extend({
return false;
},
nodeIconData (): null | NodeIconData {
if (this.nodeType === null) {
const nodeType = this.nodeType as INodeTypeDescription | IVersionNode | null;
if (nodeType === null) {
return null;
}
if (this.nodeType.iconData) {
return this.nodeType.iconData;
if ((nodeType as IVersionNode).iconData) {
return (nodeType as IVersionNode).iconData;
}
const restUrl = this.$store.getters.getRestUrl;
if (this.nodeType.icon) {
if (nodeType.icon) {
let type, path;
[type, path] = this.nodeType.icon.split(':');
[type, path] = nodeType.icon.split(':');
const returnData: NodeIconData = {
type,
path,
};
if (type === 'file') {
returnData.path = restUrl + '/node-icon/' + this.nodeType.name;
returnData.path = restUrl + '/node-icon/' + nodeType.name;
returnData.fileExtension = path.split('.').slice(-1).join();
}
@@ -90,7 +117,7 @@ export default Vue.extend({
.node-icon-wrapper {
width: 26px;
height: 26px;
border-radius: 4px;
border-radius: 2px;
color: #444;
line-height: 26px;
font-size: 1.1em;
@@ -99,7 +126,7 @@ export default Vue.extend({
font-weight: bold;
font-size: 20px;
&.full .icon {
.icon {
height: 100%;
width: 100%;
@@ -108,10 +135,6 @@ export default Vue.extend({
align-items: center;
}
&.shrink .icon {
margin: 0.24em;
}
.node-icon-placeholder {
text-align: center;
}

View File

@@ -5,27 +5,35 @@
<display-with-change :key-name="'name'" @valueChanged="valueChanged"></display-with-change>
<a v-if="nodeType" :href="'http://n8n.io/nodes/' + nodeType.name" target="_blank" class="node-info">
<n8n-tooltip class="clickable" placement="top" >
<div slot="content" v-html="'<strong>Node Description:</strong><br />' + nodeTypeDescription + '<br /><br /><strong>Click the \'?\' icon to open this node on n8n.io </strong>'"></div>
<div slot="content" v-html="`<strong>${$locale.baseText('nodeSettings.nodeDescription')}:</strong><br />` + nodeTypeDescription + `<br /><br /><strong>${$locale.baseText('nodeSettings.clickOnTheQuestionMarkIcon')}</strong>`"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</a>
</span>
<span v-else>No node active</span>
</div>
<div class="node-is-not-valid" v-if="node && !nodeValid">
The node is not valid as its type "{{node.type}}" is unknown.
<n8n-text>
{{
$locale.baseText(
'nodeSettings.theNodeIsNotValidAsItsTypeIsUnknown',
{ interpolate: { nodeType: node.type } },
)
}}
</n8n-text>
</div>
<div class="node-parameters-wrapper" v-if="node && nodeValid">
<el-tabs stretch @tab-click="handleTabClick">
<el-tab-pane label="Parameters">
<el-tab-pane :label="$locale.baseText('nodeSettings.parameters')">
<node-credentials :node="node" @credentialSelected="credentialSelected"></node-credentials>
<node-webhooks :node="node" :nodeType="nodeType" />
<parameter-input-list :parameters="parametersNoneSetting" :hideDelete="true" :nodeValues="nodeValues" path="parameters" @valueChanged="valueChanged" />
<div v-if="parametersNoneSetting.length === 0">
This node does not have any parameters.
<div v-if="parametersNoneSetting.length === 0" class="no-parameters">
<n8n-text>
{{ $locale.baseText('nodeSettings.thisNodeDoesNotHaveAnyParameters') }}
</n8n-text>
</div>
</el-tab-pane>
<el-tab-pane label="Settings">
<el-tab-pane :label="$locale.baseText('nodeSettings.settings')">
<parameter-input-list :parameters="nodeSettings" :hideDelete="true" :nodeValues="nodeValues" path="" @valueChanged="valueChanged" />
<parameter-input-list :parameters="parametersSetting" :nodeValues="nodeValues" path="parameters" @valueChanged="valueChanged" />
</el-tab-pane>
@@ -87,11 +95,28 @@ export default mixins(
return null;
},
nodeTypeName(): string {
if (this.nodeType) {
const shortNodeType = this.$locale.shortNodeType(this.nodeType.name);
return this.$locale.headerText({
key: `headers.${shortNodeType}.displayName`,
fallback: this.nodeType.name,
});
}
return '';
},
nodeTypeDescription (): string {
if (this.nodeType && this.nodeType.description) {
return this.nodeType.description;
const shortNodeType = this.$locale.shortNodeType(this.nodeType.name);
return this.$locale.headerText({
key: `headers.${shortNodeType}.description`,
fallback: this.nodeType.description,
});
} else {
return 'No description found';
return this.$locale.baseText('nodeSettings.noDescriptionFound');
}
},
headerStyle (): object {
@@ -123,13 +148,6 @@ export default mixins(
return this.nodeType.properties;
},
isColorDefaultValue (): boolean {
if (this.nodeType === null) {
return false;
}
return this.node.color === this.nodeType.defaults.color;
},
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
@@ -153,7 +171,7 @@ export default mixins(
nodeSettings: [
{
displayName: 'Notes',
displayName: this.$locale.baseText('nodeSettings.notes.displayName'),
name: 'notes',
type: 'string',
typeOptions: {
@@ -161,50 +179,42 @@ export default mixins(
},
default: '',
noDataExpression: true,
description: 'Optional note to save with the node.',
description: this.$locale.baseText('nodeSettings.notes.description'),
},
{
displayName: 'Display note in flow?',
displayName: this.$locale.baseText('nodeSettings.notesInFlow.displayName'),
name: 'notesInFlow',
type: 'boolean',
default: false,
noDataExpression: true,
description: 'If active, the note above will display in the flow as a subtitle.',
description: this.$locale.baseText('nodeSettings.notesInFlow.description'),
},
{
displayName: 'Node Color',
name: 'color',
type: 'color',
default: '#ff0000',
noDataExpression: true,
description: 'The color of the node in the flow.',
},
{
displayName: 'Always Output Data',
displayName: this.$locale.baseText('nodeSettings.alwaysOutputData.displayName'),
name: 'alwaysOutputData',
type: 'boolean',
default: false,
noDataExpression: true,
description: 'If active, the node will return an empty item even if the <br />node returns no data during an initial execution. Be careful setting <br />this on IF-Nodes as it could cause an infinite loop.',
description: this.$locale.baseText('nodeSettings.alwaysOutputData.description'),
},
{
displayName: 'Execute Once',
displayName: this.$locale.baseText('nodeSettings.executeOnce.displayName'),
name: 'executeOnce',
type: 'boolean',
default: false,
noDataExpression: true,
description: 'If active, the node executes only once, with data<br /> from the first item it recieves. ',
description: this.$locale.baseText('nodeSettings.executeOnce.description'),
},
{
displayName: 'Retry On Fail',
displayName: this.$locale.baseText('nodeSettings.retryOnFail.displayName'),
name: 'retryOnFail',
type: 'boolean',
default: false,
noDataExpression: true,
description: 'If active, the node tries to execute a failed attempt <br /> multiple times until it succeeds.',
description: this.$locale.baseText('nodeSettings.retryOnFail.description'),
},
{
displayName: 'Max. Tries',
displayName: this.$locale.baseText('nodeSettings.maxTries.displayName'),
name: 'maxTries',
type: 'number',
typeOptions: {
@@ -220,10 +230,10 @@ export default mixins(
},
},
noDataExpression: true,
description: 'Number of times Retry On Fail should attempt to execute the node <br />before stopping and returning the execution as failed.',
description: this.$locale.baseText('nodeSettings.maxTries.description'),
},
{
displayName: 'Wait Between Tries',
displayName: this.$locale.baseText('nodeSettings.waitBetweenTries.displayName'),
name: 'waitBetweenTries',
type: 'number',
typeOptions: {
@@ -239,15 +249,15 @@ export default mixins(
},
},
noDataExpression: true,
description: 'How long to wait between each attempt. Value in ms.',
description: this.$locale.baseText('nodeSettings.waitBetweenTries.description'),
},
{
displayName: 'Continue On Fail',
displayName: this.$locale.baseText('nodeSettings.continueOnFail.displayName'),
name: 'continueOnFail',
type: 'boolean',
default: false,
noDataExpression: true,
description: 'If active, the workflow continues even if this node\'s <br />execution fails. When this occurs, the node passes along input data from<br />previous nodes - so your workflow should account for unexpected output data.',
description: this.$locale.baseText('nodeSettings.continueOnFail.description'),
},
] as INodeProperties[],
@@ -318,7 +328,7 @@ export default mixins(
// Update the values on the node
this.$store.commit('updateNodeProperties', updateInformation);
const node = this.$store.getters.nodeByName(updateInformation.name);
const node = this.$store.getters.getNodeByName(updateInformation.name);
// Update the issues
this.updateNodeCredentialIssues(node);
@@ -338,7 +348,7 @@ export default mixins(
// Save the node name before we commit the change because
// we need the old name to rename the node properly
const nodeNameBefore = parameterData.node || this.node.name;
const node = this.$store.getters.nodeByName(nodeNameBefore);
const node = this.$store.getters.getNodeByName(nodeNameBefore);
if (parameterData.name === 'name') {
// Name of node changed so we have to set also the new node name as active
@@ -354,7 +364,10 @@ export default mixins(
} else if (parameterData.name.startsWith('parameters.')) {
// A node parameter changed
const nodeType = this.$store.getters.nodeType(node.type);
const nodeType = this.$store.getters.nodeType(node.type) as INodeTypeDescription | null;
if (!nodeType) {
return;
}
// Get only the parameters which are different to the defaults
let nodeParameters = NodeHelpers.getNodeParameters(nodeType.properties, node.parameters, false, false);
@@ -492,10 +505,6 @@ export default mixins(
// Set default value
Vue.set(this.nodeValues, nodeSetting.name, nodeSetting.default);
}
if (nodeSetting.name === 'color') {
// For color also apply the default node color to the node settings
nodeSetting.default = this.nodeType.defaults.color;
}
}
Vue.set(this.nodeValues, 'parameters', JSON.parse(JSON.stringify(this.node.parameters)));
@@ -521,7 +530,10 @@ export default mixins(
overflow: hidden;
min-width: 350px;
max-width: 350px;
font-size: var(--font-size-s);
.no-parameters {
margin-top: var(--spacing-xs);
}
.header-side-menu {
padding: 1em 0 1em 1.8em;
@@ -547,10 +559,10 @@ export default mixins(
.node-parameters-wrapper {
height: 100%;
font-size: .9em;
.el-tabs__header {
background-color: #fff5f2;
margin-bottom: 0;
}
.el-tabs {
@@ -561,13 +573,13 @@ export default mixins(
padding-bottom: 180px;
.el-tab-pane {
margin: 0 1em;
margin: 0 var(--spacing-s);
}
}
}
.el-tabs__nav {
padding-bottom: 1em;
padding-bottom: var(--spacing-xs);
}
.add-option {
@@ -621,14 +633,6 @@ export default mixins(
padding: 0 1em;
}
.parameter-name {
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
align-self: center;
}
.color-reset-button-wrapper {
position: relative;

View File

@@ -1,8 +1,8 @@
<template>
<div v-if="webhooksNode.length" class="webhoooks">
<div class="clickable headline" :class="{expanded: !isMinimized}" @click="isMinimized=!isMinimized" :title="isMinimized ? 'Click to display Webhook URLs' : 'Click to hide Webhook URLs'">
<div class="clickable headline" :class="{expanded: !isMinimized}" @click="isMinimized=!isMinimized" :title="isMinimized ? $locale.baseText('nodeWebhooks.clickToDisplayWebhookUrls') : $locale.baseText('nodeWebhooks.clickToHideWebhookUrls')">
<font-awesome-icon icon="angle-down" class="minimize-button minimize-icon" />
Webhook URLs
{{ $locale.baseText('nodeWebhooks.webhookUrls') }}
</div>
<el-collapse-transition>
<div class="node-webhooks" v-if="!isMinimized">
@@ -10,14 +10,14 @@
<el-row>
<el-col :span="24">
<el-radio-group v-model="showUrlFor" size="mini">
<el-radio-button label="test">Test URL</el-radio-button>
<el-radio-button label="production">Production URL</el-radio-button>
<el-radio-button label="test">{{ $locale.baseText('nodeWebhooks.testUrl') }}</el-radio-button>
<el-radio-button label="production">{{ $locale.baseText('nodeWebhooks.productionUrl') }}</el-radio-button>
</el-radio-group>
</el-col>
</el-row>
</div>
<n8n-tooltip v-for="(webhook, index) in webhooksNode" :key="index" class="item" content="Click to copy Webhook URL" placement="left">
<n8n-tooltip v-for="(webhook, index) in webhooksNode" :key="index" class="item" :content="$locale.baseText('nodeWebhooks.clickToCopyWebhookUrls')" placement="left">
<div class="webhook-wrapper">
<div class="http-field">
<div class="http-method">
@@ -64,7 +64,7 @@ export default mixins(
],
data () {
return {
isMinimized: this.nodeType.name !== WEBHOOK_NODE_TYPE,
isMinimized: this.nodeType && this.nodeType.name !== WEBHOOK_NODE_TYPE,
showUrlFor: 'test',
};
},
@@ -83,8 +83,8 @@ export default mixins(
this.copyToClipboard(webhookUrl);
this.$showMessage({
title: 'Copied',
message: `The webhook URL was successfully copied!`,
title: this.$locale.baseText('nodeWebhooks.showMessage.title'),
message: this.$locale.baseText('nodeWebhooks.showMessage.message'),
type: 'success',
});
},
@@ -95,7 +95,7 @@ export default mixins(
try {
return this.resolveExpression(webhookData[key] as string) as string;
} catch (e) {
return '[INVALID EXPRESSION]';
return this.$locale.baseText('nodeWebhooks.invalidExpression');
}
},
getWebhookUrl (webhookData: IWebhookDescription): string {
@@ -128,14 +128,14 @@ export default mixins(
<style scoped lang="scss">
.webhoooks {
padding: 0.7em;
font-size: 0.9em;
margin: 0.5em 0;
padding-bottom: var(--spacing-xs);
margin: var(--spacing-xs) 0;
border-bottom: 1px solid #ccc;
.headline {
color: $--color-primary;
font-weight: 600;
font-size: var(--font-size-2xs);
}
}
@@ -154,8 +154,8 @@ export default mixins(
margin-left: 5px;
text-align: center;
border-radius: 2px;
font-size: 0.8em;
font-weight: 600;
font-size: var(--font-size-2xs);
font-weight: var(--font-weight-bold);
color: #fff;
}
@@ -175,11 +175,11 @@ export default mixins(
.url-field {
display: inline-block;
width: calc(100% - 60px);
margin-left: 50px;
margin-left: 55px;
}
.url-selection {
margin-top: 1em;
margin-top: var(--spacing-xs);
}
.minimize-button {
@@ -205,12 +205,11 @@ export default mixins(
position: relative;
top: 0;
width: 100%;
font-size: 0.9em;
font-size: var(--font-size-2xs);
white-space: normal;
overflow: visible;
text-overflow: initial;
color: #404040;
padding: 0.5em;
text-align: left;
direction: ltr;
word-break: break-all;
@@ -219,9 +218,8 @@ export default mixins(
.webhook-wrapper {
line-height: 1.5;
position: relative;
margin: 1em 0 0.5em 0;
margin-top: var(--spacing-xs);
background-color: #fff;
padding: 2px 0;
border-radius: 3px;
}
</style>

View File

@@ -16,7 +16,7 @@
<code-edit v-if="codeEditDialogVisible" :value="value" :parameter="parameter" @closeDialog="closeCodeEditDialog" @valueChanged="expressionUpdated"></code-edit>
<text-edit :dialogVisible="textEditDialogVisible" :value="value" :parameter="parameter" @closeDialog="closeTextEditDialog" @valueChanged="expressionUpdated"></text-edit>
<div v-if="isEditor === true" class="clickable" @click="displayEditDialog()">
<div v-if="isEditor === true" class="code-edit clickable" @click="displayEditDialog()">
<prism-editor v-if="!codeEditDialogVisible" :lineNumbers="true" :readonly="true" :code="displayValue" language="js"></prism-editor>
</div>
@@ -35,10 +35,10 @@
@focus="setFocus"
@blur="onBlur"
:title="displayTitle"
:placeholder="isValueExpression?'':parameter.placeholder"
:placeholder="isValueExpression ? '' : getPlaceholder()"
>
<div slot="suffix" class="expand-input-icon-container">
<font-awesome-icon v-if="!isValueExpression && !isReadOnly" icon="external-link-alt" class="edit-window-button clickable" title="Open Edit Window" @click="displayEditDialog()" />
<font-awesome-icon v-if="!isValueExpression && !isReadOnly" icon="external-link-alt" class="edit-window-button clickable" :title="$locale.baseText('parameterInput.openEditWindow')" @click="displayEditDialog()" />
</div>
</n8n-input>
</div>
@@ -78,7 +78,7 @@
:value="displayValue"
:title="displayTitle"
:disabled="isReadOnly"
:placeholder="parameter.placeholder?parameter.placeholder:'Select date and time'"
:placeholder="parameter.placeholder ? getPlaceholder() : $locale.baseText('parameterInput.selectDateAndTime')"
:picker-options="dateTimePickerOptions"
@change="valueChanged"
@focus="setFocus"
@@ -124,11 +124,13 @@
v-for="option in parameterOptions"
:value="option.value"
:key="option.value"
:label="option.name"
:label="getOptionsOptionDisplayName(option)"
>
<div class="list-option">
<div class="option-headline">{{ option.name }}</div>
<div v-if="option.description" class="option-description" v-html="option.description"></div>
<div class="option-headline">
{{ getOptionsOptionDisplayName(option) }}
</div>
<div v-if="option.description" class="option-description" v-html="getOptionsOptionDescription(option)"></div>
</div>
</n8n-option>
</n8n-select>
@@ -148,10 +150,10 @@
@blur="onBlur"
:title="displayTitle"
>
<n8n-option v-for="option in parameterOptions" :value="option.value" :key="option.value" :label="option.name" >
<n8n-option v-for="option in parameterOptions" :value="option.value" :key="option.value" :label="getOptionsOptionDisplayName(option)">
<div class="list-option">
<div class="option-headline">{{ option.name }}</div>
<div v-if="option.description" class="option-description" v-html="option.description"></div>
<div class="option-headline">{{ getOptionsOptionDisplayName(option) }}</div>
<div v-if="option.description" class="option-description" v-html="getOptionsOptionDescription(option)"></div>
</div>
</n8n-option>
</n8n-select>
@@ -169,7 +171,7 @@
<div class="parameter-issues" v-if="getIssues.length">
<n8n-tooltip placement="top" >
<div slot="content" v-html="'Issues:<br />&nbsp;&nbsp;- ' + getIssues.join('<br />&nbsp;&nbsp;- ')"></div>
<div slot="content" v-html="`${$locale.baseText('parameterInput.issues')}:<br />&nbsp;&nbsp;- ` + getIssues.join('<br />&nbsp;&nbsp;- ')"></div>
<font-awesome-icon icon="exclamation-triangle" />
</n8n-tooltip>
</div>
@@ -177,13 +179,13 @@
<div class="parameter-options" v-if="displayOptionsComputed">
<el-dropdown trigger="click" @command="optionSelected" size="mini">
<span class="el-dropdown-link">
<font-awesome-icon icon="cogs" class="reset-icon clickable" title="Parameter Options"/>
<font-awesome-icon icon="cogs" class="reset-icon clickable" :title="$locale.baseText('parameterInput.parameterOptions')"/>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="addExpression" v-if="parameter.noDataExpression !== true && !isValueExpression">Add Expression</el-dropdown-item>
<el-dropdown-item command="removeExpression" v-if="parameter.noDataExpression !== true && isValueExpression">Remove Expression</el-dropdown-item>
<el-dropdown-item command="refreshOptions" v-if="Boolean(remoteMethod)">Refresh List</el-dropdown-item>
<el-dropdown-item command="resetValue" :disabled="isDefault" divided>Reset Value</el-dropdown-item>
<el-dropdown-item command="addExpression" v-if="parameter.noDataExpression !== true && !isValueExpression">{{ $locale.baseText('parameterInput.addExpression') }}</el-dropdown-item>
<el-dropdown-item command="removeExpression" v-if="parameter.noDataExpression !== true && isValueExpression">{{ $locale.baseText('parameterInput.removeExpression') }}</el-dropdown-item>
<el-dropdown-item command="refreshOptions" v-if="Boolean(remoteMethod)">{{ $locale.baseText('parameterInput.refreshList') }}</el-dropdown-item>
<el-dropdown-item command="resetValue" :disabled="isDefault" divided>{{ $locale.baseText('parameterInput.resetValue') }}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
@@ -240,6 +242,7 @@ export default mixins(
'value',
'hideIssues', // boolean
'errorHighlight',
'isForCredential', // boolean
],
data () {
return {
@@ -255,14 +258,14 @@ export default mixins(
dateTimePickerOptions: {
shortcuts: [
{
text: 'Today',
text: 'Today', // TODO
// tslint:disable-next-line:no-any
onClick (picker: any) {
picker.$emit('pick', new Date());
},
},
{
text: 'Yesterday',
text: 'Yesterday', // TODO
// tslint:disable-next-line:no-any
onClick (picker: any) {
const date = new Date();
@@ -271,7 +274,7 @@ export default mixins(
},
},
{
text: 'A week ago',
text: 'A week ago', // TODO
// tslint:disable-next-line:no-any
onClick (picker: any) {
const date = new Date();
@@ -325,20 +328,26 @@ export default mixins(
return this.$store.getters.activeNode;
},
displayTitle (): string {
let title = `Parameter: "${this.shortPath}"`;
if (this.getIssues.length) {
title += ` has issues`;
if (this.isValueExpression === true) {
title += ` and expression`;
}
title += `!`;
} else {
if (this.isValueExpression === true) {
title += ` has expression`;
}
const interpolation = { interpolate: { shortPath: this.shortPath } };
if (this.getIssues.length && this.isValueExpression) {
return this.$locale.baseText(
'parameterInput.parameterHasIssuesAndExpression',
interpolation,
);
} else if (this.getIssues.length && !this.isValueExpression) {
return this.$locale.baseText(
'parameterInput.parameterHasIssues',
interpolation,
);
} else if (!this.getIssues.length && this.isValueExpression) {
return this.$locale.baseText(
'parameterInput.parameterHasExpression',
interpolation,
);
}
return title;
return this.$locale.baseText('parameterInput.parameter', interpolation);
},
displayValue (): string | number | boolean | null {
if (this.remoteParameterOptionsLoading === true) {
@@ -346,7 +355,7 @@ export default mixins(
// to user that the data is loading. If not it would
// display the user the key instead of the value it
// represents
return 'Loading options...';
return this.$locale.baseText('parameterInput.loadingOptions');
}
let returnValue;
@@ -415,7 +424,7 @@ export default mixins(
try {
computedValue = this.resolveExpression(this.value) as NodeParameterValue;
} catch (error) {
computedValue = `[ERROR: ${error.message}]`;
computedValue = `[${this.$locale.baseText('parameterInput.error')}}: ${error.message}]`;
}
// Try to convert it into the corret type
@@ -515,8 +524,9 @@ export default mixins(
const classes = [];
const rows = this.getArgument('rows');
const isTextarea = this.parameter.type === 'string' && rows !== undefined;
const isSwitch = this.parameter.type === 'boolean' && !this.isValueExpression;
if (!isTextarea) {
if (!isTextarea && !isSwitch) {
classes.push('parameter-value-container');
}
if (this.isValueExpression) {
@@ -558,6 +568,22 @@ export default mixins(
},
},
methods: {
getPlaceholder(): string {
return this.isForCredential
? this.$locale.credText().placeholder(this.parameter)
: this.$locale.nodeText().placeholder(this.parameter);
},
getOptionsOptionDisplayName(option: { value: string; name: string }): string {
return this.isForCredential
? this.$locale.credText().optionsOptionDisplayName(this.parameter, option)
: this.$locale.nodeText().optionsOptionDisplayName(this.parameter, option);
},
getOptionsOptionDescription(option: { value: string; description: string }): string {
return this.isForCredential
? this.$locale.credText().optionsOptionDescription(this.parameter, option)
: this.$locale.nodeText().optionsOptionDescription(this.parameter, option);
},
async loadRemoteParameterOptions () {
if (this.node === null || this.remoteMethod === undefined || this.remoteParameterOptionsLoading) {
return;
@@ -664,6 +690,8 @@ export default mixins(
(this.$refs.inputField.$el.querySelector(this.getStringInputType === 'textarea' ? 'textarea' : 'input') as HTMLInputElement).focus();
}
});
this.$emit('focus');
},
rgbaToHex (value: string): string | null {
// Convert rgba to hex from: https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb
@@ -775,8 +803,12 @@ export default mixins(
<style scoped lang="scss">
.code-edit {
font-size: var(--font-size-xs);
}
.switch-input {
margin: 5px 0;
margin: 2px 0;
}
.parameter-value-container {
@@ -804,7 +836,7 @@ export default mixins(
text-align: right;
float: right;
color: #ff8080;
font-size: 1.2em;
font-size: var(--font-size-s);
}
::v-deep .color-input {

View File

@@ -1,8 +1,9 @@
<template>
<n8n-input-label
:label="parameter.displayName"
:tooltipText="parameter.description"
:label="$locale.credText().topParameterDisplayName(parameter)"
:tooltipText="$locale.credText().topParameterDescription(parameter)"
:required="parameter.required"
:showTooltip="focused"
>
<parameter-input
:parameter="parameter"
@@ -12,14 +13,15 @@
:displayOptions="true"
:documentationUrl="documentationUrl"
:errorHighlight="showRequiredErrors"
:isForCredential="true"
@focus="onFocus"
@blur="onBlur"
@textInput="valueChanged"
@valueChanged="valueChanged"
inputSize="large"
/>
<div class="errors" v-if="showRequiredErrors">
This field is required. <a v-if="documentationUrl" :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">Open docs</a>
{{ $locale.baseText('parameterInputExpanded.thisFieldIsRequired') }} <a v-if="documentationUrl" :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">{{ $locale.baseText('parameterInputExpanded.openDocs') }}</a>
</div>
</n8n-input-label>
</template>
@@ -48,7 +50,8 @@ export default Vue.extend({
},
data() {
return {
blurred: false,
focused: false,
blurredEver: false,
};
},
computed: {
@@ -57,7 +60,7 @@ export default Vue.extend({
return false;
}
if (this.blurred || this.showValidationWarnings) {
if (this.blurredEver || this.showValidationWarnings) {
if (this.$props.parameter.type === 'string') {
return !this.value;
}
@@ -71,8 +74,12 @@ export default Vue.extend({
},
},
methods: {
onFocus() {
this.focused = true;
},
onBlur() {
this.blurred = true;
this.blurredEver = true;
this.focused = false;
},
valueChanged(parameterData: IUpdateInformation) {
this.$emit('change', parameterData);

View File

@@ -1,16 +1,22 @@
<template>
<el-row class="parameter-wrapper" :class="{'multi-line': isMultiLineParameter}">
<el-col :span="isMultiLineParameter ? 24 : 10" class="parameter-name" :class="{'multi-line': isMultiLineParameter}">
<span class="title" :title="parameter.displayName">{{parameter.displayName}}</span>:
<n8n-tooltip class="parameter-info" placement="top" v-if="parameter.description" >
<div slot="content" v-html="addTargetBlank(parameter.description)"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</el-col>
<el-col :span="isMultiLineParameter ? 24 : 14" class="parameter-value">
<parameter-input :parameter="parameter" :value="value" :displayOptions="displayOptions" :path="path" :isReadOnly="isReadOnly" @valueChanged="valueChanged" inputSize="small" />
</el-col>
</el-row>
<n8n-input-label
:label="$locale.nodeText().topParameterDisplayName(parameter)"
:tooltipText="$locale.nodeText().topParameterDescription(parameter)"
:showTooltip="focused"
:bold="false"
size="small"
>
<parameter-input
:parameter="parameter"
:value="value"
:displayOptions="displayOptions"
:path="path"
:isReadOnly="isReadOnly"
@valueChanged="valueChanged"
@focus="focused = true"
@blur="focused = false"
inputSize="small" />
</n8n-input-label>
</template>
<script lang="ts">
@@ -21,7 +27,6 @@ import {
} from '@/Interface';
import ParameterInput from '@/components/ParameterInput.vue';
import { addTargetBlank } from './helpers';
export default Vue
.extend({
@@ -29,21 +34,10 @@ export default Vue
components: {
ParameterInput,
},
computed: {
isMultiLineParameter () {
if (this.level > 4) {
return true;
}
const rows = this.getArgument('rows');
if (rows !== undefined && rows > 1) {
return true;
}
return false;
},
level (): number {
return this.path.split('.').length;
},
data() {
return {
focused: false,
};
},
props: [
'displayOptions',
@@ -53,7 +47,6 @@ export default Vue
'value',
],
methods: {
addTargetBlank,
getArgument (argumentName: string): string | number | boolean | undefined {
if (this.parameter.typeOptions === undefined) {
return undefined;
@@ -71,46 +64,3 @@ export default Vue
},
});
</script>
<style lang="scss">
.parameter-wrapper {
display: flex;
align-items: center;
&.multi-line {
flex-direction: column;
}
.option {
margin: 1em;
}
.parameter-info {
background-color: #ffffffaa;
display: none;
position: absolute;
right: 2px;
top: 1px;
}
.parameter-name {
position: relative;
&:hover {
.parameter-info {
display: inline;
}
}
&.multi-line {
line-height: 1.5em;
}
}
.title {
font-weight: 400;
}
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="paramter-input-list-wrapper">
<div v-for="parameter in filteredParameters" :key="parameter.name">
<div v-for="parameter in filteredParameters" :key="parameter.name" :class="{indent}">
<div
v-if="multipleValues(parameter) === true && parameter.type !== 'fixedCollection'"
class="parameter-item"
@@ -14,28 +14,31 @@
/>
</div>
<div v-else-if="parameter.type === 'notice'" v-html="parameter.displayName" class="parameter-item parameter-notice"></div>
<div v-else-if="parameter.type === 'notice'" class="parameter-item parameter-notice">
<n8n-text size="small">
<span v-html="$locale.nodeText().topParameterDisplayName(parameter)"></span>
</n8n-text>
</div>
<div
v-else-if="['collection', 'fixedCollection'].includes(parameter.type)"
class="multi-parameter"
>
<div class="parameter-name" :title="parameter.displayName">
<div class="delete-option clickable" title="Delete" v-if="hideDelete !== true && !isReadOnly">
<font-awesome-icon
icon="trash"
class="reset-icon clickable"
title="Parameter Options"
@click="deleteOption(parameter.name)"
/>
</div>
{{parameter.displayName}}:
<n8n-tooltip placement="top" class="parameter-info" v-if="parameter.description" >
<div slot="content" v-html="addTargetBlank(parameter.description)"></div>
<font-awesome-icon icon="question-circle"/>
</n8n-tooltip>
<div class="delete-option clickable" :title="$locale.baseText('parameterInputList.delete')" v-if="hideDelete !== true && !isReadOnly">
<font-awesome-icon
icon="trash"
class="reset-icon clickable"
:title="$locale.baseText('parameterInputList.parameterOptions')"
@click="deleteOption(parameter.name)"
/>
</div>
<div>
<n8n-input-label
:label="$locale.nodeText().topParameterDisplayName(parameter)"
:tooltipText="$locale.nodeText().topParameterDescription(parameter)"
size="small"
:underline="true"
:labelHoverableOnly="true"
>
<collection-parameter
v-if="parameter.type === 'collection'"
:parameter="parameter"
@@ -52,15 +55,15 @@
:path="getPath(parameter.name)"
@valueChanged="valueChanged"
/>
</div>
</n8n-input-label>
</div>
<div v-else-if="displayNodeParameter(parameter)" class="parameter-item">
<div class="delete-option clickable" title="Delete" v-if="hideDelete !== true && !isReadOnly">
<div class="delete-option clickable" :title="$locale.baseText('parameterInputList.delete')" v-if="hideDelete !== true && !isReadOnly">
<font-awesome-icon
icon="trash"
class="reset-icon clickable"
title="Delete Parameter"
:title="$locale.baseText('parameterInputList.deleteParameter')"
@click="deleteOption(parameter.name)"
/>
</div>
@@ -93,8 +96,6 @@ import { genericHelpers } from '@/components/mixins/genericHelpers';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import { addTargetBlank } from './helpers';
import { get, set } from 'lodash';
import mixins from 'vue-typed-mixins';
@@ -114,6 +115,7 @@ export default mixins(
'parameters', // INodeProperties
'path', // string
'hideDelete', // boolean
'indent',
],
computed: {
filteredParameters (): INodeProperties[] {
@@ -124,7 +126,6 @@ export default mixins(
},
},
methods: {
addTargetBlank,
multipleValues (parameter: INodeProperties): boolean {
if (this.getArgument('multipleValues', parameter) === true) {
return true;
@@ -260,50 +261,42 @@ export default mixins(
position: absolute;
z-index: 999;
color: #f56c6c;
font-size: var(--font-size-2xs);
&:hover {
color: #ff0000;
}
}
.indent > div {
padding-left: var(--spacing-s);
}
.multi-parameter {
position: relative;
margin: 0.5em 0;
padding: 0.5em 0;
margin: var(--spacing-xs) 0;
>.parameter-name {
font-weight: 600;
border-bottom: 1px solid #999;
&:hover {
.parameter-info {
display: inline;
}
}
.delete-option {
top: 0;
left: -0.9em;
}
.parameter-info {
display: none;
}
.delete-option {
top: 0;
left: 0;
}
.parameter-info {
display: none;
}
}
.parameter-item {
position: relative;
margin: 8px 0;
margin: var(--spacing-xs) 0;
>.delete-option {
left: -0.9em;
top: 0.6em;
top: var(--spacing-5xs);
left: 0;
}
}
.parameter-item:hover > .delete-option,
.parameter-name:hover > .delete-option {
.multi-parameter:hover > .delete-option {
display: block;
}
@@ -311,9 +304,7 @@ export default mixins(
background-color: #fff5d3;
color: $--custom-font-black;
margin: 0.3em 0;
padding: 0.8em;
line-height: 1.5;
word-break: normal;
padding: 0.7em;
a {
font-weight: var(--font-weight-bold);

View File

@@ -1,8 +1,8 @@
<template>
<Modal
:name="PERSONALIZATION_MODAL_KEY"
:title="!submitted? 'Get started' : 'Thanks!'"
:subtitle="!submitted? 'These questions help us tailor n8n to you' : ''"
:title="!submitted? $locale.baseText('personalizationModal.getStarted') : $locale.baseText('personalizationModal.thanks')"
:subtitle="!submitted? $locale.baseText('personalizationModal.theseQuestionsHelpUs') : ''"
:centerTitle="true"
:showClose="false"
:eventBus="modalBus"
@@ -10,101 +10,124 @@
:closeOnPressEscape="false"
width="460px"
@enter="save"
@input="onInput"
>
<template v-slot:content>
<div v-if="submitted" :class="$style.submittedContainer">
<img :class="$style.demoImage" :src="baseUrl + 'suggestednodes.png'" />
<n8n-text>Look out for things marked with a . They are personalized to make n8n more relevant to you.</n8n-text>
<n8n-text>{{ $locale.baseText('personalizationModal.lookOutForThingsMarked') }}</n8n-text>
</div>
<div :class="$style.container" v-else>
<n8n-input-label label="Which of these areas do you mainly work in?">
<n8n-select :value="values[WORK_AREA_KEY]" placeholder="Select..." @change="(value) => onInput(WORK_AREA_KEY, value)">
<n8n-option :value="AUTOMATION_CONSULTING_WORK_AREA" label="Automation consulting" />
<n8n-option :value="FINANCE_WORK_AREA" label="Finance" />
<n8n-option :value="HR_WORK_AREA" label="HR" />
<n8n-option :value="IT_ENGINEERING_WORK_AREA" label="IT / Engineering" />
<n8n-option :value="LEGAL_WORK_AREA" label="Legal" />
<n8n-option :value="MARKETING_WORK_AREA" label="Marketing / Growth" />
<n8n-option :value="OPS_WORK_AREA" label="Operations" />
<n8n-option :value="PRODUCT_WORK_AREA" label="Product" />
<n8n-option :value="SALES_BUSINESSDEV_WORK_AREA" label="Sales / Business Development" />
<n8n-option :value="SECURITY_WORK_AREA" label="Security" />
<n8n-option :value="SUPPORT_WORK_AREA" label="Support" />
<n8n-option :value="OTHER_WORK_AREA_OPTION" label="Other (please specify)" />
</n8n-select>
</n8n-input-label>
<n8n-input
v-if="otherWorkAreaFieldVisible"
:value="values[OTHER_WORK_AREA_KEY]"
placeholder="Specify your work area"
@input="(value) => onInput(OTHER_WORK_AREA_KEY, value)"
/>
<n8n-input-label label="How are your coding skills?">
<n8n-select :value="values[CODING_SKILL_KEY]" placeholder="Select..." @change="(value) => onInput(CODING_SKILL_KEY, value)">
<n8n-input-label :label="$locale.baseText('personalizationModal.howAreYourCodingSkills')">
<n8n-select :value="values[CODING_SKILL_KEY]" :placeholder="$locale.baseText('personalizationModal.select')" @change="(value) => values[CODING_SKILL_KEY] = value">
<n8n-option
label="0 (Never coded)"
:label="baseText('personalizationModal.neverCoded')"
value="0"
/>
<n8n-option
label="1"
:label="baseText('personalizationModal.iGetStuckTooQuicklyToAchieveMuch')"
value="1"
/>
<n8n-option
label="2"
:label="baseText('personalizationModal.iCanCodeSomeUsefulThingsBut')"
value="2"
/>
<n8n-option
label="3"
:label="baseText('personalizationModal.iKnowEnoughToBeDangerousBut')"
value="3"
/>
<n8n-option
label="4"
:label="baseText('personalizationModal.iCanFigureMostThingsOut')"
value="4"
/>
<n8n-option
label="5 (Pro coder)"
:label="baseText('personalizationModal.iCanDoAlmostAnythingIWant')"
value="5"
/>
</n8n-select>
</n8n-input-label>
<n8n-input-label label="How big is your company?">
<n8n-select :value="values[COMPANY_SIZE_KEY]" placeholder="Select..." @change="(value) => onInput(COMPANY_SIZE_KEY, value)">
<n8n-input-label :label="$locale.baseText('personalizationModal.whichOfTheseAreasDoYouMainlyWorkIn')">
<n8n-select :value="values[WORK_AREA_KEY]" multiple :placeholder="$locale.baseText('personalizationModal.select')" @change="(value) => onMultiInput(WORK_AREA_KEY, value)">
<n8n-option :value="FINANCE_WORK_AREA" :label="$locale.baseText('personalizationModal.finance')" />
<n8n-option :value="HR_WORK_AREA" :label="$locale.baseText('personalizationModal.hr')" />
<n8n-option :value="IT_ENGINEERING_WORK_AREA" :label="$locale.baseText('personalizationModal.itEngineering')" />
<n8n-option :value="LEGAL_WORK_AREA" :label="$locale.baseText('personalizationModal.legal')" />
<n8n-option :value="MARKETING_WORK_AREA" :label="$locale.baseText('personalizationModal.marketing')" />
<n8n-option :value="OPS_WORK_AREA" :label="$locale.baseText('personalizationModal.operations')" />
<n8n-option :value="PRODUCT_WORK_AREA" :label="$locale.baseText('personalizationModal.product')" />
<n8n-option :value="SALES_BUSINESSDEV_WORK_AREA" :label="$locale.baseText('personalizationModal.salesBizDev')" />
<n8n-option :value="SECURITY_WORK_AREA" :label="$locale.baseText('personalizationModal.security')" />
<n8n-option :value="SUPPORT_WORK_AREA" :label="$locale.baseText('personalizationModal.support')" />
<n8n-option :value="EXECUTIVE_WORK_AREA" :label="$locale.baseText('personalizationModal.executiveTeam')" />
<n8n-option :value="OTHER_WORK_AREA_OPTION" :label="$locale.baseText('personalizationModal.otherPleaseSpecify')" />
<n8n-option :value="NOT_APPLICABLE_WORK_AREA" :label="$locale.baseText('personalizationModal.imNotUsingN8nForWork')" />
</n8n-select>
</n8n-input-label>
<n8n-input
v-if="otherWorkAreaFieldVisible"
:value="values[OTHER_WORK_AREA_KEY]"
:placeholder="$locale.baseText('personalizationModal.specifyYourWorkArea')"
@input="(value) => values[OTHER_WORK_AREA_KEY] = value"
/>
<section v-if="showAllIndustryQuestions">
<n8n-input-label :label="$locale.baseText('personalizationModal.whichIndustriesIsYourCompanyIn')">
<n8n-select :value="values[COMPANY_INDUSTRY_KEY]" multiple :placeholder="$locale.baseText('personalizationModal.select')" @change="(value) => onMultiInput(COMPANY_INDUSTRY_KEY, value)">
<n8n-option :value="E_COMMERCE_INDUSTRY" :label="$locale.baseText('personalizationModal.eCommerce')" />
<n8n-option :value="AUTOMATION_CONSULTING_INDUSTRY" :label="$locale.baseText('personalizationModal.automationConsulting')" />
<n8n-option :value="SYSTEM_INTEGRATION_INDUSTRY" :label="$locale.baseText('personalizationModal.systemsIntegration')" />
<n8n-option :value="GOVERNMENT_INDUSTRY" :label="$locale.baseText('personalizationModal.government')" />
<n8n-option :value="LEGAL_INDUSTRY" :label="$locale.baseText('personalizationModal.legal')" />
<n8n-option :value="HEALTHCARE_INDUSTRY" :label="$locale.baseText('personalizationModal.healthcare')" />
<n8n-option :value="FINANCE_INDUSTRY" :label="$locale.baseText('personalizationModal.finance')" />
<n8n-option :value="SECURITY_INDUSTRY" :label="$locale.baseText('personalizationModal.security')" />
<n8n-option :value="SAAS_INDUSTRY" :label="$locale.baseText('personalizationModal.saas')" />
<n8n-option :value="OTHER_INDUSTRY_OPTION" :label="$locale.baseText('personalizationModal.otherPleaseSpecify')" />
</n8n-select>
</n8n-input-label>
<n8n-input
v-if="otherCompanyIndustryFieldVisible"
:value="values[OTHER_COMPANY_INDUSTRY_KEY]"
:placeholder="$locale.baseText('personalizationModal.specifyYourCompanysIndustry')"
@input="(value) => values[OTHER_COMPANY_INDUSTRY_KEY] = value"
/>
<n8n-input-label :label="$locale.baseText('personalizationModal.howBigIsYourCompany')">
<n8n-select :value="values[COMPANY_SIZE_KEY]" placeholder="Select..." @change="(value) => values[COMPANY_SIZE_KEY] = value">
<n8n-option
label="Less than 20 people"
:label="$locale.baseText('personalizationModal.lessThan20People')"
:value="COMPANY_SIZE_20_OR_LESS"
/>
<n8n-option
label="20-99 people"
:label="`20-99 ${$locale.baseText('personalizationModal.people')}`"
:value="COMPANY_SIZE_20_99"
/>
<n8n-option
label="100-499 people"
:label="`100-499 ${$locale.baseText('personalizationModal.people')}`"
:value="COMPANY_SIZE_100_499"
/>
<n8n-option
label="500-999 people"
:label="`500-999 ${$locale.baseText('personalizationModal.people')}`"
:value="COMPANY_SIZE_500_999"
/>
<n8n-option
label="1000+ people"
:label="`1000+ ${$locale.baseText('personalizationModal.people')}`"
:value="COMPANY_SIZE_1000_OR_MORE"
/>
<n8n-option
label="I'm not using n8n for work"
:label="$locale.baseText('personalizationModal.imNotUsingN8nForWork')"
:value="COMPANY_SIZE_PERSONAL_USE"
/>
</n8n-select>
</n8n-input-label>
</section>
</div>
</template>
<template v-slot:footer>
<div>
<n8n-button v-if="submitted" @click="closeDialog" label="Get started" float="right" />
<n8n-button v-else @click="save" :loading="isSaving" label="Continue" float="right" />
<n8n-button v-if="submitted" @click="closeDialog" :label="$locale.baseText('personalizationModal.getStarted')" float="right" />
<n8n-button v-else @click="save" :loading="isSaving" :label="$locale.baseText('personalizationModal.continue')" float="right" />
</div>
</template>
</Modal>
@@ -114,29 +137,42 @@
import mixins from "vue-typed-mixins";
import {
PERSONALIZATION_MODAL_KEY,
AUTOMATION_CONSULTING_WORK_AREA,
FINANCE_WORK_AREA,
HR_WORK_AREA,
IT_ENGINEERING_WORK_AREA,
LEGAL_WORK_AREA,
MARKETING_WORK_AREA,
PRODUCT_WORK_AREA,
SALES_BUSINESSDEV_WORK_AREA,
SECURITY_WORK_AREA,
SUPPORT_WORK_AREA,
OPS_WORK_AREA,
OTHER_WORK_AREA_OPTION,
AUTOMATION_CONSULTING_INDUSTRY,
CODING_SKILL_KEY,
COMPANY_INDUSTRY_KEY,
COMPANY_SIZE_100_499,
COMPANY_SIZE_1000_OR_MORE,
COMPANY_SIZE_20_OR_LESS,
COMPANY_SIZE_20_99,
COMPANY_SIZE_100_499,
COMPANY_SIZE_500_999,
COMPANY_SIZE_1000_OR_MORE,
COMPANY_SIZE_PERSONAL_USE,
WORK_AREA_KEY,
COMPANY_SIZE_KEY,
CODING_SKILL_KEY,
COMPANY_SIZE_PERSONAL_USE,
E_COMMERCE_INDUSTRY,
EXECUTIVE_WORK_AREA,
FINANCE_INDUSTRY,
FINANCE_WORK_AREA,
GOVERNMENT_INDUSTRY,
HEALTHCARE_INDUSTRY,
HR_WORK_AREA,
IT_ENGINEERING_WORK_AREA,
LEGAL_INDUSTRY,
LEGAL_WORK_AREA,
MARKETING_WORK_AREA,
NOT_APPLICABLE_WORK_AREA,
OPS_WORK_AREA,
OTHER_COMPANY_INDUSTRY_KEY,
OTHER_INDUSTRY_OPTION,
OTHER_WORK_AREA_KEY,
OTHER_WORK_AREA_OPTION,
PERSONALIZATION_MODAL_KEY,
PRODUCT_WORK_AREA,
SAAS_INDUSTRY,
SALES_BUSINESSDEV_WORK_AREA,
SECURITY_INDUSTRY,
SECURITY_WORK_AREA,
SUPPORT_WORK_AREA,
SYSTEM_INTEGRATION_INDUSTRY,
WORK_AREA_KEY,
} from "../constants";
import { workflowHelpers } from "@/components/mixins/workflowHelpers";
import { showMessage } from "@/components/mixins/showMessage";
@@ -154,14 +190,17 @@ export default mixins(showMessage, workflowHelpers).extend({
isSaving: false,
PERSONALIZATION_MODAL_KEY,
otherWorkAreaFieldVisible: false,
otherCompanyIndustryFieldVisible: false,
showAllIndustryQuestions: true,
modalBus: new Vue(),
values: {
[WORK_AREA_KEY]: null,
[WORK_AREA_KEY]: [],
[COMPANY_SIZE_KEY]: null,
[CODING_SKILL_KEY]: null,
[OTHER_WORK_AREA_KEY]: null,
[COMPANY_INDUSTRY_KEY]: [],
[OTHER_COMPANY_INDUSTRY_KEY]: null,
} as IPersonalizationSurveyAnswers,
AUTOMATION_CONSULTING_WORK_AREA,
FINANCE_WORK_AREA,
HR_WORK_AREA,
IT_ENGINEERING_WORK_AREA,
@@ -170,19 +209,33 @@ export default mixins(showMessage, workflowHelpers).extend({
PRODUCT_WORK_AREA,
SALES_BUSINESSDEV_WORK_AREA,
SECURITY_WORK_AREA,
EXECUTIVE_WORK_AREA,
SUPPORT_WORK_AREA,
OPS_WORK_AREA,
OTHER_WORK_AREA_OPTION,
NOT_APPLICABLE_WORK_AREA,
COMPANY_SIZE_20_OR_LESS,
COMPANY_SIZE_20_99,
COMPANY_SIZE_100_499,
COMPANY_SIZE_500_999,
COMPANY_SIZE_1000_OR_MORE,
COMPANY_SIZE_PERSONAL_USE,
E_COMMERCE_INDUSTRY,
AUTOMATION_CONSULTING_INDUSTRY,
SYSTEM_INTEGRATION_INDUSTRY,
GOVERNMENT_INDUSTRY,
LEGAL_INDUSTRY,
HEALTHCARE_INDUSTRY,
FINANCE_INDUSTRY,
SECURITY_INDUSTRY,
SAAS_INDUSTRY,
OTHER_INDUSTRY_OPTION,
WORK_AREA_KEY,
COMPANY_SIZE_KEY,
CODING_SKILL_KEY,
COMPANY_INDUSTRY_KEY,
OTHER_WORK_AREA_KEY,
OTHER_COMPANY_INDUSTRY_KEY,
};
},
computed: {
@@ -194,16 +247,19 @@ export default mixins(showMessage, workflowHelpers).extend({
closeDialog() {
this.modalBus.$emit('close');
},
onInput(name: IPersonalizationSurveyKeys, value: string) {
if (name === WORK_AREA_KEY && value === OTHER_WORK_AREA_OPTION) {
this.otherWorkAreaFieldVisible = true;
onMultiInput(name: IPersonalizationSurveyKeys, value: string[]) {
if (name === WORK_AREA_KEY) {
this.otherWorkAreaFieldVisible = value.includes(OTHER_WORK_AREA_OPTION);
this.showAllIndustryQuestions = !value.includes(NOT_APPLICABLE_WORK_AREA);
this.values[OTHER_WORK_AREA_KEY] = value.includes(OTHER_WORK_AREA_OPTION) ? this.values[OTHER_WORK_AREA_KEY] : null;
this.values[WORK_AREA_KEY] = value;
}
else if (name === WORK_AREA_KEY) {
this.otherWorkAreaFieldVisible = false;
this.values[OTHER_WORK_AREA_KEY] = null;
if (name === COMPANY_INDUSTRY_KEY) {
this.otherCompanyIndustryFieldVisible = value.includes(OTHER_INDUSTRY_OPTION);
this.values[OTHER_COMPANY_INDUSTRY_KEY] = value.includes(OTHER_INDUSTRY_OPTION) ? this.values[OTHER_COMPANY_INDUSTRY_KEY] : null;
this.values[COMPANY_INDUSTRY_KEY] = value;
}
this.values[name] = value;
},
async save(): Promise<void> {
this.$data.isSaving = true;
@@ -228,7 +284,7 @@ export default mixins(showMessage, workflowHelpers).extend({
<style lang="scss" module>
.container {
> div:not(:last-child) {
> div, section > div:not(:last-child) {
margin-bottom: var(--spacing-m);
}
}
@@ -246,4 +302,5 @@ export default mixins(showMessage, workflowHelpers).extend({
height: 140px;
}
</style>

View File

@@ -3,12 +3,10 @@
<div class="push-connection-lost primary-color" v-if="!pushConnectionActive">
<n8n-tooltip placement="bottom-end" >
<div slot="content">
Cannot connect to server.<br />
It is either down or you have a connection issue. <br />
It should reconnect automatically once the issue is resolved.
{{ $locale.baseText('pushConnectionTracker.cannotConnectToServer') }}
</div>
<span>
<font-awesome-icon icon="exclamation-triangle" />&nbsp; Connection lost
<font-awesome-icon icon="exclamation-triangle" />&nbsp; {{ $locale.baseText('pushConnectionTracker.connectionLost') }}
</span>
</n8n-tooltip>
</div>

View File

@@ -7,41 +7,41 @@
class="execute-node-button"
>
<n8n-button
:title="`Executes this ${node.name} node after executing any previous nodes that have not yet returned data`"
:title="$locale.baseText('runData.executesThisNodeAfterExecuting', { interpolate: { nodeName: node.name } })"
:loading="workflowRunning"
icon="play-circle"
label="Execute Node"
:label="$locale.baseText('runData.executeNode')"
@click.stop="runWorkflow(node.name, 'RunData.ExecuteNodeButton')"
/>
</div>
<div class="header">
<div class="title-text">
<strong v-if="dataCount < maxDisplayItems">
Items: {{ dataCount }}
</strong>
<n8n-text :bold="true" v-if="dataCount < maxDisplayItems">
{{ $locale.baseText('runData.items') }}: {{ dataCount }}
</n8n-text>
<div v-else class="title-text">
<strong>Items:</strong>
<n8n-text :bold="true">{{ $locale.baseText('runData.items') }}:</n8n-text>
<span class="opts">
<n8n-select size="mini" v-model="maxDisplayItems" @click.stop>
<n8n-option v-for="option in maxDisplayItemsOptions" :label="option" :value="option" :key="option" />
</n8n-select>
</span>/
<strong>{{ dataCount }}</strong>
<n8n-text :bold="true">{{ dataCount }}</n8n-text>
</div>
<n8n-tooltip
v-if="runMetadata"
placement="right"
>
<div slot="content">
<strong>Start Time:</strong> {{runMetadata.startTime}}<br/>
<strong>Execution Time:</strong> {{runMetadata.executionTime}} ms
<n8n-text :bold="true" size="small">{{ $locale.baseText('runData.startTime') + ':' }}</n8n-text> {{runMetadata.startTime}}<br/>
<n8n-text :bold="true" size="small">{{ $locale.baseText('runData.executionTime') + ':' }}</n8n-text> {{runMetadata.executionTime}} {{ $locale.baseText('runData.ms') }}
</div>
<font-awesome-icon icon="info-circle" class="primary-color" />
</n8n-tooltip>
<span v-if="maxOutputIndex > 0">
| Output:
</span>
<n8n-text :bold="true" v-if="maxOutputIndex > 0">
| {{ $locale.baseText('runData.output') }}:
</n8n-text>
<span class="opts" v-if="maxOutputIndex > 0" >
<n8n-select size="mini" v-model="outputIndex" @click.stop>
<n8n-option v-for="option in (maxOutputIndex + 1)" :label="getOutputName(option-1)" :value="option -1" :key="option">
@@ -49,9 +49,9 @@
</n8n-select>
</span>
<span v-if="maxRunIndex > 0">
| Data of Execution:
</span>
<n8n-text :bold="true" v-if="maxRunIndex > 0">
| {{ $locale.baseText('runData.dataOfExecution') }}:
</n8n-text>
<span class="opts">
<n8n-select v-if="maxRunIndex > 0" size="mini" v-model="runIndex" @click.stop>
<n8n-option v-for="option in (maxRunIndex + 1)" :label="option + '/' + (maxRunIndex+1)" :value="option-1" :key="option">
@@ -62,20 +62,26 @@
</div>
<div v-if="hasNodeRun && !hasRunError" class="title-data-display-selector" @click.stop>
<el-radio-group v-model="displayMode" size="mini">
<el-radio-button label="JSON" :disabled="showData === false"></el-radio-button>
<el-radio-button label="Table"></el-radio-button>
<el-radio-button label="Binary" v-if="binaryData.length !== 0"></el-radio-button>
<el-radio-button :label="$locale.baseText('runData.json')" :disabled="showData === false"></el-radio-button>
<el-radio-button :label="$locale.baseText('runData.table')"></el-radio-button>
<el-radio-button :label="$locale.baseText('runData.binary')" v-if="binaryData.length !== 0"></el-radio-button>
</el-radio-group>
</div>
<div v-if="hasNodeRun && !hasRunError && displayMode === 'JSON' && state.path !== deselectedPlaceholder" class="select-button">
<el-dropdown trigger="click" @command="handleCopyClick">
<span class="el-dropdown-link">
<n8n-icon-button title="Copy to Clipboard" icon="copy" />
<n8n-icon-button :title="$locale.baseText('runData.copyToClipboard')" icon="copy" />
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item :command="{command: 'itemPath'}">Copy Item Path</el-dropdown-item>
<el-dropdown-item :command="{command: 'parameterPath'}">Copy Parameter Path</el-dropdown-item>
<el-dropdown-item :command="{command: 'value'}">Copy Value</el-dropdown-item>
<el-dropdown-item :command="{command: 'itemPath'}">
{{ $locale.baseText('runData.copyItemPath') }}
</el-dropdown-item>
<el-dropdown-item :command="{command: 'parameterPath'}">
{{ $locale.baseText('runData.copyParameterPath') }}
</el-dropdown-item>
<el-dropdown-item :command="{command: 'value'}">
{{ $locale.baseText('runData.copyValue') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
@@ -88,29 +94,33 @@
<span v-else>
<div v-if="showData === false" class="too-much-data">
<h3>
Node returned a large amount of data
{{ $locale.baseText('runData.nodeReturnedALargeAmountOfData') }}
</h3>
<div class="text">
The node contains {{parseInt(dataSize/1024).toLocaleString()}} KB of data.<br />
Displaying it could cause problems!<br />
<br />
If you do decide to display it, avoid the JSON view!
{{ $locale.baseText(
'runData.theNodeContains',
{
interpolate: {
numberOfKb: parseInt(dataSize/1024).toLocaleString()
}
}
)}}
</div>
<n8n-button
icon="eye"
label="Display Data Anyway"
:label="$locale.baseText('runData.displayDataAnyway')"
@click="displayMode = 'Table';showData = true;"
/>
</div>
<div v-else-if="['JSON', 'Table'].includes(displayMode)">
<div v-if="jsonData.length === 0" class="no-data">
No text data found
{{ $locale.baseText('runData.noTextDataFound') }}
</div>
<div v-else-if="displayMode === 'Table'">
<div v-if="tableData !== null && tableData.columns.length === 0" class="no-data">
Entries exist but they do not contain any JSON data.
{{ $locale.baseText('runData.entriesExistButThey') }}
</div>
<table v-else-if="tableData !== null">
<tr>
@@ -138,7 +148,7 @@
</div>
<div v-else-if="displayMode === 'Binary'">
<div v-if="binaryData.length === 0" class="no-data">
No binary data found
{{ $locale.baseText('runData.noBinaryDataFound') }}
</div>
<div v-else>
@@ -156,24 +166,24 @@
{{key}}
</div>
<div v-if="binaryData.fileName">
<div class="label">File Name: </div>
<div class="label">{{ $locale.baseText('runData.fileName') }}: </div>
<div class="value">{{binaryData.fileName}}</div>
</div>
<div v-if="binaryData.directory">
<div class="label">Directory: </div>
<div class="label">{{ $locale.baseText('runData.directory') }}: </div>
<div class="value">{{binaryData.directory}}</div>
</div>
<div v-if="binaryData.fileExtension">
<div class="label">File Extension:</div>
<div class="label">{{ $locale.baseText('runData.fileExtension') }}:</div>
<div class="value">{{binaryData.fileExtension}}</div>
</div>
<div v-if="binaryData.mimeType">
<div class="label">Mime Type: </div>
<div class="label">{{ $locale.baseText('runData.mimeType') }}: </div>
<div class="value">{{binaryData.mimeType}}</div>
</div>
<div class="binary-data-show-data-button-wrapper">
<n8n-button size="small" label="Show Binary Data" class="binary-data-show-data-button" @click="displayBinaryData(index, key)" />
<n8n-button size="small" :label="$locale.baseText('runData.showBinaryData')" class="binary-data-show-data-button" @click="displayBinaryData(index, key)" />
</div>
</div>
@@ -186,9 +196,9 @@
</span>
<div v-else class="message">
<div>
<strong>No data</strong><br />
<n8n-text :bold="true">{{ $locale.baseText('runData.noData') }}</n8n-text ><br />
<br />
Data returned by this node will display here<br />
{{ $locale.baseText('runData.dataReturnedByThisNodeWillDisplayHere') }}<br />
</div>
</div>
</div>
@@ -203,6 +213,7 @@ import {
IBinaryKeyData,
IDataObject,
INodeExecutionData,
INodeTypeDescription,
IRunData,
IRunExecutionData,
ITaskData,
@@ -221,7 +232,7 @@ import {
} from '@/constants';
import BinaryDataDisplay from '@/components/BinaryDataDisplay.vue';
import NodeErrorView from '@/components/Error/NodeViewError.vue';
import NodeErrorView from '@/components/Error/NodeErrorView.vue';
import { copyPaste } from '@/components/mixins/copyPaste';
import { externalHooks } from "@/components/mixins/externalHooks";
@@ -529,8 +540,8 @@ export default mixins(
return outputIndex + 1;
}
const nodeType = this.$store.getters.nodeType(this.node.type);
if (!nodeType.hasOwnProperty('outputNames') || nodeType.outputNames.length <= outputIndex) {
const nodeType = this.$store.getters.nodeType(this.node.type) as INodeTypeDescription | null;
if (!nodeType || !nodeType.outputNames || nodeType.outputNames.length <= outputIndex) {
return outputIndex + 1;
}
@@ -659,6 +670,7 @@ export default mixins(
overflow-y: auto;
line-height: 1.5;
word-break: normal;
font-size: var(--font-size-s);
.binary-data-row {
display: inline-flex;

View File

@@ -1,9 +1,9 @@
<template>
<span :class="$style.container">
<span :class="$style.saved" v-if="saved">{{ savedLabel }}</span>
<span :class="$style.saved" v-if="saved">{{ $locale.baseText('saveButton.saved') }}</span>
<n8n-button
v-else
:label="isSaving ? savingLabel : saveLabel"
:label="saveButtonLabel"
:loading="isSaving"
:disabled="disabled"
@click="$emit('click')"
@@ -28,15 +28,19 @@ export default Vue.extend({
},
saveLabel: {
type: String,
default: 'Save',
},
savingLabel: {
type: String,
default: 'Saving',
},
savedLabel: {
type: String,
default: 'Saved',
},
},
computed: {
saveButtonLabel() {
return this.isSaving
? this.$locale.baseText('saveButton.saving')
: this.$locale.baseText('saveButton.save');
},
},
});

View File

@@ -24,12 +24,14 @@
ref="create"
>
<font-awesome-icon icon="plus-circle" />
<span>Create tag "{{ filter }}"</span>
<span>
{{ $locale.baseText('tagsDropdown.createTag', { interpolate: { filter } }) }}
</span>
</n8n-option>
<n8n-option v-else-if="options.length === 0" value="message" disabled>
<span v-if="createEnabled">Type to create a tag</span>
<span v-else-if="allTags.length > 0">No matching tags exist</span>
<span v-else>No tags exist</span>
<span v-if="createEnabled">{{ $locale.baseText('tagsDropdown.typeToCreateATag') }}</span>
<span v-else-if="allTags.length > 0">{{ $locale.baseText('tagsDropdown.noMatchingTagsExist') }}</span>
<span v-else>{{ $locale.baseText('tagsDropdown.noTagsExist') }}</span>
</n8n-option>
<!-- key is id+index for keyboard navigation to work well with filter -->
@@ -44,7 +46,7 @@
<n8n-option :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags">
<font-awesome-icon icon="cog" />
<span>Manage tags</span>
<span>{{ $locale.baseText('tagsDropdown.manageTags') }}</span>
</n8n-option>
</n8n-select>
</div>
@@ -139,8 +141,11 @@ export default mixins(showMessage).extend({
} catch (error) {
this.$showError(
error,
"New tag was not created",
`A problem occurred when trying to create the "${name}" tag`,
this.$locale.baseText('tagsDropdown.showError.title'),
this.$locale.baseText(
'tagsDropdown.showError.message',
{ interpolate: { name } },
),
);
}
},

View File

@@ -3,10 +3,9 @@
<el-col class="notags" :span="16">
<div class="icon">🗄</div>
<div>
<div class="headline">Ready to organize your workflows?</div>
<div class="headline">{{ $locale.baseText('noTagsView.readyToOrganizeYourWorkflows') }}</div>
<div class="description">
With workflow tags, you're free to create the perfect tagging system for
your flows
{{ $locale.baseText('noTagsView.withWorkflowTagsYouReFree') }}
</div>
</div>
<n8n-button label="Create a tag" size="large" @click="$emit('enableCreate')" />

View File

@@ -1,6 +1,6 @@
<template>
<Modal
title="Manage tags"
:title="$locale.baseText('tagsManager.manageTags')"
:name="TAGS_MANAGER_MODAL_KEY"
:eventBus="modalBus"
@enter="onEnter"
@@ -25,7 +25,7 @@
</el-row>
</template>
<template v-slot:footer="{ close }">
<n8n-button label="Done" @click="close" float="right" />
<n8n-button :label="$locale.baseText('tagsManager.done')" @click="close" float="right" />
</template>
</Modal>
</template>
@@ -86,7 +86,9 @@ export default mixins(showMessage).extend({
async onCreate(name: string, cb: (tag: ITag | null, error?: Error) => void) {
try {
if (!name) {
throw new Error("Tag name cannot be empty");
throw new Error(
this.$locale.baseText('tagsManager.tagNameCannotBeEmpty'),
);
}
const newTag = await this.$store.dispatch("tags/create", name);
@@ -96,8 +98,11 @@ export default mixins(showMessage).extend({
const escapedName = escape(name);
this.$showError(
error,
"New tag was not created",
`A problem occurred when trying to create the "${escapedName}" tag`,
this.$locale.baseText('tagsManager.showError.onCreate.title'),
this.$locale.baseText(
'tagsManager.showError.onCreate.message',
{ interpolate: { escapedName } },
) + ':',
);
cb(null, error);
}
@@ -109,7 +114,9 @@ export default mixins(showMessage).extend({
try {
if (!name) {
throw new Error("Tag name cannot be empty");
throw new Error(
this.$locale.baseText('tagsManager.tagNameCannotBeEmpty'),
);
}
if (name === oldName) {
@@ -124,16 +131,22 @@ export default mixins(showMessage).extend({
const escapedOldName = escape(oldName);
this.$showMessage({
title: "Tag was updated",
message: `The "${escapedOldName}" tag was successfully updated to "${escapedName}"`,
title: this.$locale.baseText('tagsManager.showMessage.onUpdate.title'),
message: this.$locale.baseText(
'tagsManager.showMessage.onUpdate.message',
{ interpolate: { escapedName, escapedOldName } },
),
type: "success",
});
} catch (error) {
const escapedName = escape(oldName);
this.$showError(
error,
"Tag was not updated",
`A problem occurred when trying to update the "${escapedName}" tag`,
this.$locale.baseText('tagsManager.showError.onUpdate.title'),
this.$locale.baseText(
'tagsManager.showError.onUpdate.message',
{ interpolate: { escapedName } },
) + ':',
);
cb(false, error);
}
@@ -146,7 +159,9 @@ export default mixins(showMessage).extend({
try {
const deleted = await this.$store.dispatch("tags/delete", id);
if (!deleted) {
throw new Error('Could not delete tag');
throw new Error(
this.$locale.baseText('tagsManager.couldNotDeleteTag'),
);
}
this.$data.tagIds = this.$data.tagIds.filter((tagId: string) => tagId !== id);
@@ -155,16 +170,22 @@ export default mixins(showMessage).extend({
const escapedName = escape(name);
this.$showMessage({
title: "Tag was deleted",
message: `The "${escapedName}" tag was successfully deleted from your tag collection`,
title: this.$locale.baseText('tagsManager.showMessage.onDelete.title'),
message: this.$locale.baseText(
'tagsManager.showMessage.onDelete.message',
{ interpolate: { escapedName } },
),
type: "success",
});
} catch (error) {
const escapedName = escape(name);
this.$showError(
error,
"Tag was not deleted",
`A problem occurred when trying to delete the "${escapedName}" tag`,
this.$locale.baseText('tagsManager.showError.onDelete.title'),
this.$locale.baseText(
'tagsManager.showError.onDelete.message',
{ interpolate: { escapedName } },
) + ':',
);
cb(false, error);
}

View File

@@ -3,13 +3,13 @@
stripe
max-height="450"
ref="table"
empty-text="No matching tags exist"
:empty-text="$locale.baseText('tagsTable.noMatchingTagsExist')"
:data="rows"
:span-method="getSpan"
:row-class-name="getRowClasses"
v-loading="isLoading"
>
<el-table-column label="Name">
<el-table-column :label="$locale.baseText('tagsTable.name')">
<template slot-scope="scope">
<div class="name" :key="scope.row.id" @keydown.stop>
<transition name="fade" mode="out-in">
@@ -21,7 +21,7 @@
ref="nameInput"
></n8n-input>
<span v-else-if="scope.row.delete">
<span>Are you sure you want to delete this tag?</span>
<span>{{ $locale.baseText('tagsTable.areYouSureYouWantToDeleteThisTag') }}</span>
<input ref="deleteHiddenInput" class="hidden" />
</span>
<span v-else :class="{ disabled: scope.row.disable }">
@@ -31,7 +31,7 @@
</div>
</template>
</el-table-column>
<el-table-column label="Usage" width="150">
<el-table-column :label="$locale.baseText('tagsTable.usage')" width="150">
<template slot-scope="scope">
<transition name="fade" mode="out-in">
<div v-if="!scope.row.create && !scope.row.delete" :class="{ disabled: scope.row.disable }">
@@ -44,20 +44,20 @@
<template slot-scope="scope">
<transition name="fade" mode="out-in">
<div class="ops" v-if="scope.row.create">
<n8n-button label="Cancel" @click.stop="cancel" type="outline" :disabled="isSaving" />
<n8n-button label="Create tag" @click.stop="apply" :loading="isSaving" />
<n8n-button :label="$locale.baseText('tagsTable.cancel')" @click.stop="cancel" type="outline" :disabled="isSaving" />
<n8n-button :label="$locale.baseText('tagsTable.createTag')" @click.stop="apply" :loading="isSaving" />
</div>
<div class="ops" v-else-if="scope.row.update">
<n8n-button label="Cancel" @click.stop="cancel" type="outline" :disabled="isSaving" />
<n8n-button label="Save changes" @click.stop="apply" :loading="isSaving" />
<n8n-button :label="$locale.baseText('tagsTable.cancel')" @click.stop="cancel" type="outline" :disabled="isSaving" />
<n8n-button :label="$locale.baseText('tagsTable.saveChanges')" @click.stop="apply" :loading="isSaving" />
</div>
<div class="ops" v-else-if="scope.row.delete">
<n8n-button label="Cancel" @click.stop="cancel" type="outline" :disabled="isSaving" />
<n8n-button label="Delete tag" @click.stop="apply" :loading="isSaving" />
<n8n-button :label="$locale.baseText('tagsTable.cancel')" @click.stop="cancel" type="outline" :disabled="isSaving" />
<n8n-button :label="$locale.baseText('tagsTable.deleteTag')" @click.stop="apply" :loading="isSaving" />
</div>
<div class="ops main" v-else-if="!scope.row.disable">
<n8n-icon-button title="Edit Tag" @click.stop="enableUpdate(scope.row)" icon="pen" />
<n8n-icon-button title="Delete Tag" @click.stop="enableDelete(scope.row)" icon="trash" />
<n8n-icon-button :title="$locale.baseText('tagsTable.editTag')" @click.stop="enableUpdate(scope.row)" icon="pen" />
<n8n-icon-button :title="$locale.baseText('tagsTable.deleteTag')" @click.stop="enableDelete(scope.row)" icon="trash" />
</div>
</transition>
</template>

View File

@@ -2,7 +2,7 @@
<el-row class="tags-header">
<el-col :span="10">
<n8n-input
placeholder="Search tags"
:placeholder="$locale.baseText('tagsTableHeader.searchTags')"
:value="search"
@input="onSearchChange"
:disabled="disabled"
@@ -13,7 +13,7 @@
</n8n-input>
</el-col>
<el-col :span="14">
<n8n-button @click="onAddNew" :disabled="disabled" icon="plus" label="Add new" size="large" float="right" />
<n8n-button @click="onAddNew" :disabled="disabled" icon="plus" :label="$locale.baseText('tagsTableHeader.addNew')" size="large" float="right" />
</el-col>
</el-row>
</template>
@@ -21,6 +21,7 @@
<script lang="ts">
import { MAX_TAG_NAME_LENGTH } from "@/constants";
import Vue from "vue";
export default Vue.extend({
props: {
disabled: {

View File

@@ -31,9 +31,9 @@ import Vue from "vue";
import { ITag, ITagRow } from "@/Interface";
import TagsTableHeader from "@/components/TagsManager/TagsView/TagsTableHeader.vue";
import TagsTable from "@/components/TagsManager/TagsView/TagsTable.vue";
import mixins from "vue-typed-mixins";
const matches = (name: string, filter: string) => name.toLowerCase().trim().includes(filter.toLowerCase().trim());
const getUsage = (count: number | undefined) => count && count > 0 ? `${count} workflow${count > 1 ? "s" : ""}` : 'Not being used';
export default Vue.extend({
components: { TagsTableHeader, TagsTable },
@@ -55,6 +55,18 @@ export default Vue.extend({
return (this.$props.tags || []).length === 0 || this.$data.createEnabled;
},
rows(): ITagRow[] {
const getUsage = (count: number | undefined) => count && count > 0
? this.$locale.baseText(
count > 1 ?
'tagsView.inUse.plural' : 'tagsView.inUse.singular',
{
interpolate: {
count: count.toString(),
},
},
)
: this.$locale.baseText('tagsView.notBeingUsed');
const disabled = this.isCreateEnabled || this.$data.updateId || this.$data.deleteId;
const tagRows = (this.$props.tags || [])
.filter((tag: ITag) => this.stickyIds.has(tag.id) || matches(tag.name, this.$data.search))
@@ -102,7 +114,7 @@ export default Vue.extend({
this.stickyIds.add(this.updateId);
this.disableUpdate();
}
};
};
this.$emit("update", this.updateId, name, onUpdate);
},

View File

@@ -1,14 +1,13 @@
<template>
<div v-if="dialogVisible">
<el-dialog :visible="dialogVisible" append-to-body width="80%" :title="`Edit ${parameter.displayName}`" :before-close="closeDialog">
<el-dialog :visible="dialogVisible" append-to-body width="80%" :title="`${$locale.baseText('textEdit.edit')} ${$locale.nodeText().topParameterDisplayName(parameter)}`" :before-close="closeDialog">
<div class="text-editor-wrapper ignore-key-press">
<div class="editor-description">
{{parameter.displayName}}:
</div>
<div class="text-editor" @keydown.stop @keydown.esc="closeDialog()">
<n8n-input v-model="tempValue" type="textarea" ref="inputField" :value="value" :placeholder="parameter.placeholder" @change="valueChanged" @keydown.stop="noOp" :rows="15" />
</div>
<div class="ignore-key-press">
<n8n-input-label :label="$locale.nodeText().topParameterDisplayName(parameter)">
<div @keydown.stop @keydown.esc="closeDialog()">
<n8n-input v-model="tempValue" type="textarea" ref="inputField" :value="value" :placeholder="$locale.nodeText().placeholder(parameter)" @change="valueChanged" @keydown.stop="noOp" :rows="15" />
</div>
</n8n-input-label>
</div>
</el-dialog>
@@ -19,7 +18,6 @@
import Vue from 'vue';
export default Vue.extend({
name: 'TextEdit',
props: [
'dialogVisible',
@@ -60,10 +58,3 @@ export default Vue.extend({
},
});
</script>
<style scoped>
.editor-description {
font-weight: bold;
padding: 0 0 0.5em 0.2em;;
}
</style>

View File

@@ -1,39 +1,17 @@
<template functional>
<span :title="$options.methods.convertToHumanReadableDate($props)">
{{$options.methods.format(props)}}
<template>
<span :title="convertDate">
{{ format }}
</span>
</template>
<script lang="ts">
import { format, LocaleFunc, register } from 'timeago.js';
import { convertToHumanReadableDate } from './helpers';
import Vue from 'vue';
import { mapGetters } from 'vuex';
const localeFunc = (num: number, index: number, totalSec: number): [string, string] => {
// number: the timeago / timein number;
// index: the index of array below;
// totalSec: total seconds between date to be formatted and today's date;
return [
['Just now', 'Right now'],
['Just now', 'Right now'], // ['%s seconds ago', 'in %s seconds'],
['1 minute ago', 'in 1 minute'],
['%s minutes ago', 'in %s minutes'],
['1 hour ago', 'in 1 hour'],
['%s hours ago', 'in %s hours'],
['1 day ago', 'in 1 day'],
['%s days ago', 'in %s days'],
['1 week ago', 'in 1 week'],
['%s weeks ago', 'in %s weeks'],
['1 month ago', 'in 1 month'],
['%s months ago', 'in %s months'],
['1 year ago', 'in 1 year'],
['%s years ago', 'in %s years'],
][index] as [string, string];
};
register('main', localeFunc as LocaleFunc);
export default {
name: 'UpdatesPanel',
export default Vue.extend({
name: 'TimeAgo',
props: {
date: {
type: String,
@@ -43,17 +21,48 @@ export default {
default: false,
},
},
beforeMount() {
register(this.defaultLocale, this.localeFunc as LocaleFunc);
},
methods: {
format(props: {date: string, capitalize: boolean}) {
const text = format(props.date, 'main');
localeFunc(num: number, index: number, totalSec: number): [string, string] {
// number: the timeago / timein number;
// index: the index of array below;
// totalSec: total seconds between date to be formatted and today's date;
return [
[this.$locale.baseText('timeAgo.justNow'), this.$locale.baseText('timeAgo.rightNow')],
[this.$locale.baseText('timeAgo.justNow'), this.$locale.baseText('timeAgo.rightNow')], // ['%s seconds ago', 'in %s seconds'],
[this.$locale.baseText('timeAgo.oneMinuteAgo'), this.$locale.baseText('timeAgo.inOneMinute')],
[this.$locale.baseText('timeAgo.minutesAgo'), this.$locale.baseText('timeAgo.inMinutes')],
[this.$locale.baseText('timeAgo.oneHourAgo'), this.$locale.baseText('timeAgo.inOneHour')],
[this.$locale.baseText('timeAgo.hoursAgo'), this.$locale.baseText('timeAgo.inHours')],
[this.$locale.baseText('timeAgo.oneDayAgo'), this.$locale.baseText('timeAgo.inOneDay')],
[this.$locale.baseText('timeAgo.daysAgo'), this.$locale.baseText('timeAgo.inDays')],
[this.$locale.baseText('timeAgo.oneWeekAgo'), this.$locale.baseText('timeAgo.inOneWeek')],
[this.$locale.baseText('timeAgo.weeksAgo'), this.$locale.baseText('timeAgo.inWeeks')],
[this.$locale.baseText('timeAgo.oneMonthAgo'), this.$locale.baseText('timeAgo.inOneMonth')],
[this.$locale.baseText('timeAgo.monthsAgo'), this.$locale.baseText('timeAgo.inMonths')],
[this.$locale.baseText('timeAgo.oneYearAgo'), this.$locale.baseText('timeAgo.inOneYear')],
[this.$locale.baseText('timeAgo.yearsAgo'), this.$locale.baseText('timeAgo.inYears')],
][index] as [string, string];
},
},
computed: {
...mapGetters(['defaultLocale']),
format(): string {
const text = format(this.date, this.defaultLocale);
if (!props.capitalize) {
if (!this.capitalize) {
return text.toLowerCase();
}
return text;
},
convertToHumanReadableDate,
convertDate(): string {
const date = new Date(this.date);
const epoch = date.getTime() / 1000;
return convertToHumanReadableDate(epoch);
},
},
};
});
</script>

View File

@@ -5,16 +5,26 @@
width="520px"
>
<template slot="header">
<span :class="$style.title">Weve been busy </span>
<span :class="$style.title">
{{ $locale.baseText('updatesPanel.weVeBeenBusy') }}
</span>
</template>
<template slot="content">
<section :class="$style['description']">
<p v-if="currentVersion">
Youre on {{ currentVersion.name }}, which was released
<strong><TimeAgo :date="currentVersion.createdAt" /></strong> and is
<strong>{{ nextVersions.length }} version{{nextVersions.length > 1 ? "s" : ""}}</strong>
behind the latest and greatest n8n
{{ $locale.baseText(
'updatesPanel.youReOnVersion',
{ interpolate: { currentVersionName: currentVersion.name } }
) }}
<strong><TimeAgo :date="currentVersion.createdAt" /></strong>{{ $locale.baseText('updatesPanel.andIs') }} <strong>{{ $locale.baseText(
'updatesPanel.version',
{
interpolate: {
numberOfVersions: nextVersions.length,
howManySuffix: nextVersions.length > 1 ? "s" : "",
}
}
)}}</strong> {{ $locale.baseText('updatesPanel.behindTheLatest') }}
</p>
<a
@@ -24,7 +34,9 @@
target="_blank"
>
<font-awesome-icon icon="info-circle"></font-awesome-icon>
<span>How to update your n8n version</span>
<span>
{{ $locale.baseText('updatesPanel.howToUpdateYourN8nVersion') }}
</span>
</a>
</section>

View File

@@ -0,0 +1,283 @@
<template>
<ModalDrawer
:name="VALUE_SURVEY_MODAL_KEY"
:beforeClose="closeDialog"
:modal="false"
:wrapperClosable="false"
direction="btt"
width="120px"
class="value-survey"
>
<template slot="header">
<div :class="$style.title">
<n8n-heading tag="h2" size="medium" color="text-xlight">{{ getTitle }}</n8n-heading>
</div>
</template>
<template slot="content">
<section :class="$style.content">
<div v-if="showButtons" :class="$style.wrapper">
<div :class="$style.buttons">
<div v-for="value in 11" :key="value - 1" :class="$style.container">
<n8n-square-button
:label="(value - 1).toString()"
@click="selectSurveyValue((value - 1).toString())"
/>
</div>
</div>
<div :class="$style.text">
<n8n-text size="small" color="text-xlight">Not likely</n8n-text>
<n8n-text size="small" color="text-xlight">Very likely</n8n-text>
</div>
</div>
<div v-else :class="$style.email">
<div :class="$style.input" @keyup.enter="send">
<n8n-input
v-model="form.email"
placeholder="Your email address"
size="medium"
@input="onInputChange"
/>
<div :class="$style.button">
<n8n-button label="Send" float="right" @click="send" :disabled="!isEmailValid" />
</div>
</div>
<div :class="$style.disclaimer">
<n8n-text size="small" color="text-xlight">
David from our product team will get in touch personally
</n8n-text>
</div>
</div>
</section>
</template>
</ModalDrawer>
</template>
<script lang="ts">
import { VALID_EMAIL_REGEX, VALUE_SURVEY_MODAL_KEY } from '@/constants';
import { IN8nPromptResponse } from '@/Interface';
import ModalDrawer from './ModalDrawer.vue';
import mixins from 'vue-typed-mixins';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
const DEFAULT_TITLE = `How likely are you to recommend n8n to a friend or colleague?`;
const GREAT_FEEDBACK_TITLE = `Great to hear! Can we reach out to see how we can make n8n even better for you?`;
const DEFAULT_FEEDBACK_TITLE = `Thanks for your feedback! We'd love to understand how we can improve. Can we reach out?`;
export default mixins(workflowHelpers).extend({
name: 'ValueSurvey',
props: ['isActive'],
components: {
ModalDrawer,
},
watch: {
isActive(isActive) {
if (isActive) {
this.$telemetry.track('User shown value survey', {
instance_id: this.$store.getters.instanceId,
});
}
},
},
computed: {
getTitle(): string {
if (this.form.value !== '') {
if (Number(this.form.value) > 7) {
return GREAT_FEEDBACK_TITLE;
} else {
return DEFAULT_FEEDBACK_TITLE;
}
} else {
return DEFAULT_TITLE;
}
},
isEmailValid(): boolean {
return VALID_EMAIL_REGEX.test(String(this.form.email).toLowerCase());
},
},
data() {
return {
form: {
email: '',
value: '',
},
showButtons: true,
VALUE_SURVEY_MODAL_KEY,
};
},
methods: {
closeDialog(): void {
if (this.form.value === '') {
this.$telemetry.track('User responded value survey score', {
instance_id: this.$store.getters.instanceId,
nps: '',
});
} else {
this.$telemetry.track('User responded value survey email', {
instance_id: this.$store.getters.instanceId,
email: '',
});
}
this.$store.commit('ui/closeTopModal');
},
onInputChange(value: string) {
this.form.email = value;
},
async selectSurveyValue(value: string) {
this.form.value = value;
this.showButtons = false;
const response: IN8nPromptResponse = await this.$store.dispatch(
'settings/submitValueSurvey',
{ value: this.form.value },
);
if (response.updated) {
this.$telemetry.track('User responded value survey score', {
instance_id: this.$store.getters.instanceId,
nps: this.form.value,
});
}
},
async send() {
if (this.isEmailValid) {
const response: IN8nPromptResponse = await this.$store.dispatch(
'settings/submitValueSurvey',
{
email: this.form.email,
value: this.form.value,
},
);
if (response.updated) {
this.$telemetry.track('User responded value survey email', {
instance_id: this.$store.getters.instanceId,
email: this.form.email,
});
this.$showMessage({
title: 'Thanks for your feedback',
message: `If youd like to help even more, answer this <a target="_blank" href="https://n8n-community.typeform.com/quicksurvey#nps=${this.form.value}&instance_id=${this.$store.getters.instanceId}">quick survey.</a>`,
type: 'success',
duration: 15000,
});
}
setTimeout(() => {
this.form.value = '';
this.form.email = '';
this.showButtons = true;
}, 1000);
this.$store.commit('ui/closeTopModal');
}
},
},
});
</script>
<style module lang="scss">
.title {
height: 16px;
text-align: center;
@media (max-width: $--breakpoint-xs) {
margin-top: 10px;
padding: 0 15px;
}
}
.content {
display: flex;
justify-content: center;
@media (max-width: $--breakpoint-xs) {
margin-top: 20px;
}
}
.wrapper {
display: flex;
flex-direction: column;
}
.buttons {
display: flex;
}
.container {
margin: 0 8px;
@media (max-width: $--breakpoint-xs) {
margin: 0 4px;
}
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
}
.text {
margin-top: 8px;
display: flex;
justify-content: space-between;
}
.input {
display: flex;
align-items: center;
}
.button {
margin-left: 10px;
}
.disclaimer {
margin-top: var(--spacing-4xs);
}
</style>
<style lang="scss">
.value-survey {
height: 120px;
top: auto;
@media (max-width: $--breakpoint-xs) {
height: 140px;
}
.el-drawer {
background: var(--color-background-dark);
@media (max-width: $--breakpoint-xs) {
height: 140px !important;
}
&__header {
height: 50px;
margin: 0;
padding: 18px 0 16px;
.el-drawer__close-btn {
top: 12px;
right: 16px;
position: absolute;
@media (max-width: $--breakpoint-xs) {
top: 2px;
right: 2px;
}
}
.el-dialog__close {
font-weight: var(--font-weight-bold);
color: var(--color-text-xlight);
}
}
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div @keydown.stop class="variable-selector-wrapper">
<div class="input-wrapper">
<n8n-input placeholder="Variable filter..." v-model="variableFilter" ref="inputField" size="small" type="text"></n8n-input>
<n8n-input :placeholder="$locale.baseText('variableSelector.variableFilter')" v-model="variableFilter" ref="inputField" size="small" type="text"></n8n-input>
</div>
<div class="result-wrapper">
@@ -525,14 +525,14 @@ export default mixins(
currentNodeData.push(
{
name: 'Parameters',
name: this.$locale.baseText('variableSelector.parameters'),
options: this.sortOptions(this.getNodeParameters(activeNode.name, initialPath, skipParameter, filterText) as IVariableSelectorOption[]),
},
);
returnData.push(
{
name: 'Current Node',
name: this.$locale.baseText('variableSelector.currentNode'),
options: this.sortOptions(currentNodeData),
},
);
@@ -546,7 +546,7 @@ export default mixins(
let nodeOptions: IVariableSelectorOption[];
const upstreamNodes = this.workflow.getParentNodes(activeNode.name, inputName);
for (const nodeName of Object.keys(this.workflow.nodes)) {
for (const [nodeName, node] of Object.entries(this.workflow.nodes)) {
// Add the parameters of all nodes
// TODO: Later have to make sure that no parameters can be referenced which have expression which use input-data (for nodes which are not parent nodes)
@@ -557,7 +557,7 @@ export default mixins(
nodeOptions = [
{
name: 'Parameters',
name: this.$locale.baseText('variableSelector.parameters'),
options: this.sortOptions(this.getNodeParameters(nodeName, `$node["${nodeName}"].parameter`, undefined, filterText)),
} as IVariableSelectorOption,
];
@@ -570,7 +570,7 @@ export default mixins(
if (tempOptions.length) {
nodeOptions = [
{
name: 'Context',
name: this.$locale.baseText('variableSelector.context'),
options: this.sortOptions(tempOptions),
} as IVariableSelectorOption,
];
@@ -583,16 +583,21 @@ export default mixins(
if (tempOutputData) {
nodeOptions.push(
{
name: 'Output Data',
name: this.$locale.baseText('variableSelector.outputData'),
options: this.sortOptions(tempOutputData),
} as IVariableSelectorOption,
);
}
}
const shortNodeType = this.$locale.shortNodeType(node.type);
allNodesData.push(
{
name: nodeName,
name: this.$locale.headerText({
key: `headers.${shortNodeType}.displayName`,
fallback: nodeName,
}),
options: this.sortOptions(nodeOptions),
},
);
@@ -600,7 +605,7 @@ export default mixins(
returnData.push(
{
name: 'Nodes',
name: this.$locale.baseText('variableSelector.nodes'),
options: this.sortOptions(allNodesData),
},
);

View File

@@ -11,7 +11,7 @@
<el-dropdown trigger="click" @click.stop @command="optionSelected($event, item)" v-if="allowParentSelect === true">
<span class="el-dropdown-link clickable" @click.stop>
<font-awesome-icon icon="dot-circle" title="Select Item" />
<font-awesome-icon icon="dot-circle" :title="$locale.baseText('variableSelectorItem.selectItem')" />
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item :command="operation.command" v-for="operation in itemAddOperations" :key="operation.command">{{operation.displayName}}</el-dropdown-item>
@@ -29,7 +29,7 @@
{{item.name}}:
<font-awesome-icon icon="dot-circle" title="Select Item" />
</div>
<div class="item-value">{{ item.value !== undefined?item.value:'--- EMPTY ---' }}</div>
<div class="item-value">{{ item.value !== undefined?item.value: $locale.baseText('variableSelectorItem.empty') }}</div>
</div>
</div>
</template>

View File

@@ -1,29 +1,29 @@
<template functional>
<template>
<!-- eslint-disable-next-line vue/no-mutating-props -->
<a v-if="props.version" :set="version = props.version" :href="version.documentationUrl" target="_blank" :class="$style.card">
<a v-if="version" :set="version = version" :href="version.documentationUrl" target="_blank" :class="$style.card">
<div :class="$style.header">
<div>
<div :class="$style.name">
Version {{version.name}}
{{ `${$locale.baseText('versionCard.version')} ${version.name}` }}
</div>
<WarningTooltip v-if="version.hasSecurityIssue">
<template>
This version has a security issue.<br/>It is listed here for completeness.
{{ $locale.baseText('versionCard.thisVersionHasASecurityIssue') }}
</template>
</WarningTooltip>
<Badge
v-if="version.hasSecurityFix"
text="Security update"
:text="$locale.baseText('versionCard.securityUpdate')"
type="danger"
/>
<Badge
v-if="version.hasBreakingChange"
text="Breaking changes"
:text="$locale.baseText('versionCard.breakingChanges')"
type="warning"
/>
</div>
<div :class="$style['release-date']">
Released&nbsp;<TimeAgo :date="version.createdAt" />
{{ $locale.baseText('versionCard.released') }}&nbsp;<TimeAgo :date="version.createdAt" />
</div>
</div>
<div :class="$style.divider" v-if="version.description || (version.nodes && version.nodes.length)"></div>
@@ -56,11 +56,11 @@ Vue.component('WarningTooltip', WarningTooltip);
export default Vue.extend({
components: { NodeIcon, TimeAgo, Badge, WarningTooltip },
name: 'UpdatesPanel',
name: 'VersionCard',
props: ['version'],
// @ts-ignore
nodeName (node: IVersionNode): string {
return node !== null ? node.displayName : 'unknown';
return node !== null ? node.displayName : this.$locale.baseText('versionCard.unknown');
},
});
</script>

View File

@@ -5,7 +5,7 @@
element-loading-spinner="el-icon-loading"
:value="workflowActive"
@change="activeChanged"
:title="workflowActive?'Deactivate Workflow':'Activate Workflow'"
:title="workflowActive ? $locale.baseText('workflowActivator.deactivateWorkflow') : $locale.baseText('workflowActivator.activateWorkflow')"
:disabled="disabled || loading"
:active-color="getActiveColor"
inactive-color="#8899AA">
@@ -13,7 +13,7 @@
<div class="could-not-be-started" v-if="couldNotBeStarted">
<n8n-tooltip placement="top">
<div @click="displayActivationError" slot="content">The workflow is set to be active but could not be started.<br />Click to display error message.</div>
<div @click="displayActivationError" slot="content">{{ $locale.baseText('workflowActivator.theWorkflowIsSetToBeActiveBut') }}</div>
<font-awesome-icon @click="displayActivationError" icon="exclamation-triangle" />
</n8n-tooltip>
</div>
@@ -79,8 +79,8 @@ export default mixins(
async activeChanged (newActiveState: boolean) {
if (this.workflowId === undefined) {
this.$showMessage({
title: 'Problem activating workflow',
message: 'The workflow did not get saved yet so can not be set active!',
title: this.$locale.baseText('workflowActivator.showMessage.activeChangedWorkflowIdUndefined.title'),
message: this.$locale.baseText('workflowActivator.showMessage.activeChangedWorkflowIdUndefined.message'),
type: 'error',
});
return;
@@ -88,8 +88,8 @@ export default mixins(
if (this.nodesIssuesExist === true) {
this.$showMessage({
title: 'Problem activating workflow',
message: 'It is only possible to activate a workflow when all issues on all nodes got resolved!',
title: this.$locale.baseText('workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.title'),
message: this.$locale.baseText('workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.message'),
type: 'error',
});
return;
@@ -105,7 +105,13 @@ export default mixins(
// for people because it would activate a different version of the workflow
// than the one they can currently see.
if (this.dirtyState) {
const importConfirm = await this.confirmMessage(`When you activate the workflow all currently unsaved changes of the workflow will be saved.`, 'Activate and save?', 'warning', 'Yes, activate and save!');
const importConfirm = await this.confirmMessage(
this.$locale.baseText('workflowActivator.confirmMessage.message'),
this.$locale.baseText('workflowActivator.confirmMessage.headline'),
'warning',
this.$locale.baseText('workflowActivator.confirmMessage.confirmButtonText'),
this.$locale.baseText('workflowActivator.confirmMessage.cancelButtonText'),
);
if (importConfirm === false) {
return;
}
@@ -123,7 +129,14 @@ export default mixins(
await this.restApi().updateWorkflow(this.workflowId, data);
} catch (error) {
const newStateName = newActiveState === true ? 'activated' : 'deactivated';
this.$showError(error, 'Problem', `There was a problem and the workflow could not be ${newStateName}:`);
this.$showError(
error,
this.$locale.baseText('workflowActivator.showError.title'),
this.$locale.baseText(
'workflowActivator.showError.message',
{ interpolate: { newStateName } },
) + ':',
);
this.loading = false;
return;
}
@@ -148,6 +161,7 @@ export default mixins(
this.$emit('workflowActiveChanged', { id: this.workflowId, active: newActiveState });
this.loading = false;
this.$store.dispatch('settings/fetchPromptsData');
},
async displayActivationError () {
let errorMessage: string;
@@ -155,16 +169,19 @@ export default mixins(
const errorData = await this.restApi().getActivationError(this.workflowId);
if (errorData === undefined) {
errorMessage = 'Sorry there was a problem. No error got found to display.';
errorMessage = this.$locale.baseText('workflowActivator.showMessage.displayActivationError.message.errorDataUndefined');
} else {
errorMessage = `The following error occurred on workflow activation:<br /><i>${errorData.error.message}</i>`;
errorMessage = this.$locale.baseText(
'workflowActivator.showMessage.displayActivationError.message.errorDataNotUndefined',
{ interpolate: { message: errorData.error.message } },
);
}
} catch (error) {
errorMessage = 'Sorry there was a problem requesting the error';
errorMessage = this.$locale.baseText('workflowActivator.showMessage.displayActivationError.message.catchBlock');
}
this.$showMessage({
title: 'Problem activating workflow',
title: this.$locale.baseText('workflowActivator.showMessage.displayActivationError.title'),
message: errorMessage,
type: 'warning',
duration: 0,

View File

@@ -8,11 +8,11 @@
<template v-slot:header>
<div class="workflows-header">
<n8n-heading tag="h1" size="xlarge" class="title">
Open Workflow
{{ $locale.baseText('workflowOpen.openWorkflow') }}
</n8n-heading>
<div class="tags-filter">
<TagsDropdown
placeholder="Filter by tags..."
:placeholder="$locale.baseText('workflowOpen.openWorkflow')"
:currentTagIds="filterTagIds"
:createEnabled="false"
@update="updateTagsFilter"
@@ -21,7 +21,7 @@
/>
</div>
<div class="search-filter">
<n8n-input placeholder="Search workflows..." ref="inputFieldFilter" v-model="filterText">
<n8n-input :placeholder="$locale.baseText('workflowOpen.searchWorkflows')" ref="inputFieldFilter" v-model="filterText">
<font-awesome-icon slot="prefix" icon="search"></font-awesome-icon>
</n8n-input>
</div>
@@ -30,7 +30,7 @@
<template v-slot:content>
<el-table class="search-table" :data="filteredWorkflows" stripe @cell-click="openWorkflow" :default-sort = "{prop: 'updatedAt', order: 'descending'}" v-loading="isDataLoading">
<el-table-column property="name" label="Name" class-name="clickable" sortable>
<el-table-column property="name" :label="$locale.baseText('workflowOpen.name')" class-name="clickable" sortable>
<template slot-scope="scope">
<div :key="scope.row.id">
<span class="name">{{scope.row.name}}</span>
@@ -38,9 +38,9 @@
</div>
</template>
</el-table-column>
<el-table-column property="createdAt" label="Created" class-name="clickable" width="155" sortable></el-table-column>
<el-table-column property="updatedAt" label="Updated" class-name="clickable" width="155" sortable></el-table-column>
<el-table-column label="Active" width="75">
<el-table-column property="createdAt" :label="$locale.baseText('workflowOpen.created')" class-name="clickable" width="155" sortable></el-table-column>
<el-table-column property="updatedAt" :label="$locale.baseText('workflowOpen.updated')" class-name="clickable" width="155" sortable></el-table-column>
<el-table-column :label="$locale.baseText('workflowOpen.active')" width="75">
<template slot-scope="scope">
<workflow-activator :workflow-active="scope.row.active" :workflow-id="scope.row.id" @workflowActiveChanged="workflowActiveChanged" />
</template>
@@ -134,15 +134,22 @@ export default mixins(
this.filterTagIds.push(tagId);
}
},
async openWorkflow (data: IWorkflowShortResponse, column: any) { // tslint:disable-line:no-any
async openWorkflow (data: IWorkflowShortResponse, column: any, cell: any, e: PointerEvent) { // tslint:disable-line:no-any
if (column.label !== 'Active') {
const currentWorkflowId = this.$store.getters.workflowId;
if (e.metaKey || e.ctrlKey) {
const route = this.$router.resolve({name: 'NodeViewExisting', params: {name: data.id}});
window.open(route.href, '_blank');
return;
}
if (data.id === currentWorkflowId) {
this.$showMessage({
title: 'Already open',
message: 'This is the current workflow',
title: this.$locale.baseText('workflowOpen.showMessage.title'),
message: this.$locale.baseText('workflowOpen.showMessage.message'),
type: 'error',
duration: 1500,
});
@@ -152,7 +159,13 @@ export default mixins(
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('workflowOpen.confirmMessage.message'),
this.$locale.baseText('workflowOpen.confirmMessage.headline'),
'warning',
this.$locale.baseText('workflowOpen.confirmMessage.confirmButtonText'),
this.$locale.baseText('workflowOpen.confirmMessage.cancelButtonText'),
);
if (importConfirm === false) {
return;
} else {
@@ -189,7 +202,11 @@ export default mixins(
)
.catch(
(error: Error) => {
this.$showError(error, 'Problem loading workflows', 'There was a problem loading the workflows:');
this.$showError(
error,
this.$locale.baseText('workflowOpen.showError.title'),
this.$locale.baseText('workflowOpen.showError.message') + ':',
);
this.isDataLoading = false;
},
);

View File

@@ -3,7 +3,7 @@
:name="WORKFLOW_SETTINGS_MODAL_KEY"
width="65%"
maxHeight="80%"
title="Workflow Settings"
:title="$locale.baseText('workflowSettings.settingsFor', { interpolate: { workflowName, workflowId } })"
:eventBus="modalBus"
:scrollable="true"
>
@@ -11,7 +11,7 @@
<div v-loading="isLoading" class="workflow-settings">
<el-row>
<el-col :span="10" class="setting-name">
Error Workflow:
{{ $locale.baseText('workflowSettings.errorWorkflow') + ":" }}
<n8n-tooltip class="setting-info" placement="top" >
<div slot="content" v-html="helpTexts.errorWorkflow"></div>
<font-awesome-icon icon="question-circle" />
@@ -30,7 +30,7 @@
</el-row>
<el-row>
<el-col :span="10" class="setting-name">
Timezone:
{{ $locale.baseText('workflowSettings.timezone') + ":" }}
<n8n-tooltip class="setting-info" placement="top" >
<div slot="content" v-html="helpTexts.timezone"></div>
<font-awesome-icon icon="question-circle" />
@@ -49,14 +49,14 @@
</el-row>
<el-row>
<el-col :span="10" class="setting-name">
Save Data Error Execution:
{{ $locale.baseText('workflowSettings.saveDataErrorExecution') + ":" }}
<n8n-tooltip class="setting-info" placement="top" >
<div slot="content" v-html="helpTexts.saveDataErrorExecution"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</el-col>
<el-col :span="14" class="ignore-key-press">
<n8n-select v-model="workflowSettings.saveDataErrorExecution" placeholder="Select Option" size="medium" filterable :limit-popper-width="true">
<n8n-select v-model="workflowSettings.saveDataErrorExecution" :placeholder="$locale.baseText('workflowSettings.selectOption')" size="medium" filterable :limit-popper-width="true">
<n8n-option
v-for="option of saveDataErrorExecutionOptions"
:key="option.key"
@@ -68,14 +68,14 @@
</el-row>
<el-row>
<el-col :span="10" class="setting-name">
Save Data Success Execution:
{{ $locale.baseText('workflowSettings.saveDataSuccessExecution') + ":" }}
<n8n-tooltip class="setting-info" placement="top" >
<div slot="content" v-html="helpTexts.saveDataSuccessExecution"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</el-col>
<el-col :span="14" class="ignore-key-press">
<n8n-select v-model="workflowSettings.saveDataSuccessExecution" placeholder="Select Option" size="medium" filterable :limit-popper-width="true">
<n8n-select v-model="workflowSettings.saveDataSuccessExecution" :placeholder="$locale.baseText('workflowSettings.selectOption')" size="medium" filterable :limit-popper-width="true">
<n8n-option
v-for="option of saveDataSuccessExecutionOptions"
:key="option.key"
@@ -87,14 +87,14 @@
</el-row>
<el-row>
<el-col :span="10" class="setting-name">
Save Manual Executions:
{{ $locale.baseText('workflowSettings.saveManualExecutions') + ":" }}
<n8n-tooltip class="setting-info" placement="top" >
<div slot="content" v-html="helpTexts.saveManualExecutions"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</el-col>
<el-col :span="14" class="ignore-key-press">
<n8n-select v-model="workflowSettings.saveManualExecutions" placeholder="Select Option" size="medium" filterable :limit-popper-width="true">
<n8n-select v-model="workflowSettings.saveManualExecutions" :placeholder="$locale.baseText('workflowSettings.selectOption')" size="medium" filterable :limit-popper-width="true">
<n8n-option
v-for="option of saveManualOptions"
:key="option.key"
@@ -106,14 +106,14 @@
</el-row>
<el-row>
<el-col :span="10" class="setting-name">
Save Execution Progress:
{{ $locale.baseText('workflowSettings.saveExecutionProgress') + ":" }}
<n8n-tooltip class="setting-info" placement="top" >
<div slot="content" v-html="helpTexts.saveExecutionProgress"></div>
<font-awesome-icon icon="question-circle" />
</n8n-tooltip>
</el-col>
<el-col :span="14" class="ignore-key-press">
<n8n-select v-model="workflowSettings.saveExecutionProgress" placeholder="Select Option" size="medium" filterable :limit-popper-width="true">
<n8n-select v-model="workflowSettings.saveExecutionProgress" :placeholder="$locale.baseText('workflowSettings.selectOption')" size="medium" filterable :limit-popper-width="true">
<n8n-option
v-for="option of saveExecutionProgressOptions"
:key="option.key"
@@ -125,7 +125,7 @@
</el-row>
<el-row>
<el-col :span="10" class="setting-name">
Timeout Workflow:
{{ $locale.baseText('workflowSettings.timeoutWorkflow') + ":" }}
<n8n-tooltip class="setting-info" placement="top" >
<div slot="content" v-html="helpTexts.executionTimeoutToggle"></div>
<font-awesome-icon icon="question-circle" />
@@ -140,7 +140,7 @@
<div v-if="workflowSettings.executionTimeout > -1">
<el-row>
<el-col :span="10" class="setting-name">
Timeout After:
{{ $locale.baseText('workflowSettings.timeoutAfter') + ":" }}
<n8n-tooltip class="setting-info" placement="top" >
<div slot="content" v-html="helpTexts.executionTimeout"></div>
<font-awesome-icon icon="question-circle" />
@@ -148,17 +148,17 @@
</el-col>
<el-col :span="4">
<n8n-input size="medium" :value="timeoutHMS.hours" @input="(value) => setTimeout('hours', value)" :min="0">
<template slot="append">hours</template>
<template slot="append">{{ $locale.baseText('workflowSettings.hours') }}</template>
</n8n-input>
</el-col>
<el-col :span="4" class="timeout-input">
<n8n-input size="medium" :value="timeoutHMS.minutes" @input="(value) => setTimeout('minutes', value)" :min="0" :max="60">
<template slot="append">minutes</template>
<template slot="append">{{ $locale.baseText('workflowSettings.minutes') }}</template>
</n8n-input>
</el-col>
<el-col :span="4" class="timeout-input">
<n8n-input size="medium" :value="timeoutHMS.seconds" @input="(value) => setTimeout('seconds', value)" :min="0" :max="60">
<template slot="append">seconds</template>
<template slot="append">{{ $locale.baseText('workflowSettings.seconds') }}</template>
</n8n-input>
</el-col>
</el-row>
@@ -167,7 +167,7 @@
</template>
<template v-slot:footer>
<div class="action-buttons">
<n8n-button label="Save" size="large" float="right" @click="saveSettings" />
<n8n-button :label="$locale.baseText('workflowSettings.save')" size="large" float="right" @click="saveSettings" />
</div>
</template>
</Modal>
@@ -191,6 +191,8 @@ import { WORKFLOW_SETTINGS_MODAL_KEY } from '../constants';
import mixins from 'vue-typed-mixins';
import { mapGetters } from "vuex";
export default mixins(
externalHooks,
genericHelpers,
@@ -205,14 +207,14 @@ export default mixins(
return {
isLoading: true,
helpTexts: {
errorWorkflow: 'The workflow to run in case the current one fails.<br />To function correctly that workflow has to contain an "Error Trigger" node!',
timezone: 'The timezone in which the workflow should run. Gets for example used by "Cron" node.',
saveDataErrorExecution: 'If data data of executions should be saved in case they failed.',
saveDataSuccessExecution: 'If data data of executions should be saved in case they succeed.',
saveExecutionProgress: 'If data should be saved after each node, allowing you to resume in case of errors from where it stopped. May increase latency.',
saveManualExecutions: 'If data data of executions should be saved when started manually from the editor.',
executionTimeoutToggle: 'Cancel workflow execution after defined time',
executionTimeout: 'After what time the workflow should timeout.',
errorWorkflow: this.$locale.baseText('workflowSettings.helpTexts.errorWorkflow'),
timezone: this.$locale.baseText('workflowSettings.helpTexts.timezone'),
saveDataErrorExecution: this.$locale.baseText('workflowSettings.helpTexts.saveDataErrorExecution'),
saveDataSuccessExecution: this.$locale.baseText('workflowSettings.helpTexts.saveDataSuccessExecution'),
saveExecutionProgress: this.$locale.baseText('workflowSettings.helpTexts.saveExecutionProgress'),
saveManualExecutions: this.$locale.baseText('workflowSettings.helpTexts.saveManualExecutions'),
executionTimeoutToggle: this.$locale.baseText('workflowSettings.helpTexts.executionTimeoutToggle'),
executionTimeout: this.$locale.baseText('workflowSettings.helpTexts.executionTimeout'),
},
defaultValues: {
timezone: 'America/New_York',
@@ -235,6 +237,11 @@ export default mixins(
WORKFLOW_SETTINGS_MODAL_KEY,
};
},
computed: {
...mapGetters(['workflowName', 'workflowId']),
},
async mounted () {
if (this.$route.params.name === undefined) {
this.$showMessage({
@@ -317,15 +324,24 @@ export default mixins(
this.saveDataErrorExecutionOptions, [
{
key: 'DEFAULT',
value: 'Default - ' + (this.defaultValues.saveDataErrorExecution === 'all' ? 'Save' : 'Do not save'),
value: this.$locale.baseText(
'workflowSettings.saveDataErrorExecutionOptions.defaultSave',
{
interpolate: {
defaultValue: this.defaultValues.saveDataErrorExecution === 'all'
? this.$locale.baseText('workflowSettings.saveDataErrorExecutionOptions.save')
: this.$locale.baseText('workflowSettings.saveDataErrorExecutionOptions.doNotsave'),
},
},
),
},
{
key: 'all',
value: 'Save',
value: this.$locale.baseText('workflowSettings.saveDataErrorExecutionOptions.save'),
},
{
key: 'none',
value: 'Do not save',
value: this.$locale.baseText('workflowSettings.saveDataErrorExecutionOptions.doNotSave'),
},
],
);
@@ -336,15 +352,24 @@ export default mixins(
this.saveDataSuccessExecutionOptions, [
{
key: 'DEFAULT',
value: 'Default - ' + (this.defaultValues.saveDataSuccessExecution === 'all' ? 'Save' : 'Do not save'),
value: this.$locale.baseText(
'workflowSettings.saveDataSuccessExecutionOptions.defaultSave',
{
interpolate: {
defaultValue: this.defaultValues.saveDataSuccessExecution === 'all'
? this.$locale.baseText('workflowSettings.saveDataSuccessExecutionOptions.save')
: this.$locale.baseText('workflowSettings.saveDataSuccessExecutionOptions.doNotSave'),
},
},
),
},
{
key: 'all',
value: 'Save',
value: this.$locale.baseText('workflowSettings.saveDataSuccessExecutionOptions.save'),
},
{
key: 'none',
value: 'Do not save',
value: this.$locale.baseText('workflowSettings.saveDataSuccessExecutionOptions.doNotSave'),
},
],
);
@@ -355,15 +380,22 @@ export default mixins(
this.saveExecutionProgressOptions, [
{
key: 'DEFAULT',
value: 'Default - ' + (this.defaultValues.saveExecutionProgress === true ? 'Yes' : 'No'),
value: this.$locale.baseText(
'workflowSettings.saveExecutionProgressOptions.defaultSave',
{
interpolate: {
defaultValue: this.defaultValues.saveExecutionProgress ? this.$locale.baseText('workflowSettings.saveExecutionProgressOptions.yes') : this.$locale.baseText('workflowSettings.saveExecutionProgressOptions.no'),
},
},
),
},
{
key: true,
value: 'Yes',
value: this.$locale.baseText('workflowSettings.saveExecutionProgressOptions.yes'),
},
{
key: false,
value: 'No',
value: this.$locale.baseText('workflowSettings.saveExecutionProgressOptions.no'),
},
],
);
@@ -372,15 +404,22 @@ export default mixins(
this.saveManualOptions.length = 0;
this.saveManualOptions.push({
key: 'DEFAULT',
value: 'Default - ' + (this.defaultValues.saveManualExecutions === true ? 'Yes' : 'No'),
value: this.$locale.baseText(
'workflowSettings.saveManualOptions.defaultSave',
{
interpolate: {
defaultValue: this.defaultValues.saveManualExecutions ? this.$locale.baseText('workflowSettings.saveManualOptions.yes') : this.$locale.baseText('workflowSettings.saveManualOptions.no'),
},
},
),
});
this.saveManualOptions.push({
key: true,
value: 'Yes',
value: this.$locale.baseText('workflowSettings.saveManualOptions.yes'),
});
this.saveManualOptions.push({
key: false,
value: 'No',
value: this.$locale.baseText('workflowSettings.saveManualOptions.no'),
});
},
@@ -394,12 +433,15 @@ export default mixins(
let defaultTimezoneValue = timezones[this.defaultValues.timezone] as string | undefined;
if (defaultTimezoneValue === undefined) {
defaultTimezoneValue = 'Default Timezone not valid!';
defaultTimezoneValue = this.$locale.baseText('workflowSettings.defaultTimezoneNotValid');
}
this.timezones.push({
key: 'DEFAULT',
value: `Default - ${defaultTimezoneValue}`,
value: this.$locale.baseText(
'workflowSettings.defaultTimezone',
{ interpolate: { defaultTimezoneValue } },
),
});
for (const timezone of Object.keys(timezones)) {
this.timezones.push({
@@ -423,7 +465,7 @@ export default mixins(
// @ts-ignore
workflows.unshift({
id: undefined as unknown as string,
name: '- No Workflow -',
name: this.$locale.baseText('workflowSettings.noWorkflow'),
});
Vue.set(this, 'workflows', workflows);
@@ -442,14 +484,33 @@ export default mixins(
: -1;
if (data.settings!.executionTimeout === 0) {
this.$showError(new Error('timeout is activated but set to 0'), 'Problem saving settings', 'There was a problem saving the settings:');
this.$showError(
new Error(this.$locale.baseText('workflowSettings.showError.saveSettings1.errorMessage')),
this.$locale.baseText('workflowSettings.showError.saveSettings1.title'),
this.$locale.baseText('workflowSettings.showError.saveSettings1.message') + ':',
);
return;
}
// @ts-ignore
if (data.settings!.executionTimeout > this.workflowSettings.maxExecutionTimeout) {
const { hours, minutes, seconds } = this.convertToHMS(this.workflowSettings.maxExecutionTimeout as number);
this.$showError(new Error(`Maximum Timeout is: ${hours} hours, ${minutes} minutes, ${seconds} seconds`), 'Problem saving settings', 'Set timeout is exceeding the maximum timeout!');
this.$showError(
new Error(
this.$locale.baseText(
'workflowSettings.showError.saveSettings2.errorMessage',
{
interpolate: {
hours: hours.toString(),
minutes: minutes.toString(),
seconds: seconds.toString(),
},
},
),
),
this.$locale.baseText('workflowSettings.showError.saveSettings2.title'),
this.$locale.baseText('workflowSettings.showError.saveSettings2.message') + ':',
);
return;
}
delete data.settings!.maxExecutionTimeout;
@@ -459,7 +520,11 @@ export default mixins(
try {
await this.restApi().updateWorkflow(this.$route.params.name, data);
} catch (error) {
this.$showError(error, 'Problem saving settings', 'There was a problem saving the settings:');
this.$showError(
error,
this.$locale.baseText('workflowSettings.showError.saveSettings3.title'),
this.$locale.baseText('workflowSettings.showError.saveSettings3.message') + ':',
);
this.isLoading = false;
return;
}
@@ -479,8 +544,8 @@ export default mixins(
this.isLoading = false;
this.$showMessage({
title: 'Settings saved',
message: 'The workflow settings got saved!',
title: this.$locale.baseText('workflowSettings.showMessage.saveSettings.title'),
message: this.$locale.baseText('workflowSettings.showMessage.saveSettings.message'),
type: 'success',
});
@@ -509,6 +574,7 @@ export default mixins(
<style scoped lang="scss">
.workflow-settings {
font-size: var(--font-size-s);
.el-row {
padding: 0.25em 0;
}

View File

@@ -2,12 +2,6 @@ import dateformat from 'dateformat';
const KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
export function addTargetBlank(html: string) {
return html.includes('href=')
? html.replace(/href=/g, 'target="_blank" href=')
: html;
}
export function convertToDisplayDate (epochTime: number) {
return dateformat(epochTime, 'yyyy-mm-dd HH:MM:ss');
}
@@ -19,3 +13,8 @@ export function convertToHumanReadableDate (epochTime: number) {
export function getAppNameFromCredType(name: string) {
return name.split(' ').filter((word) => !KEYWORDS_TO_FILTER.includes(word)).join(' ');
}
export function getStyleTokenValue(name: string): string {
const style = getComputedStyle(document.body);
return style.getPropertyValue(name);
}

View File

@@ -22,23 +22,24 @@ export const genericHelpers = mixins(showMessage).extend({
displayTimer (msPassed: number, showMs = false): string {
if (msPassed < 60000) {
if (showMs === false) {
return `${Math.floor(msPassed / 1000)} sec.`;
return `${Math.floor(msPassed / 1000)} ${this.$locale.baseText('genericHelpers.sec')}`;
}
return `${msPassed / 1000} sec.`;
return `${msPassed / 1000} ${this.$locale.baseText('genericHelpers.sec')}`;
}
const secondsPassed = Math.floor(msPassed / 1000);
const minutesPassed = Math.floor(secondsPassed / 60);
const secondsLeft = (secondsPassed - (minutesPassed * 60)).toString().padStart(2, '0');
return `${minutesPassed}:${secondsLeft} min.`;
return `${minutesPassed}:${secondsLeft} ${this.$locale.baseText('genericHelpers.min')}`;
},
editAllowedCheck (): boolean {
if (this.isReadOnly) {
this.$showMessage({
title: 'Workflow can not be changed!',
message: `The workflow can not be edited as a past execution gets displayed. To make changed either open the original workflow of which the execution gets displayed or save it under a new name first.`,
// title: 'Workflow can not be changed!',
title: this.$locale.baseText('genericHelpers.showMessage.title'),
message: this.$locale.baseText('genericHelpers.showMessage.message'),
type: 'error',
duration: 0,
});
@@ -57,7 +58,7 @@ export const genericHelpers = mixins(showMessage).extend({
this.loadingService = this.$loading(
{
lock: true,
text: text || 'Loading',
text: text || this.$locale.baseText('genericHelpers.loading'),
spinner: 'el-icon-loading',
background: 'rgba(255, 255, 255, 0.8)',
},

View File

@@ -1,9 +1,10 @@
import { INodeUi } from '@/Interface';
import { INodeUi, XYPosition } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
import { nodeIndex } from '@/components/mixins/nodeIndex';
import { getMousePosition, getRelativePosition } from '@/views/canvasHelpers';
export const mouseSelect = mixins(
deviceSupportHelpers,
@@ -42,23 +43,14 @@ export const mouseSelect = mixins(
}
return e.ctrlKey;
},
/**
* Gets mouse position within the node view. Both node view offset and scale (zoom) are considered when
* calculating position.
*
* @param event - mouse event within node view
*/
getMousePositionWithinNodeView (event: MouseEvent) {
getMousePositionWithinNodeView (event: MouseEvent | TouchEvent): XYPosition {
const [x, y] = getMousePosition(event);
// @ts-ignore
const nodeViewScale = this.nodeViewScale;
const offsetPosition = this.$store.getters.getNodeViewOffsetPosition;
return {
x: (event.pageX - offsetPosition[0]) / nodeViewScale,
y: (event.pageY - offsetPosition[1]) / nodeViewScale,
};
return getRelativePosition(x, y, this.nodeViewScale, this.$store.getters.getNodeViewOffsetPosition);
},
showSelectBox (event: MouseEvent) {
this.selectBox = Object.assign(this.selectBox, this.getMousePositionWithinNodeView(event));
const [x, y] = this.getMousePositionWithinNodeView(event);
this.selectBox = Object.assign(this.selectBox, {x, y});
// @ts-ignore
this.selectBox.style.left = this.selectBox.x + 'px';
@@ -90,7 +82,7 @@ export const mouseSelect = mixins(
this.selectActive = false;
},
getSelectionBox (event: MouseEvent) {
const {x, y} = this.getMousePositionWithinNodeView(event);
const [x, y] = this.getMousePositionWithinNodeView(event);
return {
// @ts-ignore
x: Math.min(x, this.selectBox.x),
@@ -162,6 +154,10 @@ export const mouseSelect = mixins(
this.nodeSelected(node);
});
if (selectedNodes.length === 1) {
this.$store.commit('setLastSelectedNode', selectedNodes[0].name);
}
this.hideSelectBox();
},
mouseMoveSelect (e: MouseEvent) {
@@ -195,6 +191,10 @@ export const mouseSelect = mixins(
this.$store.commit('setLastSelectedNode', null);
this.$store.commit('setLastSelectedNodeOutputIndex', null);
this.$store.commit('setActiveNode', null);
// @ts-ignore
this.lastSelectedConnection = null;
// @ts-ignore
this.newNodeInsertPosition = null;
},
},
});

View File

@@ -3,6 +3,7 @@ import mixins from 'vue-typed-mixins';
import normalizeWheel from 'normalize-wheel';
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
import { nodeIndex } from '@/components/mixins/nodeIndex';
import { getMousePosition } from '@/views/canvasHelpers';
export const moveNodeWorkflow = mixins(
deviceSupportHelpers,
@@ -15,29 +16,18 @@ export const moveNodeWorkflow = mixins(
},
methods: {
getMousePosition(e: MouseEvent | TouchEvent) {
// @ts-ignore
const x = e.pageX !== undefined ? e.pageX : (e.touches && e.touches[0] && e.touches[0].pageX ? e.touches[0].pageX : 0);
// @ts-ignore
const y = e.pageY !== undefined ? e.pageY : (e.touches && e.touches[0] && e.touches[0].pageY ? e.touches[0].pageY : 0);
return {
x,
y,
};
},
moveWorkflow (e: MouseEvent) {
const offsetPosition = this.$store.getters.getNodeViewOffsetPosition;
const position = this.getMousePosition(e);
const [x, y] = getMousePosition(e);
const nodeViewOffsetPositionX = offsetPosition[0] + (position.x - this.moveLastPosition[0]);
const nodeViewOffsetPositionY = offsetPosition[1] + (position.y - this.moveLastPosition[1]);
const nodeViewOffsetPositionX = offsetPosition[0] + (x - this.moveLastPosition[0]);
const nodeViewOffsetPositionY = offsetPosition[1] + (y - this.moveLastPosition[1]);
this.$store.commit('setNodeViewOffsetPosition', {newOffset: [nodeViewOffsetPositionX, nodeViewOffsetPositionY]});
// Update the last position
this.moveLastPosition[0] = position.x;
this.moveLastPosition[1] = position.y;
this.moveLastPosition[0] = x;
this.moveLastPosition[1] = y;
},
mouseDownMoveWorkflow (e: MouseEvent) {
if (this.isCtrlKeyPressed(e) === false) {
@@ -53,10 +43,10 @@ export const moveNodeWorkflow = mixins(
this.$store.commit('setNodeViewMoveInProgress', true);
const position = this.getMousePosition(e);
const [x, y] = getMousePosition(e);
this.moveLastPosition[0] = position.x;
this.moveLastPosition[1] = position.y;
this.moveLastPosition[0] = x;
this.moveLastPosition[1] = y;
// @ts-ignore
this.$el.addEventListener('mousemove', this.mouseMoveNodeWorkflow);

View File

@@ -1,10 +1,17 @@
import { IConnectionsUi, IEndpointOptions, INodeUi, XYPositon } from '@/Interface';
import { IEndpointOptions, INodeUi, XYPosition } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
import { nodeIndex } from '@/components/mixins/nodeIndex';
import { NODE_NAME_PREFIX, NO_OP_NODE_TYPE } from '@/constants';
import * as CanvasHelpers from '@/views/canvasHelpers';
import { Endpoint } from 'jsplumb';
import {
INodeTypeDescription,
} from 'n8n-workflow';
import { getStyleTokenValue } from '../helpers';
export const nodeBase = mixins(
deviceSupportHelpers,
@@ -18,145 +25,31 @@ export const nodeBase = mixins(
},
computed: {
data (): INodeUi {
return this.$store.getters.nodeByName(this.name);
return this.$store.getters.getNodeByName(this.name);
},
hasIssues (): boolean {
if (this.data.issues !== undefined && Object.keys(this.data.issues).length) {
return true;
}
return false;
},
nodeName (): string {
nodeId (): string {
return NODE_NAME_PREFIX + this.nodeIndex;
},
nodeIndex (): string {
return this.$store.getters.getNodeIndex(this.data.name).toString();
},
nodePosition (): object {
const returnStyles: {
[key: string]: string;
} = {
left: this.data.position[0] + 'px',
top: this.data.position[1] + 'px',
};
return returnStyles;
},
nodeStyle (): object {
const returnStyles: {
[key: string]: string;
} = {
'border-color': this.data.color as string,
};
return returnStyles;
},
},
props: [
'name',
'nodeId',
'instance',
'isReadOnly',
'isActive',
'hideActions',
],
methods: {
__addNode (node: INodeUi) {
// TODO: Later move the node-connection definitions to a special file
let nodeTypeData = this.$store.getters.nodeType(node.type);
const nodeConnectors: IConnectionsUi = {
main: {
input: {
uuid: '-input',
maxConnections: -1,
endpoint: 'Rectangle',
endpointStyle: {
width: nodeTypeData && nodeTypeData.outputs.length > 2 ? 9 : 10,
height: nodeTypeData && nodeTypeData.outputs.length > 2 ? 18 : 24,
fill: '#777',
stroke: '#777',
lineWidth: 0,
},
dragAllowedWhenFull: true,
},
output: {
uuid: '-output',
maxConnections: -1,
endpoint: 'Dot',
endpointStyle: {
radius: nodeTypeData && nodeTypeData.outputs.length > 2 ? 7 : 11,
fill: '#555',
outlineStroke: 'none',
},
dragAllowedWhenFull: true,
},
},
};
if (!nodeTypeData) {
// If node type is not know use by default the base.noOp data to display it
nodeTypeData = this.$store.getters.nodeType(NO_OP_NODE_TYPE);
}
const anchorPositions: {
[key: string]: {
[key: number]: string[] | number[][];
}
} = {
input: {
1: [
'Left',
],
2: [
[0, 0.3, -1, 0],
[0, 0.7, -1, 0],
],
3: [
[0, 0.25, -1, 0],
[0, 0.5, -1, 0],
[0, 0.75, -1, 0],
],
4: [
[0, 0.2, -1, 0],
[0, 0.4, -1, 0],
[0, 0.6, -1, 0],
[0, 0.8, -1, 0],
],
},
output: {
1: [
'Right',
],
2: [
[1, 0.3, 1, 0],
[1, 0.7, 1, 0],
],
3: [
[1, 0.25, 1, 0],
[1, 0.5, 1, 0],
[1, 0.75, 1, 0],
],
4: [
[1, 0.2, 1, 0],
[1, 0.4, 1, 0],
[1, 0.6, 1, 0],
[1, 0.8, 1, 0],
],
},
};
__addInputEndpoints (node: INodeUi, nodeTypeData: INodeTypeDescription) {
// Add Inputs
let index, inputData, anchorPosition;
let newEndpointData: IEndpointOptions;
let indexData: {
let index;
const indexData: {
[key: string]: number;
} = {};
nodeTypeData.inputs.forEach((inputName: string) => {
// @ts-ignore
inputData = nodeConnectors[inputName].input;
nodeTypeData.inputs.forEach((inputName: string, i: number) => {
// Increment the index for inputs with current name
if (indexData.hasOwnProperty(inputName)) {
indexData[inputName]++;
@@ -166,22 +59,25 @@ export const nodeBase = mixins(
index = indexData[inputName];
// Get the position of the anchor depending on how many it has
anchorPosition = anchorPositions.input[nodeTypeData.inputs.length][index];
const anchorPosition = CanvasHelpers.ANCHOR_POSITIONS.input[nodeTypeData.inputs.length][index];
newEndpointData = {
uuid: `${this.nodeIndex}` + inputData.uuid + index,
const newEndpointData: IEndpointOptions = {
uuid: CanvasHelpers.getInputEndpointUUID(this.nodeIndex, index),
anchor: anchorPosition,
maxConnections: inputData.maxConnections,
endpoint: inputData.endpoint,
endpointStyle: inputData.endpointStyle,
maxConnections: -1,
endpoint: 'Rectangle',
endpointStyle: CanvasHelpers.getInputEndpointStyle(nodeTypeData, '--color-foreground-xdark'),
endpointHoverStyle: CanvasHelpers.getInputEndpointStyle(nodeTypeData, '--color-primary'),
isSource: false,
isTarget: !this.isReadOnly,
isTarget: !this.isReadOnly && nodeTypeData.inputs.length > 1, // only enabled for nodes with multiple inputs.. otherwise attachment handled by connectionDrag event in NodeView,
parameters: {
nodeIndex: this.nodeIndex,
type: inputName,
index,
},
dragAllowedWhenFull: inputData.dragAllowedWhenFull,
enabled: !this.isReadOnly, // enabled in default case to allow dragging
cssClass: 'rect-input-endpoint',
dragAllowedWhenFull: true,
dropOptions: {
tolerance: 'touch',
hoverClass: 'dropHover',
@@ -191,19 +87,17 @@ export const nodeBase = mixins(
if (nodeTypeData.inputNames) {
// Apply input names if they got set
newEndpointData.overlays = [
['Label',
{
id: 'input-name-label',
location: [-2, 0.5],
label: nodeTypeData.inputNames[index],
cssClass: 'node-input-endpoint-label',
visible: true,
},
],
CanvasHelpers.getInputNameOverlay(nodeTypeData.inputNames[index]),
];
}
this.instance.addEndpoint(this.nodeName, newEndpointData);
const endpoint: Endpoint = this.instance.addEndpoint(this.nodeId, newEndpointData);
endpoint.__meta = {
nodeName: node.name,
nodeId: this.nodeId,
index: i,
totalEndpoints: nodeTypeData.inputs.length,
};
// TODO: Activate again if it makes sense. Currently makes problems when removing
// connection on which the input has a name. It does not get hidden because
@@ -213,15 +107,17 @@ export const nodeBase = mixins(
// if (index === 0 && inputName === 'main') {
// // Make the first main-input the default one to connect to when connection gets dropped on node
// this.instance.makeTarget(this.nodeName, newEndpointData);
// this.instance.makeTarget(this.nodeId, newEndpointData);
// }
});
},
__addOutputEndpoints(node: INodeUi, nodeTypeData: INodeTypeDescription) {
let index;
const indexData: {
[key: string]: number;
} = {};
// Add Outputs
indexData = {};
nodeTypeData.outputs.forEach((inputName: string) => {
inputData = nodeConnectors[inputName].output;
nodeTypeData.outputs.forEach((inputName: string, i: number) => {
// Increment the index for outputs with current name
if (indexData.hasOwnProperty(inputName)) {
indexData[inputName]++;
@@ -231,54 +127,99 @@ export const nodeBase = mixins(
index = indexData[inputName];
// Get the position of the anchor depending on how many it has
anchorPosition = anchorPositions.output[nodeTypeData.outputs.length][index];
const anchorPosition = CanvasHelpers.ANCHOR_POSITIONS.output[nodeTypeData.outputs.length][index];
newEndpointData = {
uuid: `${this.nodeIndex}` + inputData.uuid + index,
const newEndpointData: IEndpointOptions = {
uuid: CanvasHelpers.getOutputEndpointUUID(this.nodeIndex, index),
anchor: anchorPosition,
maxConnections: inputData.maxConnections,
endpoint: inputData.endpoint,
endpointStyle: inputData.endpointStyle,
isSource: !this.isReadOnly,
maxConnections: -1,
endpoint: 'Dot',
endpointStyle: CanvasHelpers.getOutputEndpointStyle(nodeTypeData, '--color-foreground-xdark'),
endpointHoverStyle: CanvasHelpers.getOutputEndpointStyle(nodeTypeData, '--color-primary'),
isSource: true,
isTarget: false,
enabled: !this.isReadOnly,
parameters: {
nodeIndex: this.nodeIndex,
type: inputName,
index,
},
dragAllowedWhenFull: inputData.dragAllowedWhenFull,
cssClass: 'dot-output-endpoint',
dragAllowedWhenFull: false,
dragProxy: ['Rectangle', { width: 1, height: 1, strokeWidth: 0 }],
};
if (nodeTypeData.outputNames) {
// Apply output names if they got set
newEndpointData.overlays = [
['Label',
{
id: 'output-name-label',
location: [1.75, 0.5],
label: nodeTypeData.outputNames[index],
cssClass: 'node-output-endpoint-label',
visible: true,
},
],
CanvasHelpers.getOutputNameOverlay(nodeTypeData.outputNames[index]),
];
}
this.instance.addEndpoint(this.nodeName, newEndpointData);
});
const endpoint: Endpoint = this.instance.addEndpoint(this.nodeId, {...newEndpointData});
endpoint.__meta = {
nodeName: node.name,
nodeId: this.nodeId,
index: i,
totalEndpoints: nodeTypeData.outputs.length,
};
if (!this.isReadOnly) {
const plusEndpointData: IEndpointOptions = {
uuid: CanvasHelpers.getOutputEndpointUUID(this.nodeIndex, index),
anchor: anchorPosition,
maxConnections: -1,
endpoint: 'N8nPlus',
isSource: true,
isTarget: false,
enabled: !this.isReadOnly,
endpointStyle: {
fill: getStyleTokenValue('--color-xdark'),
outlineStroke: 'none',
hover: false,
showOutputLabel: nodeTypeData.outputs.length === 1,
size: nodeTypeData.outputs.length >= 3 ? 'small' : 'medium',
hoverMessage: this.$locale.baseText('nodeBase.clickToAddNodeOrDragToConnect'),
},
endpointHoverStyle: {
fill: getStyleTokenValue('--color-primary'),
outlineStroke: 'none',
hover: true, // hack to distinguish hover state
},
parameters: {
nodeIndex: this.nodeIndex,
type: inputName,
index,
},
cssClass: 'plus-draggable-endpoint',
dragAllowedWhenFull: false,
dragProxy: ['Rectangle', { width: 1, height: 1, strokeWidth: 0 }],
};
const plusEndpoint: Endpoint = this.instance.addEndpoint(this.nodeId, plusEndpointData);
plusEndpoint.__meta = {
nodeName: node.name,
nodeId: this.nodeId,
index: i,
totalEndpoints: nodeTypeData.outputs.length,
};
}
});
},
__makeInstanceDraggable(node: INodeUi) {
// TODO: This caused problems with displaying old information
// https://github.com/jsplumb/katavorio/wiki
// https://jsplumb.github.io/jsplumb/home.html
// Make nodes draggable
this.instance.draggable(this.nodeName, {
grid: [10, 10],
this.instance.draggable(this.nodeId, {
grid: [CanvasHelpers.GRID_SIZE, CanvasHelpers.GRID_SIZE],
start: (params: { e: MouseEvent }) => {
if (this.isReadOnly === true) {
// Do not allow to move nodes in readOnly mode
return false;
}
// @ts-ignore
this.dragging = true;
if (params.e && !this.$store.getters.isNodeSelected(this.data.name)) {
// Only the node which gets dragged directly gets an event, for all others it is
@@ -292,6 +233,8 @@ export const nodeBase = mixins(
return true;
},
stop: (params: { e: MouseEvent }) => {
// @ts-ignore
this.dragging = false;
if (this.$store.getters.isActionActive('dragActive')) {
const moveNodes = this.$store.getters.getSelectedNodes.slice();
const selectedNodeNames = moveNodes.map((node: INodeUi) => node.name);
@@ -305,7 +248,7 @@ export const nodeBase = mixins(
// even though "start" and "drag" gets called for all. So lets do for now
// some dirty DOM query to get the new positions till I have more time to
// create a proper solution
let newNodePositon: XYPositon;
let newNodePositon: XYPosition;
moveNodes.forEach((node: INodeUi) => {
const nodeElement = `node-${this.getNodeIndex(node.name)}`;
const element = document.getElementById(nodeElement);
@@ -328,11 +271,23 @@ export const nodeBase = mixins(
this.$store.commit('updateNodeProperties', updateInformation);
});
this.$emit('moved', node);
}
},
filter: '.node-description, .node-description .node-name, .node-description .node-subtitle',
});
},
__addNode (node: INodeUi) {
let nodeTypeData = this.$store.getters.nodeType(node.type) as INodeTypeDescription | null;
if (!nodeTypeData) {
// If node type is not know use by default the base.noOp data to display it
nodeTypeData = this.$store.getters.nodeType(NO_OP_NODE_TYPE) as INodeTypeDescription;
}
this.__addInputEndpoints(node, nodeTypeData);
this.__addOutputEndpoints(node, nodeTypeData);
this.__makeInstanceDraggable(node);
},
touchEnd(e: MouseEvent) {
if (this.isTouchDevice) {

View File

@@ -344,6 +344,7 @@ export const nodeHelpers = mixins(
};
this.$store.commit('updateNodeProperties', updateInformation);
this.$store.commit('clearNodeExecutionData', node.name);
this.updateNodeParameterIssues(node);
this.updateNodeCredentialIssues(node);
}

View File

@@ -165,7 +165,7 @@ export const pushConnection = mixins(
if (receivedData.type === 'sendConsoleMessage') {
const pushData = receivedData.data;
console.log(pushData.source, pushData.message); // eslint-disable-line no-console
console.log(pushData.source, ...pushData.messages); // eslint-disable-line no-console
return true;
}
@@ -263,8 +263,8 @@ export const pushConnection = mixins(
// Workflow did execute without a problem
this.$titleSet(workflow.name as string, 'IDLE');
this.$showMessage({
title: 'Workflow was executed',
message: 'Workflow was executed successfully!',
title: this.$locale.baseText('pushConnection.showMessage.title'),
message: this.$locale.baseText('pushConnection.showMessage.message'),
type: 'success',
});
}

View File

@@ -16,6 +16,7 @@ import {
IWorkflowShortResponse,
IRestApi,
IWorkflowDataUpdate,
INodeTranslationHeaders,
} from '@/Interface';
import {
IDataObject,
@@ -78,6 +79,10 @@ export const restApi = Vue.extend({
return self.restApi().makeRestApiRequest('POST', `/executions-current/${executionId}/stop`);
},
getNodeTranslationHeaders: (): Promise<INodeTranslationHeaders> => {
return self.restApi().makeRestApiRequest('GET', '/node-translation-headers');
},
// Returns all node-types
getNodeTypes: (onlyLatest = false): Promise<INodeTypeDescription[]> => {
return self.restApi().makeRestApiRequest('GET', `/node-types`, {onlyLatest});

View File

@@ -110,7 +110,8 @@ export const showMessage = mixins(externalHooks).extend({
return errorMessage;
},
$showError(error: Error, title: string, message?: string) {
$showError(e: Error | unknown, title: string, message?: string) {
const error = e as Error;
const messageLine = message ? `${message}<br/>` : '';
this.$showMessage({
title,
@@ -130,11 +131,11 @@ export const showMessage = mixins(externalHooks).extend({
this.$telemetry.track('Instance FE emitted error', { error_title: title, error_description: message, error_message: error.message, workflow_id: this.$store.getters.workflowId });
},
async confirmMessage (message: string, headline: string, type: MessageType | null = 'warning', confirmButtonText = 'OK', cancelButtonText = 'Cancel'): Promise<boolean> {
async confirmMessage (message: string, headline: string, type: MessageType | null = 'warning', confirmButtonText?: string, cancelButtonText?: string): Promise<boolean> {
try {
const options: ElMessageBoxOptions = {
confirmButtonText,
cancelButtonText,
confirmButtonText: confirmButtonText || this.$locale.baseText('showMessage.ok'),
cancelButtonText: cancelButtonText || this.$locale.baseText('showMessage.cancel'),
dangerouslyUseHTMLString: true,
...(type && { type }),
};
@@ -172,7 +173,7 @@ export const showMessage = mixins(externalHooks).extend({
<summary
style="color: #ff6d5a; font-weight: bold; cursor: pointer;"
>
Show Details
${this.$locale.baseText('showMessage.showDetails')}
</summary>
<p>${node.name}: ${errorDescription}</p>
</details>

View File

@@ -36,7 +36,7 @@ import {
IWorkflowData,
IWorkflowDb,
IWorkflowDataUpdate,
XYPositon,
XYPosition,
ITag,
IUpdateInformation,
} from '../../Interface';
@@ -49,7 +49,7 @@ import { showMessage } from '@/components/mixins/showMessage';
import { isEqual } from 'lodash';
import mixins from 'vue-typed-mixins';
import { v4 as uuidv4} from 'uuid';
import { v4 as uuidv4 } from 'uuid';
export const workflowHelpers = mixins(
externalHooks,
@@ -225,7 +225,7 @@ export const workflowHelpers = mixins(
return [];
},
getByName: (nodeType: string): INodeType | INodeVersionedType | undefined => {
const nodeTypeDescription = this.$store.getters.nodeType(nodeType);
const nodeTypeDescription = this.$store.getters.nodeType(nodeType) as INodeTypeDescription | null;
if (nodeTypeDescription === null) {
return undefined;
@@ -236,7 +236,7 @@ export const workflowHelpers = mixins(
};
},
getByNameAndVersion: (nodeType: string, version?: number): INodeType | undefined => {
const nodeTypeDescription = this.$store.getters.nodeType(nodeType, version);
const nodeTypeDescription = this.$store.getters.nodeType(nodeType, version) as INodeTypeDescription | null;
if (nodeTypeDescription === null) {
return undefined;
@@ -329,7 +329,7 @@ export const workflowHelpers = mixins(
// Get the data of the node type that we can get the default values
// TODO: Later also has to care about the node-type-version as defaults could be different
const nodeType = this.$store.getters.nodeType(node.type, node.typeVersion) as INodeTypeDescription;
const nodeType = this.$store.getters.nodeType(node.type, node.typeVersion) as INodeTypeDescription | null;
if (nodeType !== null) {
// Node-Type is known so we can save the parameters correctly
@@ -362,11 +362,6 @@ export const workflowHelpers = mixins(
nodeData.credentials = saveCredenetials;
}
}
// Save the node color only if it is different to the default color
if (node.color && node.color !== nodeType.defaults.color) {
nodeData.color = node.color;
}
} else {
// Node-Type is not known so save the data as it is
nodeData.credentials = node.credentials;
@@ -483,8 +478,8 @@ export const workflowHelpers = mixins(
this.$store.commit('removeActiveAction', 'workflowSaving');
this.$showMessage({
title: 'Problem saving workflow',
message: `There was a problem saving the workflow: "${e.message}"`,
title: this.$locale.baseText('workflowHelpers.showMessage.title'),
message: this.$locale.baseText('workflowHelpers.showMessage.message') + `: "${e.message}"`,
type: 'error',
});
@@ -557,8 +552,8 @@ export const workflowHelpers = mixins(
this.$store.commit('removeActiveAction', 'workflowSaving');
this.$showMessage({
title: 'Problem saving workflow',
message: `There was a problem saving the workflow: "${e.message}"`,
title: this.$locale.baseText('workflowHelpers.showMessage.title'),
message: this.$locale.baseText('workflowHelpers.showMessage.message') + `: "${e.message}"`,
type: 'error',
});
@@ -568,7 +563,7 @@ export const workflowHelpers = mixins(
// Updates the position of all the nodes that the top-left node
// is at the given position
updateNodePositions (workflowData: IWorkflowData | IWorkflowDataUpdate, position: XYPositon): void {
updateNodePositions (workflowData: IWorkflowData | IWorkflowDataUpdate, position: XYPosition): void {
if (workflowData.nodes === undefined) {
return;
}

View File

@@ -31,7 +31,9 @@ export const workflowRun = mixins(
if (this.$store.getters.pushConnectionActive === false) {
// Do not start if the connection to server is not active
// because then it can not receive the data as it executes.
throw new Error('No active connection to server. It is maybe down.');
throw new Error(
this.$locale.baseText('workflowRun.noActiveConnectionToTheServer'),
);
}
this.$store.commit('addActiveAction', 'workflowRunning');
@@ -89,8 +91,8 @@ export const workflowRun = mixins(
}
this.$showMessage({
title: 'Workflow can not be executed',
message: 'The workflow has issues. Please fix them first:<br />&nbsp;&nbsp;- ' + errorMessages.join('<br />&nbsp;&nbsp;- '),
title: this.$locale.baseText('workflowRun.showMessage.title'),
message: this.$locale.baseText('workflowRun.showMessage.message') + ':<br />&nbsp;&nbsp;- ' + errorMessages.join('<br />&nbsp;&nbsp;- '),
type: 'error',
duration: 0,
});
@@ -191,6 +193,7 @@ export const workflowRun = mixins(
},
};
this.$store.commit('setWorkflowExecutionData', executionData);
this.updateNodesExecutionIssues();
const runWorkflowApiResponse = await this.runWorkflowApi(startRunData);
@@ -199,7 +202,11 @@ export const workflowRun = mixins(
return runWorkflowApiResponse;
} catch (error) {
this.$titleSet(workflow.name as string, 'ERROR');
this.$showError(error, 'Problem running workflow', 'There was a problem running the workflow:');
this.$showError(
error,
this.$locale.baseText('workflowRun.showError.title'),
this.$locale.baseText('workflowRun.showError.message'),
);
return undefined;
}
},

View File

@@ -11,6 +11,7 @@ export const DEFAULT_NEW_WORKFLOW_NAME = 'My workflow';
export const MIN_WORKFLOW_NAME_LENGTH = 1;
export const MAX_WORKFLOW_NAME_LENGTH = 128;
export const DUPLICATE_POSTFFIX = ' copy';
export const NODE_OUTPUT_DEFAULT_KEY = '_NODE_OUTPUT_DEFAULT_KEY_';
// tags
export const MAX_TAG_NAME_LENGTH = 24;
@@ -25,6 +26,8 @@ export const CREDENTIAL_EDIT_MODAL_KEY = 'editCredential';
export const CREDENTIAL_SELECT_MODAL_KEY = 'selectCredential';
export const CREDENTIAL_LIST_MODAL_KEY = 'credentialsList';
export const PERSONALIZATION_MODAL_KEY = 'personalization';
export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
export const VALUE_SURVEY_MODAL_KEY = 'valueSurvey';
// breakpoints
export const BREAKPOINT_SM = 768;
@@ -71,7 +74,7 @@ export const CUSTOM_NODES_CATEGORY = 'Custom Nodes';
export const SUBCATEGORY_DESCRIPTIONS: {
[category: string]: { [subcategory: string]: string };
} = {
'Core Nodes': {
'Core Nodes': { // this - all subkeys are set from codex
Flow: 'Branches, core triggers, merge data',
Files: 'Work with CSV, XML, text, images etc.',
'Data Transformation': 'Manipulate data fields, run code',
@@ -93,7 +96,6 @@ export const INSTANCE_ID_HEADER = 'n8n-instance-id';
export const WAIT_TIME_UNLIMITED = '3000-01-01T00:00:00.000Z';
export const WORK_AREA_KEY = 'workArea';
export const AUTOMATION_CONSULTING_WORK_AREA = 'automationConsulting';
export const FINANCE_WORK_AREA = 'finance';
export const HR_WORK_AREA = 'HR';
export const IT_ENGINEERING_WORK_AREA = 'IT-Engineering';
@@ -104,7 +106,21 @@ export const SALES_BUSINESSDEV_WORK_AREA = 'sales-businessDevelopment';
export const SECURITY_WORK_AREA = 'security';
export const SUPPORT_WORK_AREA = 'support';
export const OPS_WORK_AREA = 'ops';
export const EXECUTIVE_WORK_AREA = 'executive';
export const OTHER_WORK_AREA_OPTION = 'other';
export const NOT_APPLICABLE_WORK_AREA = 'n/a';
export const COMPANY_INDUSTRY_KEY = 'companyIndustry';
export const E_COMMERCE_INDUSTRY = 'e-commerce';
export const AUTOMATION_CONSULTING_INDUSTRY = 'automation-consulting';
export const SYSTEM_INTEGRATION_INDUSTRY = 'systems-integration';
export const GOVERNMENT_INDUSTRY = 'government';
export const LEGAL_INDUSTRY = 'legal-industry';
export const HEALTHCARE_INDUSTRY= 'healthcare';
export const FINANCE_INDUSTRY = 'finance-industry';
export const SECURITY_INDUSTRY = 'security-industry';
export const SAAS_INDUSTRY = 'saas';
export const OTHER_INDUSTRY_OPTION= 'other';
export const COMPANY_SIZE_KEY = 'companySize';
export const COMPANY_SIZE_20_OR_LESS = '<20';
@@ -116,3 +132,7 @@ export const COMPANY_SIZE_PERSONAL_USE = 'personalUser';
export const CODING_SKILL_KEY = 'codingSkill';
export const OTHER_WORK_AREA_KEY = 'otherWorkArea';
export const OTHER_COMPANY_INDUSTRY_KEY = 'otherCompanyIndustry';
export const VALID_EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

View File

@@ -18,6 +18,7 @@ import router from './router';
import { runExternalHook } from './components/mixins/externalHooks';
import { TelemetryPlugin } from './plugins/telemetry';
import { I18nPlugin } from './plugins/i18n';
import { store } from './store';
@@ -27,6 +28,7 @@ router.afterEach((to, from) => {
});
Vue.use(TelemetryPlugin);
Vue.use((vue) => I18nPlugin(vue, store));
new Vue({
router,

View File

@@ -98,7 +98,7 @@ const module: Module<ICredentialsState, IRootState> = {
},
getCredentialsByType: (state: ICredentialsState, getters: any) => { // tslint:disable-line:no-any
return (credentialType: string): ICredentialsResponse[] => {
return getters.allCredentialsByType[credentialType];
return getters.allCredentialsByType[credentialType] || [];
};
},
getNodesWithAccess (state: ICredentialsState, getters: any, rootState: IRootState, rootGetters: any) { // tslint:disable-line:no-any

View File

@@ -1,12 +1,22 @@
import { AUTOMATION_CONSULTING_WORK_AREA, CALENDLY_TRIGGER_NODE_TYPE, CLEARBIT_NODE_TYPE, COMPANY_SIZE_1000_OR_MORE, COMPANY_SIZE_500_999, CRON_NODE_TYPE, ELASTIC_SECURITY_NODE_TYPE, EMAIL_SEND_NODE_TYPE, EXECUTE_COMMAND_NODE_TYPE, FINANCE_WORK_AREA, FUNCTION_NODE_TYPE, GITHUB_TRIGGER_NODE_TYPE, HTTP_REQUEST_NODE_TYPE, IF_NODE_TYPE, ITEM_LISTS_NODE_TYPE, IT_ENGINEERING_WORK_AREA, JIRA_TRIGGER_NODE_TYPE, MICROSOFT_EXCEL_NODE_TYPE, MICROSOFT_TEAMS_NODE_TYPE, PERSONALIZATION_MODAL_KEY, PAGERDUTY_NODE_TYPE, PRODUCT_WORK_AREA, QUICKBOOKS_NODE_TYPE, SALESFORCE_NODE_TYPE, SALES_BUSINESSDEV_WORK_AREA, SECURITY_WORK_AREA, SEGMENT_NODE_TYPE, SET_NODE_TYPE, SLACK_NODE_TYPE, SPREADSHEET_FILE_NODE_TYPE, SWITCH_NODE_TYPE, WEBHOOK_NODE_TYPE, XERO_NODE_TYPE, COMPANY_SIZE_KEY, WORK_AREA_KEY, CODING_SKILL_KEY } from '@/constants';
import { CALENDLY_TRIGGER_NODE_TYPE, CLEARBIT_NODE_TYPE, COMPANY_SIZE_1000_OR_MORE, COMPANY_SIZE_500_999, CRON_NODE_TYPE, ELASTIC_SECURITY_NODE_TYPE, EMAIL_SEND_NODE_TYPE, EXECUTE_COMMAND_NODE_TYPE, FINANCE_WORK_AREA, FUNCTION_NODE_TYPE, GITHUB_TRIGGER_NODE_TYPE, HTTP_REQUEST_NODE_TYPE, IF_NODE_TYPE, ITEM_LISTS_NODE_TYPE, IT_ENGINEERING_WORK_AREA, JIRA_TRIGGER_NODE_TYPE, MICROSOFT_EXCEL_NODE_TYPE, MICROSOFT_TEAMS_NODE_TYPE, PERSONALIZATION_MODAL_KEY, PAGERDUTY_NODE_TYPE, PRODUCT_WORK_AREA, QUICKBOOKS_NODE_TYPE, SALESFORCE_NODE_TYPE, SALES_BUSINESSDEV_WORK_AREA, SECURITY_WORK_AREA, SEGMENT_NODE_TYPE, SET_NODE_TYPE, SLACK_NODE_TYPE, SPREADSHEET_FILE_NODE_TYPE, SWITCH_NODE_TYPE, WEBHOOK_NODE_TYPE, XERO_NODE_TYPE, COMPANY_SIZE_KEY, WORK_AREA_KEY, CODING_SKILL_KEY } from '@/constants';
import { IPersonalizationSurveyAnswers } from '@/Interface';
export function getPersonalizedNodeTypes(answers: IPersonalizationSurveyAnswers) {
const companySize = answers[COMPANY_SIZE_KEY];
const workArea = answers[WORK_AREA_KEY];
if (companySize === null && workArea === null && answers[CODING_SKILL_KEY] === null) {
function isWorkAreaAnswer(name: string) {
if (Array.isArray(workArea)) {
return workArea.includes(name);
} else {
return workArea === name;
}
}
const workAreaIsEmpty = workArea === null || workArea.length === 0;
if (companySize === null && workAreaIsEmpty && answers[CODING_SKILL_KEY] === null) {
return [];
}
@@ -17,7 +27,7 @@ export function getPersonalizedNodeTypes(answers: IPersonalizationSurveyAnswers)
}
let nodeTypes = [] as string[];
if (workArea === IT_ENGINEERING_WORK_AREA || workArea === AUTOMATION_CONSULTING_WORK_AREA) {
if (isWorkAreaAnswer(IT_ENGINEERING_WORK_AREA)) {
nodeTypes = nodeTypes.concat(WEBHOOK_NODE_TYPE);
}
else {
@@ -39,16 +49,16 @@ export function getPersonalizedNodeTypes(answers: IPersonalizationSurveyAnswers)
}
if (companySize === COMPANY_SIZE_500_999 || companySize === COMPANY_SIZE_1000_OR_MORE) {
if (workArea === SALES_BUSINESSDEV_WORK_AREA) {
if (isWorkAreaAnswer(SALES_BUSINESSDEV_WORK_AREA)) {
nodeTypes = nodeTypes.concat(SALESFORCE_NODE_TYPE);
}
else if (workArea === SECURITY_WORK_AREA) {
else if (isWorkAreaAnswer(SECURITY_WORK_AREA)) {
nodeTypes = nodeTypes.concat([ELASTIC_SECURITY_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
}
else if (workArea === PRODUCT_WORK_AREA) {
else if (isWorkAreaAnswer(PRODUCT_WORK_AREA)) {
nodeTypes = nodeTypes.concat([JIRA_TRIGGER_NODE_TYPE, SEGMENT_NODE_TYPE]);
}
else if (workArea === IT_ENGINEERING_WORK_AREA) {
else if (isWorkAreaAnswer(IT_ENGINEERING_WORK_AREA)) {
nodeTypes = nodeTypes.concat([GITHUB_TRIGGER_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
}
else {
@@ -56,19 +66,19 @@ export function getPersonalizedNodeTypes(answers: IPersonalizationSurveyAnswers)
}
}
else {
if (workArea === SALES_BUSINESSDEV_WORK_AREA) {
if (isWorkAreaAnswer(SALES_BUSINESSDEV_WORK_AREA)) {
nodeTypes = nodeTypes.concat(CLEARBIT_NODE_TYPE);
}
else if (workArea === SECURITY_WORK_AREA) {
else if (isWorkAreaAnswer(SECURITY_WORK_AREA)) {
nodeTypes = nodeTypes.concat([PAGERDUTY_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
}
else if (workArea === PRODUCT_WORK_AREA) {
else if (isWorkAreaAnswer(PRODUCT_WORK_AREA)) {
nodeTypes = nodeTypes.concat([JIRA_TRIGGER_NODE_TYPE, CALENDLY_TRIGGER_NODE_TYPE]);
}
else if (workArea === IT_ENGINEERING_WORK_AREA) {
else if (isWorkAreaAnswer(IT_ENGINEERING_WORK_AREA)) {
nodeTypes = nodeTypes.concat([EXECUTE_COMMAND_NODE_TYPE, HTTP_REQUEST_NODE_TYPE]);
}
else if (workArea === FINANCE_WORK_AREA) {
else if (isWorkAreaAnswer(FINANCE_WORK_AREA)) {
nodeTypes = nodeTypes.concat([XERO_NODE_TYPE, QUICKBOOKS_NODE_TYPE, SPREADSHEET_FILE_NODE_TYPE]);
}
else {

View File

@@ -1,19 +1,22 @@
import { ActionContext, Module } from 'vuex';
import {
IN8nPrompts,
IN8nUISettings,
IN8nValueSurveyData,
IPersonalizationSurveyAnswers,
IRootState,
ISettingsState,
} from '../Interface';
import { getSettings, submitPersonalizationSurvey } from '../api/settings';
import { getPromptsData, getSettings, submitValueSurvey, submitPersonalizationSurvey, submitContactInfo } from '../api/settings';
import Vue from 'vue';
import { getPersonalizedNodeTypes } from './helper';
import { PERSONALIZATION_MODAL_KEY } from '@/constants';
import { CONTACT_PROMPT_MODAL_KEY, PERSONALIZATION_MODAL_KEY, VALUE_SURVEY_MODAL_KEY } from '@/constants';
const module: Module<ISettingsState, IRootState> = {
namespaced: true,
state: {
settings: {} as IN8nUISettings,
promptsData: {} as IN8nPrompts,
},
getters: {
personalizedNodeTypes(state: ISettingsState): string[] {
@@ -24,6 +27,9 @@ const module: Module<ISettingsState, IRootState> = {
return getPersonalizedNodeTypes(answers);
},
getPromptsData(state: ISettingsState) {
return state.promptsData;
},
},
mutations: {
setSettings(state: ISettingsState, settings: IN8nUISettings) {
@@ -35,6 +41,9 @@ const module: Module<ISettingsState, IRootState> = {
shouldShow: false,
});
},
setPromptsData(state: ISettingsState, promptsData: IN8nPrompts) {
Vue.set(state, 'promptsData', promptsData);
},
},
actions: {
async getSettings(context: ActionContext<ISettingsState, IRootState>) {
@@ -55,10 +64,12 @@ const module: Module<ISettingsState, IRootState> = {
context.commit('setInstanceId', settings.instanceId, {root: true});
context.commit('setOauthCallbackUrls', settings.oauthCallbackUrls, {root: true});
context.commit('setN8nMetadata', settings.n8nMetadata || {}, {root: true});
context.commit('setDefaultLocale', settings.defaultLocale, {root: true});
context.commit('versions/setVersionNotificationSettings', settings.versionNotifications, {root: true});
context.commit('setTelemetry', settings.telemetry, {root: true});
const showPersonalizationsModal = settings.personalizationSurvey && settings.personalizationSurvey.shouldShow && !settings.personalizationSurvey.answers;
if (showPersonalizationsModal) {
context.commit('ui/openModal', PERSONALIZATION_MODAL_KEY, {root: true});
}
@@ -69,6 +80,40 @@ const module: Module<ISettingsState, IRootState> = {
context.commit('setPersonalizationAnswers', results);
},
async fetchPromptsData(context: ActionContext<ISettingsState, IRootState>) {
if (!context.rootGetters.isTelemetryEnabled) {
return;
}
try {
const promptsData: IN8nPrompts = await getPromptsData(context.state.settings.instanceId);
if (promptsData && promptsData.showContactPrompt) {
context.commit('ui/openModal', CONTACT_PROMPT_MODAL_KEY, {root: true});
} else if (promptsData && promptsData.showValueSurvey) {
context.commit('ui/openModal', VALUE_SURVEY_MODAL_KEY, {root: true});
}
context.commit('setPromptsData', promptsData);
} catch (e) {
return e;
}
},
async submitContactInfo(context: ActionContext<ISettingsState, IRootState>, email: string) {
try {
return await submitContactInfo(context.state.settings.instanceId, email);
} catch (e) {
return e;
}
},
async submitValueSurvey(context: ActionContext<ISettingsState, IRootState>, params: IN8nValueSurveyData) {
try {
return await submitValueSurvey(context.state.settings.instanceId, params);
} catch (e) {
return e;
}
},
},
};

View File

@@ -1,4 +1,4 @@
import { CREDENTIAL_EDIT_MODAL_KEY, DUPLICATE_MODAL_KEY, PERSONALIZATION_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VERSIONS_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, CREDENTIAL_LIST_MODAL_KEY } from '@/constants';
import { CONTACT_PROMPT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, DUPLICATE_MODAL_KEY, PERSONALIZATION_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VERSIONS_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, CREDENTIAL_LIST_MODAL_KEY, VALUE_SURVEY_MODAL_KEY } from '@/constants';
import Vue from 'vue';
import { ActionContext, Module } from 'vuex';
import {
@@ -10,6 +10,9 @@ const module: Module<IUiState, IRootState> = {
namespaced: true,
state: {
modals: {
[CONTACT_PROMPT_MODAL_KEY]: {
open: false,
},
[CREDENTIAL_EDIT_MODAL_KEY]: {
open: false,
mode: '',
@@ -33,6 +36,9 @@ const module: Module<IUiState, IRootState> = {
[WORKFLOW_OPEN_MODAL_KEY]: {
open: false,
},
[VALUE_SURVEY_MODAL_KEY]: {
open: false,
},
[VERSIONS_MODAL_KEY]: {
open: false,
},

View File

@@ -28,8 +28,6 @@ $--badge-warning-color: #6b5900;
// Warning tooltip
$--warning-tooltip-color: #ff8080;
$--custom-node-view-background : #faf9fe;
// Table
$--custom-table-background-main: $--custom-header-background;
$--custom-table-background-stripe-color: #f6f6f6;

View File

@@ -2,8 +2,9 @@
@import "~n8n-design-system/theme/dist/index.css";
body {
background-color: $--custom-node-view-background;
background-color: var(--color-canvas-background);
}
.clickable {
@@ -116,28 +117,6 @@ body {
}
}
// Transfer list (nodes)
.el-transfer {
.el-transfer-panel {
background-color: $--custom-input-background;
// border: 1px solid #404040;
border: none;
border-radius: 10px;
padding: 1em;
.el-transfer-panel__header {
background: none;
}
}
.el-transfer__buttons button {
border: 2px solid $--color-primary;
}
.el-transfer__buttons button.is-disabled {
border: 2px solid #aaa;
background-color: #fff;
}
}
// Tabs
.type-selector:focus,
.el-tabs__header:focus,
@@ -179,7 +158,7 @@ body {
// Notification
.el-notification {
border-radius: 0;
border-radius: 4px;
border: none;
}
@@ -211,4 +190,3 @@ body {
}
}
}

View File

@@ -0,0 +1,779 @@
/**
* Custom connector type
* Based on jsplumb Flowchart and Bezier types
*
* Source GitHub repository:
* https://github.com/jsplumb/jsplumb
*
* Source files:
* https://github.com/jsplumb/jsplumb/blob/fb5fce52794fa52306825bdaa62bf3855cdfd7e0/src/connectors-flowchart.js
* https://github.com/jsplumb/jsplumb/blob/fb5fce52794fa52306825bdaa62bf3855cdfd7e0/src/connectors-bezier.js
*
*
* All 1.x.x and 2.x.x versions of jsPlumb Community edition, and so also the
* content of this file, are dual-licensed under both MIT and GPLv2.
*
* MIT LICENSE
*
* Copyright (c) 2010 - 2014 jsPlumb, http://jsplumbtoolkit.com/
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
* ===============================================================================
* GNU GENERAL PUBLIC LICENSE
* Version 2, June 1991
*
* Copyright (C) 1989, 1991 Free Software Foundation, Inc.
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
* Everyone is permitted to copy and distribute verbatim copies
* of this license document, but changing it is not allowed.
*
* Preamble
*
* The licenses for most software are designed to take away your
* freedom to share and change it. By contrast, the GNU General Public
* License is intended to guarantee your freedom to share and change free
* software--to make sure the software is free for all its users. This
* General Public License applies to most of the Free Software
* Foundation's software and to any other program whose authors commit to
* using it. (Some other Free Software Foundation software is covered by
* the GNU Lesser General Public License instead.) You can apply it to
* your programs, too.
*
* When we speak of free software, we are referring to freedom, not
* price. Our General Public Licenses are designed to make sure that you
* have the freedom to distribute copies of free software (and charge for
* this service if you wish), that you receive source code or can get it
* if you want it, that you can change the software or use pieces of it
* in new free programs; and that you know you can do these things.
*
* To protect your rights, we need to make restrictions that forbid
* anyone to deny you these rights or to ask you to surrender the rights.
* These restrictions translate to certain responsibilities for you if you
* distribute copies of the software, or if you modify it.
*
* For example, if you distribute copies of such a program, whether
* gratis or for a fee, you must give the recipients all the rights that
* you have. You must make sure that they, too, receive or can get the
* source code. And you must show them these terms so they know their
* rights.
*
* We protect your rights with two steps: (1) copyright the software, and
* (2) offer you this license which gives you legal permission to copy,
* distribute and/or modify the software.
*
* Also, for each author's protection and ours, we want to make certain
* that everyone understands that there is no warranty for this free
* software. If the software is modified by someone else and passed on, we
* want its recipients to know that what they have is not the original, so
* that any problems introduced by others will not reflect on the original
* authors' reputations.
*
* Finally, any free program is threatened constantly by software
* patents. We wish to avoid the danger that redistributors of a free
* program will individually obtain patent licenses, in effect making the
* program proprietary. To prevent this, we have made it clear that any
* patent must be licensed for everyone's free use or not licensed at all.
*
* The precise terms and conditions for copying, distribution and
* modification follow.
*
* GNU GENERAL PUBLIC LICENSE
* TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
*
* 0. This License applies to any program or other work which contains
* a notice placed by the copyright holder saying it may be distributed
* under the terms of this General Public License. The "Program", below,
* refers to any such program or work, and a "work based on the Program"
* means either the Program or any derivative work under copyright law:
* that is to say, a work containing the Program or a portion of it,
* either verbatim or with modifications and/or translated into another
* language. (Hereinafter, translation is included without limitation in
* the term "modification".) Each licensee is addressed as "you".
*
* Activities other than copying, distribution and modification are not
* covered by this License; they are outside its scope. The act of
* running the Program is not restricted, and the output from the Program
* is covered only if its contents constitute a work based on the
* Program (independent of having been made by running the Program).
* Whether that is true depends on what the Program does.
*
* 1. You may copy and distribute verbatim copies of the Program's
* source code as you receive it, in any medium, provided that you
* conspicuously and appropriately publish on each copy an appropriate
* copyright notice and disclaimer of warranty; keep intact all the
* notices that refer to this License and to the absence of any warranty;
* and give any other recipients of the Program a copy of this License
* along with the Program.
*
* You may charge a fee for the physical act of transferring a copy, and
* you may at your option offer warranty protection in exchange for a fee.
*
* 2. You may modify your copy or copies of the Program or any portion
* of it, thus forming a work based on the Program, and copy and
* distribute such modifications or work under the terms of Section 1
* above, provided that you also meet all of these conditions:
*
* a) You must cause the modified files to carry prominent notices
* stating that you changed the files and the date of any change.
*
* b) You must cause any work that you distribute or publish, that in
* whole or in part contains or is derived from the Program or any
* part thereof, to be licensed as a whole at no charge to all third
* parties under the terms of this License.
*
* c) If the modified program normally reads commands interactively
* when run, you must cause it, when started running for such
* interactive use in the most ordinary way, to print or display an
* announcement including an appropriate copyright notice and a
* notice that there is no warranty (or else, saying that you provide
* a warranty) and that users may redistribute the program under
* these conditions, and telling the user how to view a copy of this
* License. (Exception: if the Program itself is interactive but
* does not normally print such an announcement, your work based on
* the Program is not required to print an announcement.)
*
* These requirements apply to the modified work as a whole. If
* identifiable sections of that work are not derived from the Program,
* and can be reasonably considered independent and separate works in
* themselves, then this License, and its terms, do not apply to those
* sections when you distribute them as separate works. But when you
* distribute the same sections as part of a whole which is a work based
* on the Program, the distribution of the whole must be on the terms of
* this License, whose permissions for other licensees extend to the
* entire whole, and thus to each and every part regardless of who wrote it.
*
* Thus, it is not the intent of this section to claim rights or contest
* your rights to work written entirely by you; rather, the intent is to
* exercise the right to control the distribution of derivative or
* collective works based on the Program.
*
* In addition, mere aggregation of another work not based on the Program
* with the Program (or with a work based on the Program) on a volume of
* a storage or distribution medium does not bring the other work under
* the scope of this License.
*
* 3. You may copy and distribute the Program (or a work based on it,
* under Section 2) in object code or executable form under the terms of
* Sections 1 and 2 above provided that you also do one of the following:
*
* a) Accompany it with the complete corresponding machine-readable
* source code, which must be distributed under the terms of Sections
* 1 and 2 above on a medium customarily used for software interchange; or,
*
* b) Accompany it with a written offer, valid for at least three
* years, to give any third party, for a charge no more than your
* cost of physically performing source distribution, a complete
* machine-readable copy of the corresponding source code, to be
* distributed under the terms of Sections 1 and 2 above on a medium
* customarily used for software interchange; or,
*
* c) Accompany it with the information you received as to the offer
* to distribute corresponding source code. (This alternative is
* allowed only for noncommercial distribution and only if you
* received the program in object code or executable form with such
* an offer, in accord with Subsection b above.)
*
* The source code for a work means the preferred form of the work for
* making modifications to it. For an executable work, complete source
* code means all the source code for all modules it contains, plus any
* associated interface definition files, plus the scripts used to
* control compilation and installation of the executable. However, as a
* special exception, the source code distributed need not include
* anything that is normally distributed (in either source or binary
* form) with the major components (compiler, kernel, and so on) of the
* operating system on which the executable runs, unless that component
* itself accompanies the executable.
*
* If distribution of executable or object code is made by offering
* access to copy from a designated place, then offering equivalent
* access to copy the source code from the same place counts as
* distribution of the source code, even though third parties are not
* compelled to copy the source along with the object code.
*
* 4. You may not copy, modify, sublicense, or distribute the Program
* except as expressly provided under this License. Any attempt
* otherwise to copy, modify, sublicense or distribute the Program is
* void, and will automatically terminate your rights under this License.
* However, parties who have received copies, or rights, from you under
* this License will not have their licenses terminated so long as such
* parties remain in full compliance.
*
* 5. You are not required to accept this License, since you have not
* signed it. However, nothing else grants you permission to modify or
* distribute the Program or its derivative works. These actions are
* prohibited by law if you do not accept this License. Therefore, by
* modifying or distributing the Program (or any work based on the
* Program), you indicate your acceptance of this License to do so, and
* all its terms and conditions for copying, distributing or modifying
* the Program or works based on it.
*
* 6. Each time you redistribute the Program (or any work based on the
* Program), the recipient automatically receives a license from the
* original licensor to copy, distribute or modify the Program subject to
* these terms and conditions. You may not impose any further
* restrictions on the recipients' exercise of the rights granted herein.
* You are not responsible for enforcing compliance by third parties to
* this License.
*
* 7. If, as a consequence of a court judgment or allegation of patent
* infringement or for any other reason (not limited to patent issues),
* conditions are imposed on you (whether by court order, agreement or
* otherwise) that contradict the conditions of this License, they do not
* excuse you from the conditions of this License. If you cannot
* distribute so as to satisfy simultaneously your obligations under this
* License and any other pertinent obligations, then as a consequence you
* may not distribute the Program at all. For example, if a patent
* license would not permit royalty-free redistribution of the Program by
* all those who receive copies directly or indirectly through you, then
* the only way you could satisfy both it and this License would be to
* refrain entirely from distribution of the Program.
*
* If any portion of this section is held invalid or unenforceable under
* any particular circumstance, the balance of the section is intended to
* apply and the section as a whole is intended to apply in other
* circumstances.
*
* It is not the purpose of this section to induce you to infringe any
* patents or other property right claims or to contest validity of any
* such claims; this section has the sole purpose of protecting the
* integrity of the free software distribution system, which is
* implemented by public license practices. Many people have made
* generous contributions to the wide range of software distributed
* through that system in reliance on consistent application of that
* system; it is up to the author/donor to decide if he or she is willing
* to distribute software through any other system and a licensee cannot
* impose that choice.
*
* This section is intended to make thoroughly clear what is believed to
* be a consequence of the rest of this License.
*
* 8. If the distribution and/or use of the Program is restricted in
* certain countries either by patents or by copyrighted interfaces, the
* original copyright holder who places the Program under this License
* may add an explicit geographical distribution limitation excluding
* those countries, so that distribution is permitted only in or among
* countries not thus excluded. In such case, this License incorporates
* the limitation as if written in the body of this License.
*
* 9. The Free Software Foundation may publish revised and/or new versions
* of the General Public License from time to time. Such new versions will
* be similar in spirit to the present version, but may differ in detail to
* address new problems or concerns.
*
* Each version is given a distinguishing version number. If the Program
* specifies a version number of this License which applies to it and "any
* later version", you have the option of following the terms and conditions
* either of that version or of any later version published by the Free
* Software Foundation. If the Program does not specify a version number of
* this License, you may choose any version ever published by the Free Software
* Foundation.
*
* 10. If you wish to incorporate parts of the Program into other free
* programs whose distribution conditions are different, write to the author
* to ask for permission. For software which is copyrighted by the Free
* Software Foundation, write to the Free Software Foundation; we sometimes
* make exceptions for this. Our decision will be guided by the two goals
* of preserving the free status of all derivatives of our free software and
* of promoting the sharing and reuse of software generally.
*
* NO WARRANTY
*
* 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
* FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
* OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
* PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
* OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
* TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
* PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
* REPAIR OR CORRECTION.
*
* 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
* WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
* REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
* INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
* OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
* TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
* YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
* PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGES.
*
*/
(function () {
"use strict";
var root = this, _jp = root.jsPlumb, _ju = root.jsPlumbUtil, _jg = root.Biltong;
var STRAIGHT = "Straight";
var ARC = "Arc";
/**
* Custom connector type
*
* @param stub {number} length of stub segments in flowchart
* @param getEndpointOffset {Function} callback to offset stub length based on endpoint in flowchart
* @param midpoint {number} float percent of halfway point of segments in flowchart
* @param loopbackVerticalLength {number} height of vertical segment when looping in flowchart
* @param cornerRadius {number} radius of flowchart connectors
* @param loopbackMinimum {number} minimum threshold before looping behavior takes effect in flowchart
* @param targetGap {number} gap between connector and target endpoint in both flowchart and bezier
*/
const N8nCustom = function (params) {
params = params || {};
this.type = "N8nCustom";
params.stub = params.stub == null ? 30 : params.stub;
var _super = _jp.Connectors.AbstractConnector.apply(this, arguments),
minorAnchor = 0, // seems to be angle at which connector leaves endpoint
majorAnchor = 0, // translates to curviness of bezier curve
segments,
midpoint = params.midpoint == null ? 0.5 : params.midpoint,
alwaysRespectStubs = params.alwaysRespectStubs === true,
loopbackVerticalLength = params.loopbackVerticalLength || 0,
lastx = null, lasty = null,
cornerRadius = params.cornerRadius != null ? params.cornerRadius : 0,
loopbackMinimum = params.loopbackMinimum || 100,
curvinessCoeffient = 0.4,
zBezierOffset = 40,
targetGap = params.targetGap || 0,
stub = params.stub || 0;
/**
* Set target endpoint
* (to override default behavior tracking mouse when dragging mouse)
* @param {Endpoint} endpoint
*/
this.setTargetEndpoint = function (endpoint) {
this.overrideTargetEndpoint = endpoint;
};
/**
* reset target endpoint overriding default behavior
*/
this.resetTargetEndpoint = function () {
this.overrideTargetEndpoint = null;
};
this._compute = function (originalPaintInfo, connParams) {
const paintInfo = _getPaintInfo(connParams, { targetGap, stub, overrideTargetEndpoint: this.overrideTargetEndpoint, getEndpointOffset: params.getEndpointOffset });
Object.keys(paintInfo).forEach((key) => {
// override so that bounding box is calculated correctly wheen target override is set
originalPaintInfo[key] = paintInfo[key];
});
if (paintInfo.tx < 0) {
this._computeFlowchart(paintInfo);
}
else {
this._computeBezier(paintInfo);
}
};
this._computeBezier = function (paintInfo) {
var sp = paintInfo.sourcePos,
tp = paintInfo.targetPos,
_w = Math.abs(sp[0] - tp[0]) - paintInfo.targetGap,
_h = Math.abs(sp[1] - tp[1]);
var _CP, _CP2,
_sx = sp[0] < tp[0] ? _w : 0,
_sy = sp[1] < tp[1] ? _h : 0,
_tx = sp[0] < tp[0] ? 0 : _w,
_ty = sp[1] < tp[1] ? 0 : _h;
if (paintInfo.ySpan <= 20 || (paintInfo.ySpan <= 100 && paintInfo.xSpan <= 100)) {
majorAnchor = 0.1;
}
else {
majorAnchor = paintInfo.xSpan * curvinessCoeffient + zBezierOffset;
}
_CP = _findControlPoint([_sx, _sy], sp, tp, paintInfo.sourceEndpoint, paintInfo.targetEndpoint, paintInfo.so, paintInfo.to, majorAnchor, minorAnchor);
_CP2 = _findControlPoint([_tx, _ty], tp, sp, paintInfo.targetEndpoint, paintInfo.sourceEndpoint, paintInfo.to, paintInfo.so, majorAnchor, minorAnchor);
_super.addSegment(this, "Bezier", {
x1: _sx, y1: _sy, x2: _tx, y2: _ty,
cp1x: _CP[0], cp1y: _CP[1], cp2x: _CP2[0], cp2y: _CP2[1],
});
};
/**
* helper method to add a segment.
*/
const addFlowchartSegment = function (segments, x, y, paintInfo) {
if (lastx === x && lasty === y) {
return;
}
var lx = lastx == null ? paintInfo.sx : lastx,
ly = lasty == null ? paintInfo.sy : lasty,
o = lx === x ? "v" : "h";
lastx = x;
lasty = y;
segments.push([ lx, ly, x, y, o ]);
};
this._computeFlowchart = function (paintInfo) {
segments = [];
lastx = null;
lasty = null;
// calculate Stubs.
var stubs = calcualteStubSegment(paintInfo, {alwaysRespectStubs});
// add the start stub segment. use stubs for loopback as it will look better, with the loop spaced
// away from the element.
addFlowchartSegment(segments, stubs[0], stubs[1], paintInfo);
// compute the rest of the line
var p = calculateLineSegment(paintInfo, stubs, {midpoint, loopbackMinimum, loopbackVerticalLength});
if (p) {
for (var i = 0; i < p.length; i++) {
addFlowchartSegment(segments, p[i][0], p[i][1], paintInfo);
}
}
// line to end stub
addFlowchartSegment(segments, stubs[2], stubs[3], paintInfo);
// end stub to end (common)
addFlowchartSegment(segments, paintInfo.tx, paintInfo.ty, paintInfo);
// write out the segments.
writeFlowchartSegments(_super, this, segments, paintInfo, cornerRadius);
};
};
_jp.Connectors.N8nCustom = N8nCustom;
_ju.extend(_jp.Connectors.N8nCustom, _jp.Connectors.AbstractConnector);
function _findControlPoint(point, sourceAnchorPosition, targetAnchorPosition, sourceEndpoint, targetEndpoint, soo, too, majorAnchor, minorAnchor) {
// determine if the two anchors are perpendicular to each other in their orientation. we swap the control
// points around if so (code could be tightened up)
var perpendicular = soo[0] !== too[0] || soo[1] === too[1],
p = [];
if (!perpendicular) {
if (soo[0] === 0) {
p.push(sourceAnchorPosition[0] < targetAnchorPosition[0] ? point[0] + minorAnchor : point[0] - minorAnchor);
}
else {
p.push(point[0] - (majorAnchor * soo[0]));
}
if (soo[1] === 0) {
p.push(sourceAnchorPosition[1] < targetAnchorPosition[1] ? point[1] + minorAnchor : point[1] - minorAnchor);
}
else {
p.push(point[1] + (majorAnchor * too[1]));
}
}
else {
if (too[0] === 0) {
p.push(targetAnchorPosition[0] < sourceAnchorPosition[0] ? point[0] + minorAnchor : point[0] - minorAnchor);
}
else {
p.push(point[0] + (majorAnchor * too[0]));
}
if (too[1] === 0) {
p.push(targetAnchorPosition[1] < sourceAnchorPosition[1] ? point[1] + minorAnchor : point[1] - minorAnchor);
}
else {
p.push(point[1] + (majorAnchor * soo[1]));
}
}
return p;
};
function sgn(n) {
return n < 0 ? -1 : n === 0 ? 0 : 1;
};
function getFlowchartSegmentDirections(segment) {
return [
sgn( segment[2] - segment[0] ),
sgn( segment[3] - segment[1] ),
];
};
function getSegmentLength(s) {
return Math.sqrt(Math.pow(s[0] - s[2], 2) + Math.pow(s[1] - s[3], 2));
};
function _cloneArray(a) {
var _a = [];
_a.push.apply(_a, a);
return _a;
};
function writeFlowchartSegments(_super, conn, segments, paintInfo, cornerRadius) {
var current = null, next, currentDirection, nextDirection;
for (var i = 0; i < segments.length - 1; i++) {
current = current || _cloneArray(segments[i]);
next = _cloneArray(segments[i + 1]);
currentDirection = getFlowchartSegmentDirections(current);
nextDirection = getFlowchartSegmentDirections(next);
if (cornerRadius > 0 && current[4] !== next[4]) {
var minSegLength = Math.min(getSegmentLength(current), getSegmentLength(next));
var radiusToUse = Math.min(cornerRadius, minSegLength / 2);
current[2] -= currentDirection[0] * radiusToUse;
current[3] -= currentDirection[1] * radiusToUse;
next[0] += nextDirection[0] * radiusToUse;
next[1] += nextDirection[1] * radiusToUse;
var ac = (currentDirection[1] === nextDirection[0] && nextDirection[0] === 1) ||
((currentDirection[1] === nextDirection[0] && nextDirection[0] === 0) && currentDirection[0] !== nextDirection[1]) ||
(currentDirection[1] === nextDirection[0] && nextDirection[0] === -1),
sgny = next[1] > current[3] ? 1 : -1,
sgnx = next[0] > current[2] ? 1 : -1,
sgnEqual = sgny === sgnx,
cx = (sgnEqual && ac || (!sgnEqual && !ac)) ? next[0] : current[2],
cy = (sgnEqual && ac || (!sgnEqual && !ac)) ? current[3] : next[1];
_super.addSegment(conn, STRAIGHT, {
x1: current[0], y1: current[1], x2: current[2], y2: current[3],
});
_super.addSegment(conn, ARC, {
r: radiusToUse,
x1: current[2],
y1: current[3],
x2: next[0],
y2: next[1],
cx: cx,
cy: cy,
ac: ac,
});
}
else {
// dx + dy are used to adjust for line width.
var dx = (current[2] === current[0]) ? 0 : (current[2] > current[0]) ? (paintInfo.lw / 2) : -(paintInfo.lw / 2),
dy = (current[3] === current[1]) ? 0 : (current[3] > current[1]) ? (paintInfo.lw / 2) : -(paintInfo.lw / 2);
_super.addSegment(conn, STRAIGHT, {
x1: current[0] - dx, y1: current[1] - dy, x2: current[2] + dx, y2: current[3] + dy,
});
}
current = next;
}
if (next != null) {
// last segment
_super.addSegment(conn, STRAIGHT, {
x1: next[0], y1: next[1], x2: next[2], y2: next[3],
});
}
};
const lineCalculators = {
opposite: function (paintInfo, {axis, startStub, endStub, idx, midx, midy}) {
var pi = paintInfo,
comparator = pi["is" + axis.toUpperCase() + "GreaterThanStubTimes2"];
if (!comparator || (pi.so[idx] === 1 && startStub > endStub) || (pi.so[idx] === -1 && startStub < endStub)) {
return {
"x": [
[startStub, midy],
[endStub, midy],
],
"y": [
[midx, startStub],
[midx, endStub],
],
}[axis];
}
else if ((pi.so[idx] === 1 && startStub < endStub) || (pi.so[idx] === -1 && startStub > endStub)) {
return {
"x": [
[midx, pi.sy],
[midx, pi.ty],
],
"y": [
[pi.sx, midy],
[pi.tx, midy],
],
}[axis];
}
},
};
const stubCalculators = {
opposite: function (paintInfo, {axis, alwaysRespectStubs}) {
var pi = paintInfo,
idx = axis === "x" ? 0 : 1,
areInProximity = {
"x": function () {
return ( (pi.so[idx] === 1 && (
( (pi.startStubX > pi.endStubX) && (pi.tx > pi.startStubX) ) ||
( (pi.sx > pi.endStubX) && (pi.tx > pi.sx))))) ||
( (pi.so[idx] === -1 && (
( (pi.startStubX < pi.endStubX) && (pi.tx < pi.startStubX) ) ||
( (pi.sx < pi.endStubX) && (pi.tx < pi.sx)))));
},
"y": function () {
return ( (pi.so[idx] === 1 && (
( (pi.startStubY > pi.endStubY) && (pi.ty > pi.startStubY) ) ||
( (pi.sy > pi.endStubY) && (pi.ty > pi.sy))))) ||
( (pi.so[idx] === -1 && (
( (pi.startStubY < pi.endStubY) && (pi.ty < pi.startStubY) ) ||
( (pi.sy < pi.endStubY) && (pi.ty < pi.sy)))));
},
};
if (!alwaysRespectStubs && areInProximity[axis]()) {
return {
"x": [(paintInfo.sx + paintInfo.tx) / 2, paintInfo.startStubY, (paintInfo.sx + paintInfo.tx) / 2, paintInfo.endStubY],
"y": [paintInfo.startStubX, (paintInfo.sy + paintInfo.ty) / 2, paintInfo.endStubX, (paintInfo.sy + paintInfo.ty) / 2],
}[axis];
}
else {
return [paintInfo.startStubX, paintInfo.startStubY, paintInfo.endStubX, paintInfo.endStubY];
}
},
};
function calcualteStubSegment(paintInfo, {alwaysRespectStubs}) {
return stubCalculators['opposite'](paintInfo, {axis: paintInfo.sourceAxis, alwaysRespectStubs});
}
function calculateLineSegment(paintInfo, stubs, { midpoint, loopbackVerticalLength, loopbackMinimum }) {
const axis = paintInfo.sourceAxis,
idx = paintInfo.sourceAxis === "x" ? 0 : 1,
oidx = paintInfo.sourceAxis === "x" ? 1 : 0,
startStub = stubs[idx],
otherStartStub = stubs[oidx],
endStub = stubs[idx + 2],
otherEndStub = stubs[oidx + 2];
const diffX = paintInfo.endStubX - paintInfo.startStubX;
const diffY = paintInfo.endStubY - paintInfo.startStubY;
const direction = -1; // vertical direction of loop, always below source
var midx = paintInfo.startStubX + ((paintInfo.endStubX - paintInfo.startStubX) * midpoint),
midy;
if (diffY >= 0 || diffX < (-1 * loopbackMinimum)) {
// loop backward behavior
midy = paintInfo.startStubY - (diffX < 0 ? direction * loopbackVerticalLength : 0);
} else {
// original flowchart behavior
midy = paintInfo.startStubY + ((paintInfo.endStubY - paintInfo.startStubY) * midpoint);
}
return lineCalculators['opposite'](paintInfo, {axis, startStub, otherStartStub, endStub, otherEndStub, idx, oidx, midx, midy});
}
function _getPaintInfo(params, { targetGap, stub, overrideTargetEndpoint, getEndpointOffset }) {
let { targetPos, targetEndpoint } = params;
if (
overrideTargetEndpoint
) {
targetPos = overrideTargetEndpoint.anchor.getCurrentLocation();
targetEndpoint = overrideTargetEndpoint;
}
const sourceGap = 0;
stub = stub || 0;
const sourceStub = _ju.isArray(stub) ? stub[0] : stub;
const targetStub = _ju.isArray(stub) ? stub[1] : stub;
var segment = _jg.quadrant(params.sourcePos, targetPos),
swapX = targetPos[0] < params.sourcePos[0],
swapY = targetPos[1] < params.sourcePos[1],
lw = params.strokeWidth || 1,
so = params.sourceEndpoint.anchor.getOrientation(params.sourceEndpoint), // source orientation
to = targetEndpoint.anchor.getOrientation(targetEndpoint), // target orientation
x = swapX ? targetPos[0] : params.sourcePos[0],
y = swapY ? targetPos[1] : params.sourcePos[1],
w = Math.abs(targetPos[0] - params.sourcePos[0]),
h = Math.abs(targetPos[1] - params.sourcePos[1]);
// if either anchor does not have an orientation set, we derive one from their relative
// positions. we fix the axis to be the one in which the two elements are further apart, and
// point each anchor at the other element. this is also used when dragging a new connection.
if (so[0] === 0 && so[1] === 0 || to[0] === 0 && to[1] === 0) {
var index = w > h ? 0 : 1, oIndex = [1, 0][index];
so = [];
to = [];
so[index] = params.sourcePos[index] > targetPos[index] ? -1 : 1;
to[index] = params.sourcePos[index] > targetPos[index] ? 1 : -1;
so[oIndex] = 0;
to[oIndex] = 0;
}
const sx = swapX ? w + (sourceGap * so[0]) : sourceGap * so[0],
sy = swapY ? h + (sourceGap * so[1]) : sourceGap * so[1],
tx = swapX ? targetGap * to[0] : w + (targetGap * to[0]),
ty = swapY ? targetGap * to[1] : h + (targetGap * to[1]),
oProduct = ((so[0] * to[0]) + (so[1] * to[1]));
const sourceStubWithOffset = sourceStub + (getEndpointOffset && params.sourceEndpoint ? getEndpointOffset(params.sourceEndpoint) : 0);
const targetStubWithOffset = targetStub + (getEndpointOffset && targetEndpoint ? getEndpointOffset(targetEndpoint) : 0);
// same as paintinfo generated by jsplumb AbstractConnector type
var result = {
sx: sx, sy: sy, tx: tx, ty: ty, lw: lw,
xSpan: Math.abs(tx - sx),
ySpan: Math.abs(ty - sy),
mx: (sx + tx) / 2,
my: (sy + ty) / 2,
so: so, to: to, x: x, y: y, w: w, h: h,
segment: segment,
startStubX: sx + (so[0] * sourceStubWithOffset),
startStubY: sy + (so[1] * sourceStubWithOffset),
endStubX: tx + (to[0] * targetStubWithOffset),
endStubY: ty + (to[1] * targetStubWithOffset),
isXGreaterThanStubTimes2: Math.abs(sx - tx) > (sourceStubWithOffset + targetStubWithOffset),
isYGreaterThanStubTimes2: Math.abs(sy - ty) > (sourceStubWithOffset + targetStubWithOffset),
opposite: oProduct === -1,
perpendicular: oProduct === 0,
orthogonal: oProduct === 1,
sourceAxis: so[0] === 0 ? "y" : "x",
points: [x, y, w, h, sx, sy, tx, ty ],
stubs:[sourceStubWithOffset, targetStubWithOffset],
anchorOrientation: "opposite", // always opposite since our endpoints are always opposite (source orientation is left (1) and target orientaiton is right (-1))
/** custom keys added */
sourceEndpoint: params.sourceEndpoint,
targetEndpoint: targetEndpoint,
sourcePos: params.sourcePos,
targetPos: targetEndpoint.anchor.getCurrentLocation(),
targetGap,
};
return result;
};
}).call(typeof window !== 'undefined' ? window : this);

View File

@@ -0,0 +1,500 @@
/**
* Custom Plus Endpoint
* Based on jsplumb Blank Endpoint type
*
* Source GitHub repository:
* https://github.com/jsplumb/jsplumb
*
* Source files:
* https://github.com/jsplumb/jsplumb/blob/fb5fce52794fa52306825bdaa62bf3855cdfd7e0/src/defaults.js#L1230
*
* All 1.x.x and 2.x.x versions of jsPlumb Community edition, and so also the
* content of this file, are dual-licensed under both MIT and GPLv2.
*
* MIT LICENSE
*
* Copyright (c) 2010 - 2014 jsPlumb, http://jsplumbtoolkit.com/
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
* ===============================================================================
* GNU GENERAL PUBLIC LICENSE
* Version 2, June 1991
*
* Copyright (C) 1989, 1991 Free Software Foundation, Inc.
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
* Everyone is permitted to copy and distribute verbatim copies
* of this license document, but changing it is not allowed.
*
* Preamble
*
* The licenses for most software are designed to take away your
* freedom to share and change it. By contrast, the GNU General Public
* License is intended to guarantee your freedom to share and change free
* software--to make sure the software is free for all its users. This
* General Public License applies to most of the Free Software
* Foundation's software and to any other program whose authors commit to
* using it. (Some other Free Software Foundation software is covered by
* the GNU Lesser General Public License instead.) You can apply it to
* your programs, too.
*
* When we speak of free software, we are referring to freedom, not
* price. Our General Public Licenses are designed to make sure that you
* have the freedom to distribute copies of free software (and charge for
* this service if you wish), that you receive source code or can get it
* if you want it, that you can change the software or use pieces of it
* in new free programs; and that you know you can do these things.
*
* To protect your rights, we need to make restrictions that forbid
* anyone to deny you these rights or to ask you to surrender the rights.
* These restrictions translate to certain responsibilities for you if you
* distribute copies of the software, or if you modify it.
*
* For example, if you distribute copies of such a program, whether
* gratis or for a fee, you must give the recipients all the rights that
* you have. You must make sure that they, too, receive or can get the
* source code. And you must show them these terms so they know their
* rights.
*
* We protect your rights with two steps: (1) copyright the software, and
* (2) offer you this license which gives you legal permission to copy,
* distribute and/or modify the software.
*
* Also, for each author's protection and ours, we want to make certain
* that everyone understands that there is no warranty for this free
* software. If the software is modified by someone else and passed on, we
* want its recipients to know that what they have is not the original, so
* that any problems introduced by others will not reflect on the original
* authors' reputations.
*
* Finally, any free program is threatened constantly by software
* patents. We wish to avoid the danger that redistributors of a free
* program will individually obtain patent licenses, in effect making the
* program proprietary. To prevent this, we have made it clear that any
* patent must be licensed for everyone's free use or not licensed at all.
*
* The precise terms and conditions for copying, distribution and
* modification follow.
*
* GNU GENERAL PUBLIC LICENSE
* TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
*
* 0. This License applies to any program or other work which contains
* a notice placed by the copyright holder saying it may be distributed
* under the terms of this General Public License. The "Program", below,
* refers to any such program or work, and a "work based on the Program"
* means either the Program or any derivative work under copyright law:
* that is to say, a work containing the Program or a portion of it,
* either verbatim or with modifications and/or translated into another
* language. (Hereinafter, translation is included without limitation in
* the term "modification".) Each licensee is addressed as "you".
*
* Activities other than copying, distribution and modification are not
* covered by this License; they are outside its scope. The act of
* running the Program is not restricted, and the output from the Program
* is covered only if its contents constitute a work based on the
* Program (independent of having been made by running the Program).
* Whether that is true depends on what the Program does.
*
* 1. You may copy and distribute verbatim copies of the Program's
* source code as you receive it, in any medium, provided that you
* conspicuously and appropriately publish on each copy an appropriate
* copyright notice and disclaimer of warranty; keep intact all the
* notices that refer to this License and to the absence of any warranty;
* and give any other recipients of the Program a copy of this License
* along with the Program.
*
* You may charge a fee for the physical act of transferring a copy, and
* you may at your option offer warranty protection in exchange for a fee.
*
* 2. You may modify your copy or copies of the Program or any portion
* of it, thus forming a work based on the Program, and copy and
* distribute such modifications or work under the terms of Section 1
* above, provided that you also meet all of these conditions:
*
* a) You must cause the modified files to carry prominent notices
* stating that you changed the files and the date of any change.
*
* b) You must cause any work that you distribute or publish, that in
* whole or in part contains or is derived from the Program or any
* part thereof, to be licensed as a whole at no charge to all third
* parties under the terms of this License.
*
* c) If the modified program normally reads commands interactively
* when run, you must cause it, when started running for such
* interactive use in the most ordinary way, to print or display an
* announcement including an appropriate copyright notice and a
* notice that there is no warranty (or else, saying that you provide
* a warranty) and that users may redistribute the program under
* these conditions, and telling the user how to view a copy of this
* License. (Exception: if the Program itself is interactive but
* does not normally print such an announcement, your work based on
* the Program is not required to print an announcement.)
*
* These requirements apply to the modified work as a whole. If
* identifiable sections of that work are not derived from the Program,
* and can be reasonably considered independent and separate works in
* themselves, then this License, and its terms, do not apply to those
* sections when you distribute them as separate works. But when you
* distribute the same sections as part of a whole which is a work based
* on the Program, the distribution of the whole must be on the terms of
* this License, whose permissions for other licensees extend to the
* entire whole, and thus to each and every part regardless of who wrote it.
*
* Thus, it is not the intent of this section to claim rights or contest
* your rights to work written entirely by you; rather, the intent is to
* exercise the right to control the distribution of derivative or
* collective works based on the Program.
*
* In addition, mere aggregation of another work not based on the Program
* with the Program (or with a work based on the Program) on a volume of
* a storage or distribution medium does not bring the other work under
* the scope of this License.
*
* 3. You may copy and distribute the Program (or a work based on it,
* under Section 2) in object code or executable form under the terms of
* Sections 1 and 2 above provided that you also do one of the following:
*
* a) Accompany it with the complete corresponding machine-readable
* source code, which must be distributed under the terms of Sections
* 1 and 2 above on a medium customarily used for software interchange; or,
*
* b) Accompany it with a written offer, valid for at least three
* years, to give any third party, for a charge no more than your
* cost of physically performing source distribution, a complete
* machine-readable copy of the corresponding source code, to be
* distributed under the terms of Sections 1 and 2 above on a medium
* customarily used for software interchange; or,
*
* c) Accompany it with the information you received as to the offer
* to distribute corresponding source code. (This alternative is
* allowed only for noncommercial distribution and only if you
* received the program in object code or executable form with such
* an offer, in accord with Subsection b above.)
*
* The source code for a work means the preferred form of the work for
* making modifications to it. For an executable work, complete source
* code means all the source code for all modules it contains, plus any
* associated interface definition files, plus the scripts used to
* control compilation and installation of the executable. However, as a
* special exception, the source code distributed need not include
* anything that is normally distributed (in either source or binary
* form) with the major components (compiler, kernel, and so on) of the
* operating system on which the executable runs, unless that component
* itself accompanies the executable.
*
* If distribution of executable or object code is made by offering
* access to copy from a designated place, then offering equivalent
* access to copy the source code from the same place counts as
* distribution of the source code, even though third parties are not
* compelled to copy the source along with the object code.
*
* 4. You may not copy, modify, sublicense, or distribute the Program
* except as expressly provided under this License. Any attempt
* otherwise to copy, modify, sublicense or distribute the Program is
* void, and will automatically terminate your rights under this License.
* However, parties who have received copies, or rights, from you under
* this License will not have their licenses terminated so long as such
* parties remain in full compliance.
*
* 5. You are not required to accept this License, since you have not
* signed it. However, nothing else grants you permission to modify or
* distribute the Program or its derivative works. These actions are
* prohibited by law if you do not accept this License. Therefore, by
* modifying or distributing the Program (or any work based on the
* Program), you indicate your acceptance of this License to do so, and
* all its terms and conditions for copying, distributing or modifying
* the Program or works based on it.
*
* 6. Each time you redistribute the Program (or any work based on the
* Program), the recipient automatically receives a license from the
* original licensor to copy, distribute or modify the Program subject to
* these terms and conditions. You may not impose any further
* restrictions on the recipients' exercise of the rights granted herein.
* You are not responsible for enforcing compliance by third parties to
* this License.
*
* 7. If, as a consequence of a court judgment or allegation of patent
* infringement or for any other reason (not limited to patent issues),
* conditions are imposed on you (whether by court order, agreement or
* otherwise) that contradict the conditions of this License, they do not
* excuse you from the conditions of this License. If you cannot
* distribute so as to satisfy simultaneously your obligations under this
* License and any other pertinent obligations, then as a consequence you
* may not distribute the Program at all. For example, if a patent
* license would not permit royalty-free redistribution of the Program by
* all those who receive copies directly or indirectly through you, then
* the only way you could satisfy both it and this License would be to
* refrain entirely from distribution of the Program.
*
* If any portion of this section is held invalid or unenforceable under
* any particular circumstance, the balance of the section is intended to
* apply and the section as a whole is intended to apply in other
* circumstances.
*
* It is not the purpose of this section to induce you to infringe any
* patents or other property right claims or to contest validity of any
* such claims; this section has the sole purpose of protecting the
* integrity of the free software distribution system, which is
* implemented by public license practices. Many people have made
* generous contributions to the wide range of software distributed
* through that system in reliance on consistent application of that
* system; it is up to the author/donor to decide if he or she is willing
* to distribute software through any other system and a licensee cannot
* impose that choice.
*
* This section is intended to make thoroughly clear what is believed to
* be a consequence of the rest of this License.
*
* 8. If the distribution and/or use of the Program is restricted in
* certain countries either by patents or by copyrighted interfaces, the
* original copyright holder who places the Program under this License
* may add an explicit geographical distribution limitation excluding
* those countries, so that distribution is permitted only in or among
* countries not thus excluded. In such case, this License incorporates
* the limitation as if written in the body of this License.
*
* 9. The Free Software Foundation may publish revised and/or new versions
* of the General Public License from time to time. Such new versions will
* be similar in spirit to the present version, but may differ in detail to
* address new problems or concerns.
*
* Each version is given a distinguishing version number. If the Program
* specifies a version number of this License which applies to it and "any
* later version", you have the option of following the terms and conditions
* either of that version or of any later version published by the Free
* Software Foundation. If the Program does not specify a version number of
* this License, you may choose any version ever published by the Free Software
* Foundation.
*
* 10. If you wish to incorporate parts of the Program into other free
* programs whose distribution conditions are different, write to the author
* to ask for permission. For software which is copyrighted by the Free
* Software Foundation, write to the Free Software Foundation; we sometimes
* make exceptions for this. Our decision will be guided by the two goals
* of preserving the free status of all derivatives of our free software and
* of promoting the sharing and reuse of software generally.
*
* NO WARRANTY
*
* 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
* FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
* OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
* PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
* OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
* TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
* PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
* REPAIR OR CORRECTION.
*
* 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
* WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
* REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
* INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
* OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
* TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
* YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
* PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGES.
*
*/
(function () {
var root = this, _jp = root.jsPlumb, _ju = root.jsPlumbUtil;
var DOMElementEndpoint = function (params) {
_jp.jsPlumbUIComponent.apply(this, arguments);
this._jsPlumb.displayElements = [];
};
_ju.extend(DOMElementEndpoint, _jp.jsPlumbUIComponent, {
getDisplayElements: function () {
return this._jsPlumb.displayElements;
},
appendDisplayElement: function (el) {
this._jsPlumb.displayElements.push(el);
},
});
/*
* Class: Endpoints.N8nPlus
*/
_jp.Endpoints.N8nPlus = function (params) {
const _super = _jp.Endpoints.AbstractEndpoint.apply(this, arguments);
this.type = "N8nPlus";
this.label = '';
this.labelOffset = 0;
this.size = 'medium';
this.showOutputLabel = true;
const boxSize = {
medium: 24,
small: 18,
};
const stalkLength = 40;
DOMElementEndpoint.apply(this, arguments);
var clazz = params.cssClass ? " " + params.cssClass : "";
this.canvas = _jp.createElement("div", {
display: "block",
background: "transparent",
position: "absolute",
}, this._jsPlumb.instance.endpointClass + clazz + ' plus-endpoint');
this.canvas.innerHTML = `
<div class="plus-stalk">
<div class="connection-run-items-label">
<span class="floating"></span>
</div>
</div>
<div class="plus-container">
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="plus" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" class="svg-inline--fa fa-plus">
<path fill="currentColor" d="M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z" class=""></path>
</svg>
<div class="drop-hover-message">
Click to add node</br>
or drag to connect
</div>
</div>
`;
this.canvas.addEventListener('click', (e) => {
this._jsPlumb.instance.fire('plusEndpointClick', params.endpoint, e);
});
this._jsPlumb.instance.appendElement(this.canvas);
const container = this.canvas.querySelector('.plus-container');
const message = container.querySelector('.drop-hover-message');
const plusStalk = this.canvas.querySelector('.plus-stalk');
const successOutput = this.canvas.querySelector('.plus-stalk span');
this.setSuccessOutput = (label) => {
this.canvas.classList.add('success');
if (this.showOutputLabel) {
successOutput.textContent = label;
this.label = label;
this.labelOffset = successOutput.offsetWidth;
plusStalk.style.width = `${stalkLength + this.labelOffset}px`;
if (this._jsPlumb && this._jsPlumb.instance && !this._jsPlumb.instance.isSuspendDrawing()) {
params.endpoint.repaint(); // force rerender to move plus hoverable/draggable space
}
}
};
this.clearSuccessOutput = () => {
this.canvas.classList.remove('success');
successOutput.textContent = '';
this.label = '';
this.labelOffset = 0;
plusStalk.style.width = `${stalkLength}px`;
params.endpoint.repaint();
};
const isDragging = () => {
const endpoint = params.endpoint;
const plusConnections = endpoint.connections;
if (plusConnections.length) {
return !!plusConnections.find((conn) => conn && conn.targetId && conn.targetId.startsWith('jsPlumb'));
}
return false;
};
const hasEndpointConnections = () => {
const endpoint = params.endpoint;
const plusConnections = endpoint.connections;
if (plusConnections.length >= 1) {
return true;
}
const allConnections = this._jsPlumb.instance.getConnections({
source: endpoint.elementId,
}); // includes connections from other output endpoints like dot
return !!allConnections.find((connection) => {
if (!connection || !connection.endpoints || !connection.endpoints.length || !connection.endpoints[0]) {
return false;
}
const sourceEndpoint = connection.endpoints[0];
return sourceEndpoint === endpoint || sourceEndpoint.getUuid() === endpoint.getUuid();
});
};
this.paint = function (style, anchor) {
if (hasEndpointConnections()) {
this.canvas.classList.add('hidden');
}
else {
this.canvas.classList.remove('hidden');
container.style.color = style.fill;
container.style['border-color'] = style.fill;
message.style.display = style.hover ? 'inline' : 'none';
}
_ju.sizeElement(this.canvas, this.x, this.y, this.w, this.h);
};
this._compute = (anchorPoint, orientation, endpointStyle, connectorPaintStyle) => {
this.size = endpointStyle.size || this.size;
this.showOutputLabel = !!endpointStyle.showOutputLabel;
if (this.hoverMessage !== endpointStyle.hoverMessage) {
this.hoverMessage = endpointStyle.hoverMessage;
message.innerHTML = endpointStyle.hoverMessage;
}
if (this.size !== 'medium') {
container.classList.add(this.size);
}
setTimeout(() => {
if (this.label && !this.labelOffset) { // if label is hidden, offset is 0 so recalculate
this.setSuccessOutput(this.label);
}
}, 0);
const defaultPosition = [anchorPoint[0] + stalkLength + this.labelOffset, anchorPoint[1] - boxSize[this.size] / 2, boxSize[this.size], boxSize[this.size]];
if (isDragging()) {
return defaultPosition;
}
if (hasEndpointConnections()) {
return [0, 0, 0, 0]; // remove hoverable box from view
}
return defaultPosition;
};
};
_ju.extend(_jp.Endpoints.N8nPlus, [_jp.Endpoints.AbstractEndpoint, DOMElementEndpoint], {
cleanup: function () {
if (this.canvas && this.canvas.parentNode) {
this.canvas.parentNode.removeChild(this.canvas);
}
},
});
_jp.Endpoints.svg.N8nPlus = _jp.Endpoints.N8nPlus;
})();

View File

@@ -31,7 +31,6 @@ import Col from 'element-ui/lib/col';
import Badge from 'element-ui/lib/badge';
import Card from 'element-ui/lib/card';
import ColorPicker from 'element-ui/lib/color-picker';
import Transfer from 'element-ui/lib/transfer';
import Container from 'element-ui/lib/container';
import Loading from 'element-ui/lib/loading';
import MessageBox from 'element-ui/lib/message-box';
@@ -56,6 +55,7 @@ import {
N8nMenuItem,
N8nSelect,
N8nSpinner,
N8nSquareButton,
N8nText,
N8nTooltip,
N8nOption,
@@ -76,6 +76,7 @@ Vue.use(N8nMenu);
Vue.use(N8nMenuItem);
Vue.use(N8nSelect);
Vue.use(N8nSpinner);
Vue.component('n8n-square-button', N8nSquareButton);
Vue.component('n8n-text', N8nText);
Vue.use(N8nTooltip);
Vue.use(N8nOption);
@@ -109,7 +110,6 @@ Vue.use(Col);
Vue.use(Badge);
Vue.use(Card);
Vue.use(ColorPicker);
Vue.use(Transfer);
Vue.use(Container);
Vue.component(CollapseTransition.name, CollapseTransition);

View File

@@ -0,0 +1,596 @@
# i18n in n8n
## Scope
n8n allows for internalization of the majority of UI text:
- base text, e.g. menu display items in the left-hand sidebar menu,
- node text, e.g. parameter display names and placeholders in the node view,
- header text, e.g. node display names and descriptions in the nodes panel.
Currently, n8n does _not_ allow for internalization of:
- messages from outside the `editor-ui` package, e.g. `No active database connection`,
- node subtitles, e.g. `create: user` or `getAll: post` below the node name on the canvas,
- new version notification contents in the updates panel, e.g. `Includes node enhancements`.
## Locale identifiers
A locale identifier is a language code compatible with the [`Accept-Language` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language), e.g. `de` (German), `es` (Spanish), `ja` (Japanese). Regional variants of locale identifiers are not supported, i.e. use `de`, not `de-AT`. For a list of all locale identifiers, refer to the [639-1 column in the ISO 639-1 codes article](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes).
By default, n8n runs in the `en` (English) locale. To have it run in a different locale, set the `N8N_DEFAULT_LOCALE` environment variable. If it has been set and is not `en`, n8n will use the UI strings for that locale - for any untranslated UI strings, n8n will automatically fall back to `en`.
```sh
export N8N_DEFAULT_LOCALE=de
npm run start
```
## Base text
Base text is directly rendered with no dependencies. Base text is supplied by the user in one file per locale in the `/editor-ui` package.
### Locating base text
Each base text file is located at `/packages/editor-ui/src/plugins/i18n/locales/{localeIdentifier}.json` and exports an object where keys are Vue component names (and their containing dirs if any) and references to parts of those Vue components.
```json
"nodeCreator": {
"categoryNames": {
"analytics": "🇩🇪 Analytics",
"communication": "🇩🇪 Communication",
"coreNodes": "🇩🇪 Core Nodes",
```
### Translating base text
1. For the new locale identifier, e.g. `de`, copy the `en` base text and rename it:
```sh
cp ./packages/editor-ui/src/plugins/i18n/locales/en.json ./packages/editor-ui/src/plugins/i18n/locales/de.json
```
2. Check in the UI for a base text string to translate, and find it in the newly created base text file.
> **Note**: If you cannot find a string in the new base text file, either it does not belong to base text (i.e., the string might be part of header text, credential text, or node text), or the string might belong to the backend, where i18n is currently unsupported.
3. Translate the string value - do not change the key. In the examples below, a string starting with 🇩🇪 stands for a translated string.
Optionally, remove any untranslated strings from the new base text file. Untranslated strings in the new base text file will automatically fall back to the `en` base text file.
#### Reusable base text
As a convenience, the base text file may contain the special key `reusableBaseText` to share strings between translations. For more information, refer to Vue i18n's [linked locale messages](https://kazupon.github.io/vue-i18n/guide/messages.html#linked-locale-messages).
```json
{
"reusableBaseText": {
"save": "🇩🇪 Save",
},
"duplicateWorkflowDialog": {
"enterWorkflowName": "🇩🇪 Enter workflow name",
"save": "@:reusableBaseText.save",
},
"saveButton": {
"save": "@:reusableBaseText.save",
"saving": "🇩🇪 Saving",
"saved": "🇩🇪 Saved",
},
```
<!--
As a convenience, the base text file may also contain the special key `numberFormats` to localize numbers. For more information, refer to Vue i18n's [number localization](https://kazupon.github.io/vue-i18n/guide/number.html#number-localization).
```json
{
"numberFormats": {
"decimal": {
"style": "decimal",
},
},
}
``` -->
#### Interpolation
Some base text strings use [interpolation](https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting) with a variable in curly braces, e.g. `Execution ID {activeExecutionId} was stopped`. In case of interpolation, the translated string must not modify the variable: `Die Ausführung mit der ID {activeExecutionId} wurde gestoppt`.
## Dynamic text
Dynamic text is **text that relies on node-related data** in order to be rendered. Node-related data is supplied by the user in multiple files in the `/nodes-base` package. Dynamic text is mostly visible in the node view, i.e. the node on the canvas and the node parameters modal.
### Locating dynamic text
Dynamic text is divided into files located in `/translations` dirs alongside the translated nodes:
```
GitHub
├── GitHub.node.ts
├── GitHubTrigger.node.ts
└── translations
├── de.json
├── es.json
└── ja.json
```
Each node translation file may contain the translations for one or both (regular and trigger) nodes.
For nodes in grouping dirs, e.g. `Google`, `Aws`, and `Microsoft`, locate the `/translations` dir alongside the `*.node.ts` file:
```
Google
└── Drive
├── GoogleDrive.node.ts
└── translations
├── de.json
├── es.json
└── ja.json
```
For nodes in versioned dirs, locate the `/translations` dir alongside the versioned `*.node.ts` file:
```
Mattermost
└── Mattermost.node.ts
└── v1
├── MattermostV1.node.ts
├── actions
├── methods
├── transport
└── translations
├── de.json
├── es.json
└── ja.json
```
### Translating dynamic text
> **Note**: In the examples below, the node source is located at `/packages/nodes-base/nodes/Github/GitHub.node.ts` and the node translation is located at `/packages/nodes-base/nodes/Github/translations/de.json`.
Each node translation is an object with a key that matches the node's `description.name`:
```ts
export class Github implements INodeType {
description: INodeTypeDescription = {
displayName: 'GitHub',
description: 'Consume GitHub API',
name: 'github', // key to use in translation
icon: 'file:github.svg',
group: ['input'],
version: 1,
```
```json
{
"github": {}, // key from node's description.name
"githubTrigger": {}, // key from node's description.name
}
```
The object inside allows for three keys: `header`, `credentialsModal` and `nodeView`. These are the _sections_ of each node translation:
```json
{
"github": {
"header": {},
"credentialsModal": {},
"nodeView": {},
},
"githubTrigger": {
"header": {},
"credentialsModal": {},
"nodeView": {},
},
}
```
> **Note**: These three keys as well as all keys described below are optional. Remember that, in case of missing sections or missing translations, n8n will fall back to the `en` locale.
#### `header` section
The `header` section points to an object that may contain only two keys, `displayName` and `description`, matching the node's `description.displayName` and `description.description`. These are used in the nodes panel, in the node view and in the node credentials modal.
```ts
export class Github implements INodeType {
description: INodeTypeDescription = {
displayName: 'GitHub', // key to use in translation
description: 'Consume GitHub API', // key to use in translation
name: 'github',
icon: 'file:github.svg',
group: ['input'],
version: 1,
```
```json
{
"github": {
"header": {
"displayName": "🇩🇪 GitHub",
"description": "🇩🇪 Consume GitHub API",
},
},
}
```
Header text is used wherever the node's display name and description are needed:
<p align="center">
<img src="img/header1.png" width="400">
<img src="img/header2.png" width="200">
<img src="img/header3.png" width="400">
</p>
<p align="center">
<img src="img/header4.png" width="400">
<img src="img/header5.png" width="500">
</p>
#### `credentialsModal` section
> **Note**: In the examples below, the node credential source is located at `/packages/nodes-base/credentials/GithubApi.credentials.ts`.
The `credentialsModal` section points to an object containing a key that matches the node credential `name`.
```ts
export class GithubApi implements ICredentialType {
name = 'githubApi'; // key to use in translation
displayName = 'Github API';
documentationUrl = 'github';
properties: INodeProperties[] = [
```
```json
{
"github": {
"header": {},
"credentialsModal": {
"githubApi": {} // key from node credential name
},
"nodeView": {},
},
}
```
The node credential `name` key points to an object containing translation keys that match the node's credential parameter names:
```ts
export class GithubApi implements ICredentialType {
name = 'githubApi';
displayName = 'Github API';
documentationUrl = 'github';
properties: INodeProperties[] = [
{
displayName: 'Github Server',
name: 'server', // key to use in translation
type: 'string',
default: 'https://api.github.com',
description: 'The server to connect to. Only has to be set if Github Enterprise is used.',
},
{
displayName: 'User',
name: 'user', // key to use in translation
type: 'string',
default: '',
},
{
displayName: 'Access Token',
name: 'accessToken', // key to use in translation
type: 'string',
default: '',
},
];
}
```
```json
{
"github": {
"header": {},
"credentialsModal": {
"githubApi": {
"server": {} // key from node credential parameter name
"user": {} // key from node credential parameter name
"accessToken": {} // key from node credential parameter name
},
},
"nodeView": {},
},
}
```
The object for each node credential parameter allows for the keys `displayName`, `description`, and `placeholder`.
```json
{
"github": {
"header": {},
"credentialsModal": {
"githubApi": {
"server": {
"displayName": "🇩🇪 Github Server",
"description": "🇩🇪 The server to connect to. Only has to be set if Github Enterprise is used.",
},
"user": {
"placeholder": "🇩🇪 Hans",
},
"accessToken": {
"placeholder": "🇩🇪 123",
},
},
},
"nodeView": {},
},
}
```
<p align="center">
<img src="img/cred.png">
</p>
#### `nodeView` section
The `nodeView` section points to an object containing translation keys that match the node's operational parameters.
```ts
export class Github implements INodeType {
description: INodeTypeDescription = {
displayName: 'GitHub',
name: 'github',
properties: [
{
displayName: 'Resource',
name: 'resource', // key to use in translation
type: 'options',
options: [],
default: 'issue',
description: 'The resource to operate on.',
},
```
```json
{
"github": {
"header": {},
"credentialsModal": {},
"nodeView": {
"resource": {}, // key from node parameter name
},
},
}
```
> **Note**: Other than in the `*.node.ts` file, operational parameters may also be found in `*Description.ts` files in the same dir, e.g. `UserDescription.ts`.
A node parameter allows for different translation keys depending on parameter type.
#### `string`, `number` and `boolean` parameters
Allowed keys: `displayName`, `description`, and `placeholder`.
```ts
{
displayName: 'Repository Owner',
name: 'owner', // key to use in translation
type: 'string',
required: true,
placeholder: 'n8n-io',
description: 'Owner of the repository.',
},
```
```json
{
"github": {
"header": {},
"credentialsModal": {},
"nodeView": {
"owner": { // key from node parameter name
"displayName": "🇩🇪 Repository Owner",
"placeholder": "🇩🇪 n8n-io",
"description": "🇩🇪 Owner of the repository.",
},
},
},
}
```
<p align="center">
<img src="img/node1.png" width="400">
</p>
#### `options` parameter
Allowed keys: `displayName`, `description`, and `placeholder`.
Allowed subkeys: `options.{optionName}.displayName` and `options.{optionName}.description`.
```ts
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{
name: 'File',
value: 'file', // key to use in translation
},
{
name: 'Issue',
value: 'issue', // key to use in translation
},
],
default: 'issue',
description: 'The resource to operate on.',
},
```
```json
{
"github": {
"header": {},
"credentialsModal": {},
"nodeView": {
"resource": {
"displayName": "🇩🇪 Resource",
"description": "🇩🇪 The resource to operate on.",
"options": {
"file": { // key from node parameter options name
"displayName": "🇩🇪 File",
},
"issue": { // key from node parameter options name
"displayName": "🇩🇪 Issue",
},
},
},
},
},
}
```
<p align="center">
<img src="img/node2.png" width="400">
</p>
#### `collection` and `fixedCollection` parameters
Allowed keys: `displayName`, `description`, `placeholder`, and `multipleValueButtonText`.
```ts
{
displayName: 'Labels',
name: 'labels', // key to use in translation
type: 'collection',
typeOptions: {
multipleValues: true,
multipleValueButtonText: 'Add Label',
},
displayOptions: {
show: {
operation: [
'create',
],
resource: [
'issue',
],
},
},
default: { 'label': '' },
options: [
{
displayName: 'Label',
name: 'label', // key to use in translation
type: 'string',
default: '',
description: 'Label to add to issue.',
},
],
},
```
To reduce nesting and to share translations, a parameter inside a collection's or fixed collection's `options` parameter sits at the same level of nesting as the containing collection in the `nodeView` section:
```json
{
"github": {
"header": {},
"credentialsModal": {},
"nodeView": {
// collection
"labels": {
"displayName": "🇩🇪 Labels",
"multipleValueButtonText": "🇩🇪 Add Label",
},
// collection item - same level of nesting
"label": {
"displayName": "🇩🇪 Label",
"description": "🇩🇪 Label to add to issue.",
},
// fixed collection
"additionalParameters": {
"displayName": "🇩🇪 Additional Fields",
"options": {
"author": {
"displayName": "🇩🇪 Author",
},
},
},
// fixed collection item - same level of nesting
"author": {
"displayName": "🇩🇪 Author",
},
},
},
}
```
<p align="center">
<img src="img/node4.png" width="400">
</p>
> **Note**: In case of deep nesting, i.e. a child of a child of a `collection` and `fixedCollection` parameter, the deeply nested child in principle should be translatable at the same level of nesting as the `collection` and `fixedCollection` parameter, but this has not been fully tested for this first release.
#### Reusable dynamic text
The base text file may contain the special key `reusableDynamicText`, allowing for a node parameter to be translated once and reused in all other node parameter translations.
Currently only the keys `oauth.clientId` and `oauth.clientSecret` are supported as a PoC - these two translations will be reused in all node credential parameters.
```json
{
"reusableDynamicText": {
"oauth2": {
"clientId": "🇩🇪 Client ID",
"clientSecret": "🇩🇪 Client Secret",
```
# Building translations
## Base text
When translating a base text file at `/packages/editor-ui/src/plugins/i18n/locales/{localeIdentifier}.json`:
1. Open a terminal:
```sh
export N8N_DEFAULT_LOCALE=de
npm run start
```
2. Open another terminal:
```sh
export N8N_DEFAULT_LOCALE=de
cd packages/editor-ui
npm run dev
```
Changing the base text file will trigger a rebuild of the client at `http://localhost:8080`.
## Dynamic text
When translating a dynamic text file at `/packages/nodes-base/nodes/{node}/translations/{localeIdentifier}.json`,
1. Open a terminal:
```sh
export N8N_DEFAULT_LOCALE=de
npm run start
```
2. Open another terminal:
```sh
export N8N_DEFAULT_LOCALE=de
cd packages/nodes-base
npm run build:translations
npm run watch
```
After changing the dynamic text file:
1. Stop and restart the first terminal.
2. Refresh the browser at `http://localhost:5678`
If a `headerText` section was changed, re-run `npm run build:translations` in `/nodes-base`.
> **Note**: To translate base and dynamic text simultaneously, run three terminals following the steps from both sections (first terminal running only once) and browse `http://localhost:8080`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 857 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -0,0 +1,330 @@
import _Vue from "vue";
import axios from 'axios';
import VueI18n from 'vue-i18n';
import { Store } from "vuex";
import Vue from 'vue';
import { INodeTranslationHeaders, IRootState } from '@/Interface';
const englishBaseText = require('./locales/en');
Vue.use(VueI18n);
const REUSABLE_DYNAMIC_TEXT_KEY = 'reusableDynamicText';
const CREDENTIALS_MODAL_KEY = 'credentialsModal';
const NODE_VIEW_KEY = 'nodeView';
export function I18nPlugin(vue: typeof _Vue, store: Store<IRootState>): void {
const i18n = new I18nClass(store);
Object.defineProperty(vue, '$locale', {
get() { return i18n; },
});
Object.defineProperty(vue.prototype, '$locale', {
get() { return i18n; },
});
}
export class I18nClass {
$store: Store<IRootState>;
constructor(store: Store<IRootState>) {
this.$store = store;
}
private get i18n(): VueI18n {
return i18nInstance;
}
// ----------------------------------
// helper methods
// ----------------------------------
exists(key: string) {
return this.i18n.te(key);
}
number(value: number, options: VueI18n.FormattedNumberPartType) {
return this.i18n.n(value, options);
}
shortNodeType(longNodeType: string) {
return longNodeType.replace('n8n-nodes-base.', '');
}
// ----------------------------------
// render methods
// ----------------------------------
/**
* Render a string of base text, i.e. a string with a fixed path to the localized value in the base text object. Optionally allows for [interpolation](https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting) when the localized value contains a string between curly braces.
*/
baseText(
key: string, options?: { interpolate: { [key: string]: string } },
): string {
return this.i18n.t(key, options && options.interpolate).toString();
}
/**
* Render a string of dynamic text, i.e. a string with a constructed path to the localized value in the node text object, in the credentials modal, in the node view, or in the headers. Unlike in `baseText`, the fallback has to be set manually for dynamic text.
*/
private dynamicRender(
{ key, fallback }: { key: string; fallback: string; },
) {
return this.i18n.te(key) ? this.i18n.t(key).toString() : fallback;
}
/**
* Render a string of dynamic header text, used in the nodes panel and in the node view.
*/
headerText(arg: { key: string; fallback: string; }) {
return this.dynamicRender(arg);
}
credText () {
const { credentialTextRenderKeys: keys } = this.$store.getters;
const nodeType = keys ? keys.nodeType : '';
const credentialType = keys ? keys.credentialType : '';
const credentialPrefix = `${nodeType}.${CREDENTIALS_MODAL_KEY}.${credentialType}`;
const context = this;
return {
/**
* Display name for a top-level parameter in the credentials modal.
*/
topParameterDisplayName(
{ name: parameterName, displayName }: { name: string; displayName: string; },
) {
if (['clientId', 'clientSecret'].includes(parameterName)) {
return context.dynamicRender({
key: `${REUSABLE_DYNAMIC_TEXT_KEY}.oauth2.${parameterName}`,
fallback: displayName,
});
}
return context.dynamicRender({
key: `${credentialPrefix}.${parameterName}.displayName`,
fallback: displayName,
});
},
/**
* Description for a top-level parameter in the credentials modal.
*/
topParameterDescription(
{ name: parameterName, description }: { name: string; description: string; },
) {
return context.dynamicRender({
key: `${credentialPrefix}.${parameterName}.description`,
fallback: description,
});
},
/**
* Display name for an option inside an `options` or `multiOptions` parameter in the credentials modal.
*/
optionsOptionDisplayName(
{ name: parameterName }: { name: string; },
{ value: optionName, name: displayName }: { value: string; name: string; },
) {
return context.dynamicRender({
key: `${credentialPrefix}.${parameterName}.options.${optionName}.displayName`,
fallback: displayName,
});
},
/**
* Description for an option inside an `options` or `multiOptions` parameter in the credentials modal.
*/
optionsOptionDescription(
{ name: parameterName }: { name: string; },
{ value: optionName, description }: { value: string; description: string; },
) {
return context.dynamicRender({
key: `${credentialPrefix}.${parameterName}.options.${optionName}.description`,
fallback: description,
});
},
/**
* Placeholder for a `string` or `collection` or `fixedCollection` parameter in the credentials modal.
* - For a `string` parameter, the placeholder is unselectable greyed-out sample text.
* - For a `collection` or `fixedCollection` parameter, the placeholder is the button text.
*/
placeholder(
{ name: parameterName, displayName }: { name: string; displayName: string; },
) {
return context.dynamicRender({
key: `${credentialPrefix}.${parameterName}.placeholder`,
fallback: displayName,
});
},
};
}
nodeText () {
const type = this.$store.getters.activeNode.type;
const nodePrefix = `${type}.${NODE_VIEW_KEY}`;
const context = this;
return {
/**
* Display name for a top-level parameter in the node view.
*/
topParameterDisplayName(
{ name: parameterName, displayName }: { name: string; displayName: string; },
) {
return context.dynamicRender({
key: `${nodePrefix}.${parameterName}.displayName`,
fallback: displayName,
});
},
/**
* Description for a top-level parameter in the node view in the node view.
*/
topParameterDescription(
{ name: parameterName, description }: { name: string; description: string; },
) {
return context.dynamicRender({
key: `${nodePrefix}.${parameterName}.description`,
fallback: description,
});
},
/**
* Display name for an option inside a `collection` or `fixedCollection` parameter in the node view.
*/
collectionOptionDisplayName(
{ name: parameterName }: { name: string; },
{ name: optionName, displayName }: { name: string; displayName: string; },
) {
return context.dynamicRender({
key: `${nodePrefix}.${parameterName}.options.${optionName}.displayName`,
fallback: displayName,
});
},
/**
* Display name for an option inside an `options` or `multiOptions` parameter in the node view.
*/
optionsOptionDisplayName(
{ name: parameterName }: { name: string; },
{ value: optionName, name: displayName }: { value: string; name: string; },
) {
return context.dynamicRender({
key: `${nodePrefix}.${parameterName}.options.${optionName}.displayName`,
fallback: displayName,
});
},
/**
* Description for an option inside an `options` or `multiOptions` parameter in the node view.
*/
optionsOptionDescription(
{ name: parameterName }: { name: string; },
{ value: optionName, description }: { value: string; description: string; },
) {
return context.dynamicRender({
key: `${nodePrefix}.${parameterName}.options.${optionName}.description`,
fallback: description,
});
},
/**
* Text for a button to add another option inside a `collection` or `fixedCollection` parameter having`multipleValues: true` in the node view.
*/
multipleValueButtonText(
{ name: parameterName, typeOptions: { multipleValueButtonText } }:
{ name: string; typeOptions: { multipleValueButtonText: string; } },
) {
return context.dynamicRender({
key: `${nodePrefix}.${parameterName}.multipleValueButtonText`,
fallback: multipleValueButtonText,
});
},
/**
* Placeholder for a `string` or `collection` or `fixedCollection` parameter in the node view.
* - For a `string` parameter, the placeholder is unselectable greyed-out sample text.
* - For a `collection` or `fixedCollection` parameter, the placeholder is the button text.
*/
placeholder(
{ name: parameterName, placeholder }: { name: string; placeholder: string; },
) {
return context.dynamicRender({
key: `${nodePrefix}.${parameterName}.placeholder`,
fallback: placeholder,
});
},
};
}
}
const i18nInstance = new VueI18n({
locale: 'en',
fallbackLocale: 'en',
messages: { en: englishBaseText },
silentTranslationWarn: true,
});
const loadedLanguages = ['en'];
function setLanguage(language: string) {
i18nInstance.locale = language;
axios.defaults.headers.common['Accept-Language'] = language;
document!.querySelector('html')!.setAttribute('lang', language);
return language;
}
export async function loadLanguage(language?: string) {
if (!language) return Promise.resolve();
if (i18nInstance.locale === language) {
return Promise.resolve(setLanguage(language));
}
if (loadedLanguages.includes(language)) {
return Promise.resolve(setLanguage(language));
}
const { numberFormats, ...rest } = require(`./locales/${language}.json`);
i18nInstance.setLocaleMessage(language, rest);
if (numberFormats) {
i18nInstance.setNumberFormat(language, numberFormats);
}
loadedLanguages.push(language);
setLanguage(language);
}
export function addNodeTranslation(
nodeTranslation: { [key: string]: object },
language: string,
) {
const newNodesBase = {
'n8n-nodes-base': Object.assign(
i18nInstance.messages[language]['n8n-nodes-base'] || {},
nodeTranslation,
),
};
i18nInstance.setLocaleMessage(
language,
Object.assign(i18nInstance.messages[language], newNodesBase),
);
}
export function addHeaders(
headers: INodeTranslationHeaders,
language: string,
) {
i18nInstance.setLocaleMessage(
language,
Object.assign(i18nInstance.messages[language], { headers }),
);
}

File diff suppressed because it is too large Load Diff

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