Initial commit to release

This commit is contained in:
Jan Oberhauser
2019-06-23 12:35:23 +02:00
commit 9cb9804eee
257 changed files with 42436 additions and 0 deletions

View File

@@ -0,0 +1,135 @@
<template>
<div v-if="windowVisible" class="binary-data-window">
<el-button
@click.stop="closeWindow"
size="small"
class="binary-data-window-back"
title="Back to overview page"
icon="el-icon-arrow-left"
>
Back to list
</el-button>
<div class="binary-data-window-wrapper">
<div v-if="binaryData === null">
Data to display did not get found
</div>
<embed :src="'data:' + binaryData.mimeType + ';base64,' + binaryData.data" class="binary-data" :class="embedClass"/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IBinaryData,
IBinaryKeyData,
IRunData,
IRunExecutionData,
Workflow,
} from 'n8n-workflow';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(
nodeHelpers,
)
.extend({
name: 'BinaryDataDisplay',
props: [
'displayData', // IBinaryDisplayData
'windowVisible', // boolean
],
computed: {
binaryData (): IBinaryData | null {
const binaryData = this.getBinaryData(this.workflowRunData, this.displayData.node, this.displayData.runIndex, this.displayData.outputIndex);
if (binaryData.length === 0) {
return null;
}
if (this.displayData.index >= binaryData.length || binaryData[this.displayData.index][this.displayData.key] === undefined) {
return null;
}
return binaryData[this.displayData.index][this.displayData.key];
},
embedClass (): string[] {
if (this.binaryData !== null &&
this.binaryData.mimeType !== undefined &&
(this.binaryData.mimeType as string).startsWith('image')
) {
return ['image'];
}
return ['other'];
},
workflowRunData (): IRunData | null {
const workflowExecution = this.$store.getters.getWorkflowExecution;
if (workflowExecution === null) {
return null;
}
const executionData: IRunExecutionData = workflowExecution.data;
return executionData.resultData.runData;
},
},
methods: {
closeWindow () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('close');
return false;
},
},
});
</script>
<style lang="scss">
.binary-data-window {
position: absolute;
top: 50px;
left: 0;
z-index: 10;
width: 100%;
height: calc(100% - 50px);
background-color: #f9f9f9;
overflow: hidden;
text-align: center;
.binary-data-window-wrapper {
padding: 0 1em;
height: calc(100% - 50px);
.el-row,
.el-col {
height: 100%;
}
}
.binary-data-window-back {
margin: 0 0 0.5em 0;
}
.binary-data {
background-color: #fff;
&.image {
max-height: calc(100% - 1em);
max-width: calc(100% - 1em);
}
&.other {
height: calc(100% - 1em);
width: calc(100% - 1em);
}
}
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<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
</div>
<parameter-input-list :parameters="getProperties" :nodeValues="nodeValues" :path="path" :hideDelete="hideDelete" @valueChanged="valueChanged" />
<div v-if="parameterOptions.length > 0 && !isReadOnly">
<el-button v-if="parameter.options.length === 1" size="small" class="add-option" @click="optionSelected(parameter.options[0].name)">{{ getPlaceholderText }}</el-button>
<el-select v-else v-model="selectedOption" :placeholder="getPlaceholderText" size="small" class="add-option" @change="optionSelected" filterable>
<el-option
v-for="item in parameterOptions"
:key="item.name"
:label="item.displayName"
:value="item.name">
</el-option>
</el-select>
</div>
</div>
</div>
</template>
<script lang="ts">
import {
IUpdateInformation,
} from '@/Interface';
import {
INodeProperties,
INodePropertyOptions,
} from 'n8n-workflow';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { get } from 'lodash';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
nodeHelpers,
)
.extend({
name: 'CollectionParameter',
props: [
'hideDelete', // boolean
'nodeValues', // NodeParameters
'parameter', // INodeProperties
'path', // string
'values', // NodeParameters
],
data () {
return {
selectedOption: undefined,
};
},
computed: {
getPlaceholderText (): string {
return this.parameter.placeholder ? this.parameter.placeholder : 'Choose option to add';
},
getProperties (): INodeProperties[] {
const returnProperties = [];
let tempProperties;
for (const name of this.propertyNames) {
tempProperties = this.getOptionProperties(name);
if (tempProperties !== undefined) {
returnProperties.push(tempProperties);
}
}
return returnProperties;
},
// Returns all the options which should be displayed
filteredOptions (): Array<INodePropertyOptions | INodeProperties> {
return (this.parameter.options as Array<INodePropertyOptions | INodeProperties>).filter((option) => {
return this.displayNodeParameter(option as INodeProperties);
});
},
// Returns all the options which did not get added already
parameterOptions (): Array<INodePropertyOptions | INodeProperties> {
return (this.filteredOptions as Array<INodePropertyOptions | INodeProperties>).filter((option) => {
return !this.propertyNames.includes(option.name);
});
},
propertyNames (): string[] {
if (this.values) {
return Object.keys(this.values);
}
return [];
},
},
methods: {
getArgument (argumentName: string): string | number | boolean | undefined {
if (this.parameter.typeOptions === undefined) {
return undefined;
}
if (this.parameter.typeOptions[argumentName] === undefined) {
return undefined;
}
return this.parameter.typeOptions[argumentName];
},
getOptionProperties (optionName: string): INodeProperties | undefined {
for (const option of this.parameter.options) {
if (option.name === optionName) {
return option;
}
}
return undefined;
},
displayNodeParameter (parameter: INodeProperties) {
if (parameter.displayOptions === undefined) {
// If it is not defined no need to do a proper check
return true;
}
return this.displayParameter(this.nodeValues, parameter, this.path);
},
optionSelected (optionName: string) {
const option = this.getOptionProperties(optionName);
if (option === undefined) {
return;
}
const name = `${this.path}.${option.name}`;
let parameterData;
if (option.typeOptions !== undefined && option.typeOptions.multipleValues === true) {
// Multiple values are allowed
let newValue;
if (option.type === 'fixedCollection') {
// The "fixedCollection" entries are different as they save values
// in an object and then underneath there is an array. So initialize
// them differently.
newValue = get(this.nodeValues, `${this.path}.${optionName}`, {});
} else {
// Everything else saves them directly as an array.
newValue = get(this.nodeValues, `${this.path}.${optionName}`, []);
newValue.push(JSON.parse(JSON.stringify(option.default)));
}
parameterData = {
name,
value: newValue,
};
} else {
// Add a new option
parameterData = {
name,
value: JSON.parse(JSON.stringify(option.default)),
};
}
this.$emit('valueChanged', parameterData);
this.selectedOption = undefined;
},
valueChanged (parameterData: IUpdateInformation) {
this.$emit('valueChanged', parameterData);
},
},
beforeCreate: function () { // tslint:disable-line
// Because we have a circular dependency on ParameterInputList import it here
// to not break Vue.
this.$options!.components!.ParameterInputList = require('./ParameterInputList.vue').default;
},
});
</script>
<style lang="scss">
.collection-parameter {
padding: 0em 0 0em 2em;
.add-option {
margin-top: 0.5em;
width: 100%;
}
.no-items-exist {
margin: 0.8em 0 0.4em 0;
}
.option {
position: relative;
padding: 0.25em 0 0.25em 1em;
}
}
</style>

View File

@@ -0,0 +1,217 @@
<template>
<div v-if="dialogVisible" @keydown.stop>
<el-dialog :visible="dialogVisible" append-to-body width="55%" :title="title" :before-close="closeDialog">
<div class="credential-type-item">
<el-row v-if="!setCredentialType">
<el-col :span="6">
Credential type:
</el-col>
<el-col :span="18">
<el-select v-model="credentialType" placeholder="Select Type" size="small">
<el-option
v-for="item in credentialTypes"
:key="item.name"
:label="item.displayName"
:value="item.name">
</el-option>
</el-select>
</el-col>
</el-row>
</div>
<credentials-input v-if="credentialType" @credentialsCreated="credentialsCreated" @credentialsUpdated="credentialsUpdated" :credentialTypeData="getCredentialTypeData(credentialType)" :credentialData="credentialData" :nodesInit="nodesInit"></credentials-input>
</el-dialog>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import CredentialsInput from '@/components/CredentialsInput.vue';
import { ICredentialsDecryptedResponse } from '@/Interface';
import {
ICredentialType,
} from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
export default mixins(
restApi,
showMessage,
).extend({
name: 'CredentialsEdit',
props: [
'dialogVisible', // Boolean
'editCredentials',
'setCredentialType', // String
'nodesInit', // Array
],
components: {
CredentialsInput,
},
data () {
return {
credentialData: null as ICredentialsDecryptedResponse | null,
credentialType: null as string | null,
};
},
computed: {
credentialTypes (): ICredentialType[] {
const credentialTypes = this.$store.getters.allCredentialTypes;
if (credentialTypes === null) {
return [];
}
return credentialTypes;
},
title (): string {
if (this.editCredentials) {
const credentialType = this.$store.getters.credentialType(this.editCredentials.type);
return `Edit Credentials: "${credentialType.displayName}"`;
} else {
if (this.credentialType) {
const credentialType = this.$store.getters.credentialType(this.credentialType);
return `Create New Credentials: "${credentialType.displayName}"`;
} else {
return `Create New Credentials`;
}
}
},
},
watch: {
async dialogVisible (newValue, oldValue): Promise<void> {
if (newValue) {
if (this.editCredentials) {
// Credentials which should be edited are given
const credentialType = this.$store.getters.credentialType(this.editCredentials.type);
if (credentialType === null) {
this.$showMessage({
title: 'Credential type not known',
message: `Credentials of type "${this.editCredentials.type}" are not known.`,
type: 'error',
duration: 0,
});
this.closeDialog();
return;
}
if (this.editCredentials.id === undefined) {
this.$showMessage({
title: 'Credential ID missing',
message: 'The ID of the credentials which should be edited is missing!',
type: 'error',
});
this.closeDialog();
return;
}
let currentCredentials: ICredentialsDecryptedResponse | undefined;
try {
currentCredentials = await this.restApi().getCredentials(this.editCredentials.id as string, true) as ICredentialsDecryptedResponse | undefined;
} catch (error) {
this.$showError(error, 'Problem loading credentials', 'There was a problem loading the credentials:');
this.closeDialog();
return;
}
if (currentCredentials === undefined) {
this.$showMessage({
title: 'Credentials not found',
message: `Could not find the credentials with the id: ${this.editCredentials.id}`,
type: 'error',
duration: 0,
});
this.closeDialog();
return;
}
if (currentCredentials === undefined) {
this.$showMessage({
title: 'Problem loading credentials',
message: 'No credentials could be loaded!',
type: 'error',
});
return;
}
this.credentialData = currentCredentials;
} else {
if (this.credentialType || this.setCredentialType) {
const credentialType = this.$store.getters.credentialType(this.credentialType || this.setCredentialType);
if (credentialType === null) {
this.$showMessage({
title: 'Credential type not known',
message: `Credentials of type "${this.credentialType || this.setCredentialType}" are not known.`,
type: 'error',
duration: 0,
});
this.closeDialog();
return;
}
}
this.credentialData = null;
}
if (this.setCredentialType || (this.credentialData && this.credentialData.type)) {
this.credentialType = this.setCredentialType || (this.credentialData && this.credentialData.type);
}
} else {
// Make sure that it gets always reset else it uses by default
// again the last selection from when it was open the previous time.
this.credentialType = null;
}
},
},
methods: {
getCredentialTypeData (name: string): ICredentialType | null {
for (const credentialData of this.credentialTypes) {
if (credentialData.name === name) {
return credentialData;
}
}
return null;
},
credentialsCreated (data: ICredentialsDecryptedResponse): void {
this.$emit('credentialsCreated', data);
this.$showMessage({
title: 'Credentials created',
message: `The credential "${data.name}" got created!`,
type: 'success',
});
this.closeDialog();
},
credentialsUpdated (data: ICredentialsDecryptedResponse): void {
this.$emit('credentialsUpdated', data);
this.$showMessage({
title: 'Credentials updated',
message: `The credential "${data.name}" got updated!`,
type: 'success',
});
this.closeDialog();
},
closeDialog (): void {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
},
},
});
</script>
<style lang="scss">
.credential-type-item {
padding-bottom: 1em;
}
</style>

View File

@@ -0,0 +1,270 @@
<template>
<div @keydown.stop class="credentials-input-wrapper">
<el-row>
<el-col :span="6">
Preset Name:
</el-col>
<el-col :span="18">
<el-input size="small" type="text" v-model="name"></el-input>
</el-col>
</el-row>
<br />
<div class="headline">
Credential Data:
</div>
<el-row v-for="parameter in credentialTypeData.properties" :key="parameter.name" class="parameter-wrapper">
<el-col :span="6">
{{parameter.displayName}}:
</el-col>
<el-col :span="18">
<parameter-input :parameter="parameter" :value="propertyValue[parameter.name]" :path="parameter.name" :isCredential="true" @valueChanged="valueChanged" />
</el-col>
</el-row>
<el-row class="nodes-access-wrapper">
<el-col :span="6" class="headline">
Nodes with access:
</el-col>
<el-col :span="18">
<el-transfer
:titles="['No Access', 'Access ']"
v-model="nodesAccess"
:data="allNodesRequestingAccess">
</el-transfer>
<div v-if="nodesAccess.length === 0" class="no-nodes-access">
<strong>
Important!
</strong><br />
Add at least one node which has access to the credentials!
</div>
</el-col>
</el-row>
<div class="action-buttons">
<el-button type="success" @click="updateCredentials" v-if="credentialData">
Save
</el-button>
<el-button type="success" @click="createCredentials" v-else>
Create
</el-button>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { restApi } from '@/components/mixins/restApi';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { ICredentialsDecryptedResponse, IUpdateInformation } from '@/Interface';
import {
CredentialInformation,
ICredentialDataDecryptedObject,
ICredentialsDecrypted,
ICredentialType,
ICredentialNodeAccess,
INodeCredentialDescription,
INodeTypeDescription,
} from 'n8n-workflow';
import ParameterInput from '@/components/ParameterInput.vue';
import mixins from 'vue-typed-mixins';
export default mixins(
nodeHelpers,
restApi,
).extend({
name: 'CredentialsInput',
props: [
'credentialTypeData', // ICredentialType
'credentialData', // ICredentialsDecryptedResponse
'nodesInit', // {
// type: Array,
// default: () => { [] },
// }
],
components: {
ParameterInput,
},
data () {
return {
nodesAccess: [] as string[],
name: '',
propertyValue: {} as ICredentialDataDecryptedObject,
};
},
computed: {
allNodesRequestingAccess (): Array<{key: string, label: string}> {
const returnNodeTypes: string[] = [];
const nodeTypes: INodeTypeDescription[] = this.$store.getters.allNodeTypes;
let nodeType: INodeTypeDescription;
let credentialTypeDescription: INodeCredentialDescription;
// Find the node types which need the credentials
for (nodeType of nodeTypes) {
if (!nodeType.credentials) {
continue;
}
for (credentialTypeDescription of nodeType.credentials) {
if (credentialTypeDescription.name === (this.credentialTypeData as ICredentialType).name && !returnNodeTypes.includes(credentialTypeDescription.name)) {
returnNodeTypes.push(nodeType.name);
break;
}
}
}
// Return the data in the correct format el-transfer expects
return returnNodeTypes.map((nodeTypeName: string) => {
return {
key: nodeTypeName,
label: this.$store.getters.nodeType(nodeTypeName).displayName as string,
};
});
},
},
methods: {
valueChanged (parameterData: IUpdateInformation) {
const name = parameterData.name.split('.').pop();
// @ts-ignore
this.propertyValue[name] = parameterData.value;
},
async createCredentials () {
const nodesAccess = this.nodesAccess.map((nodeType) => {
return {
nodeType,
};
});
const newCredentials = {
name: this.name,
type: (this.credentialTypeData as ICredentialType).name,
nodesAccess,
data: this.propertyValue,
} as ICredentialsDecrypted;
const result = await this.restApi().createNewCredentials(newCredentials);
// Add also to local store
this.$store.commit('addCredentials', result);
this.$emit('credentialsCreated', result);
},
async updateCredentials () {
const nodesAccess: ICredentialNodeAccess[] = [];
const addedNodeTypes: string[] = [];
// Add Node-type which already had access to keep the original added date
let nodeAccessData: ICredentialNodeAccess;
for (nodeAccessData of (this.credentialData as ICredentialsDecryptedResponse).nodesAccess) {
if (this.nodesAccess.includes((nodeAccessData.nodeType))) {
nodesAccess.push(nodeAccessData);
addedNodeTypes.push(nodeAccessData.nodeType);
}
}
// Add Node-type which did not have access before
for (const nodeType of this.nodesAccess) {
if (!addedNodeTypes.includes(nodeType)) {
nodesAccess.push({
nodeType,
});
}
}
const newCredentials = {
name: this.name,
type: (this.credentialTypeData as ICredentialType).name,
nodesAccess,
data: this.propertyValue,
} as ICredentialsDecrypted;
const result = await this.restApi().updateCredentials((this.credentialData as ICredentialsDecryptedResponse).id as string, newCredentials);
// Update also in local store
this.$store.commit('updateCredentials', result);
// Now that the credentials changed check if any nodes use credentials
// which have now a different name
this.updateNodesCredentialsIssues();
this.$emit('credentialsUpdated', result);
},
init () {
if (this.credentialData) {
// Initialize with the given data
this.name = (this.credentialData as ICredentialsDecryptedResponse).name;
this.propertyValue = (this.credentialData as ICredentialsDecryptedResponse).data as ICredentialDataDecryptedObject;
const nodesAccess = (this.credentialData as ICredentialsDecryptedResponse).nodesAccess.map((nodeAccess) => {
return nodeAccess.nodeType;
});
Vue.set(this, 'nodesAccess', nodesAccess);
} else {
// No data supplied so init empty
this.name = '';
this.propertyValue = {} as ICredentialDataDecryptedObject;
const nodesAccess = [] as string[];
nodesAccess.push.apply(nodesAccess, this.nodesInit);
Vue.set(this, 'nodesAccess', nodesAccess);
}
// Set default values
for (const property of (this.credentialTypeData as ICredentialType).properties) {
if (!this.propertyValue.hasOwnProperty(property.name)) {
this.propertyValue[property.name] = property.default as CredentialInformation;
}
}
},
},
watch: {
credentialData () {
this.init();
},
credentialTypeData () {
this.init();
},
},
mounted () {
this.init();
},
});
</script>
<style lang="scss">
.credentials-input-wrapper {
.action-buttons {
margin-top: 2em;
text-align: right;
}
.headline {
font-weight: 600;
color: $--color-primary;
margin-bottom: 1em;
}
.nodes-access-wrapper {
margin-top: 1em;
}
.no-nodes-access {
margin: 1em 0;
color: $--color-primary;
line-height: 1.75em;
}
.parameter-wrapper {
line-height: 3em;
}
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<div v-if="dialogVisible">
<credentials-edit :dialogVisible="credentialEditDialogVisible" @closeDialog="closeCredentialEditDialog" @credentialsUpdated="reloadCredentialList" @credentialsCreated="reloadCredentialList" :setCredentialType="editCredentials && editCredentials.type" :editCredentials="editCredentials"></credentials-edit>
<el-dialog :visible="dialogVisible" append-to-body width="80%" title="Credentials" :before-close="closeDialog">
<div class="text-very-light">
Your saved credentials:
</div>
<el-button title="Create New Credentials" class="new-credentials-button" @click="createCredential()">
<font-awesome-icon icon="plus" />
<div class="next-icon-text">
Add New
</div>
</el-button>
<el-table :data="credentials" :default-sort = "{prop: 'name', order: 'ascending'}" stripe @row-click="editCredential" max-height="450" v-loading="isDataLoading">
<el-table-column property="name" label="Name" class-name="clickable" sortable></el-table-column>
<el-table-column property="type" label="Type" class-name="clickable" sortable>
<template slot-scope="scope">
{{credentialTypeDisplayNames[scope.row.type]}}
</template>
</el-table-column>
<el-table-column property="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
label="Operations"
width="120">
<template slot-scope="scope">
<el-button title="Edit Credentials" @click.stop="editCredential(scope.row)" icon="el-icon-edit" circle></el-button>
<el-button title="Delete Credentials" @click.stop="deleteCredential(scope.row)" type="danger" icon="el-icon-delete" circle></el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { restApi } from '@/components/mixins/restApi';
import { ICredentialsResponse } from '@/Interface';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import CredentialsEdit from '@/components/CredentialsEdit.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
nodeHelpers,
restApi,
showMessage,
).extend({
name: 'CredentialsList',
props: [
'dialogVisible',
],
components: {
CredentialsEdit,
},
data () {
return {
credentialEditDialogVisible: false,
credentialTypeDisplayNames: {} as { [key: string]: string; },
credentials: [] as ICredentialsResponse[],
displayAddCredentials: false,
editCredentials: null as ICredentialsResponse | null,
isDataLoading: false,
};
},
watch: {
dialogVisible (newValue, oldValue) {
if (newValue) {
this.loadCredentials();
this.loadCredentialTypes();
}
},
},
methods: {
closeCredentialEditDialog () {
this.credentialEditDialogVisible = false;
},
closeDialog () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
},
createCredential () {
this.editCredentials = null;
this.credentialEditDialogVisible = true;
},
editCredential (credential: ICredentialsResponse) {
const editCredentials = {
id: credential.id,
name: credential.name,
type: credential.type,
} as ICredentialsResponse;
this.editCredentials = editCredentials;
this.credentialEditDialogVisible = true;
},
reloadCredentialList () {
this.loadCredentials();
},
loadCredentialTypes () {
if (Object.keys(this.credentialTypeDisplayNames).length !== 0) {
// Data is already loaded
return;
}
if (this.$store.getters.allCredentialTypes === null) {
// Data is not ready yet to be loaded
return;
}
for (const credentialType of this.$store.getters.allCredentialTypes) {
this.credentialTypeDisplayNames[credentialType.name] = credentialType.displayName;
}
},
loadCredentials () {
this.isDataLoading = true;
try {
this.credentials = JSON.parse(JSON.stringify(this.$store.getters.allCredentials));
} catch (error) {
this.$showError(error, 'Proble loading credentials', 'There was a problem loading the credentials:');
this.isDataLoading = false;
return;
}
this.credentials.forEach((credentialData: ICredentialsResponse) => {
credentialData.createdAt = this.convertToDisplayDate(credentialData.createdAt as number);
credentialData.updatedAt = this.convertToDisplayDate(credentialData.updatedAt as number);
});
this.isDataLoading = false;
},
async deleteCredential (credential: ICredentialsResponse) {
const deleteConfirmed = await this.confirmMessage(`Are you sure that you want to delete the credentials "${credential.name}"?`, 'Delete Credentials?', 'warning', 'Yes, delete!');
if (deleteConfirmed === false) {
return;
}
let result;
try {
result = await this.restApi().deleteCredentials(credential.id!);
} catch (error) {
this.$showError(error, 'Problem deleting credentials', 'There was a problem deleting the credentials:');
return;
}
// Remove also from local store
this.$store.commit('removeCredentials', credential);
// Now that the credentials got removed check if any nodes used them
this.updateNodesCredentialsIssues();
this.$showMessage({
title: 'Credentials deleted',
message: `The credential "${credential.name}" got deleted!`,
type: 'success',
});
// Refresh list
this.loadCredentials();
},
},
});
</script>
<style lang="scss">
.new-credentials-button {
float: right;
position: relative;
top: -15px;
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<transition name="el-fade-in">
<div class="data-display-wrapper close-on-click" v-show="node" @click="close">
<div class="data-display" >
<NodeSettings @valueChanged="valueChanged" />
<RunData />
<div class="close-button clickable close-on-click" @click="close" title="Close">
<i class="el-icon-close close-on-click"></i>
</div>
</div>
</div>
</transition>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IRunData,
} from 'n8n-workflow';
import {
INodeUi,
IUpdateInformation,
} from '../Interface';
import NodeSettings from '@/components/NodeSettings.vue';
import RunData from '@/components/RunData.vue';
export default Vue.extend({
name: 'DataDisplay',
components: {
NodeSettings,
RunData,
},
computed: {
node (): INodeUi {
return this.$store.getters.activeNode;
},
},
methods: {
valueChanged (parameterData: IUpdateInformation) {
this.$emit('valueChanged', parameterData);
},
nodeTypeSelected (nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
close (e: MouseEvent) {
// @ts-ignore
if (e.target.className && e.target.className.includes && e.target.className.includes('close-on-click')) {
this.$store.commit('setActiveNode', null);
}
},
},
});
</script>
<style lang="scss">
.data-display-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 20;
background-color: #9d8d9dd8;
.close-button {
position: absolute;
top: 0;
right: -50px;
color: #fff;
background-color: $--custom-header-background;
border-radius: 0 18px 18px 0;
z-index: 110;
font-size: 1.7em;
text-align: center;
line-height: 50px;
height: 50px;
width: 50px;
.close-on-click {
color: #fff;
font-weight: 400;
}
.close-on-click:hover {
transform: scale(1.2);
}
}
.data-display {
position: relative;
width: 80%;
height: 80%;
margin: 8em auto;
background-color: #fff;
border-radius: 2px;
}
}
</style>

View File

@@ -0,0 +1,121 @@
<template >
<span class="static-text-wrapper">
<span v-show="!editActive" title="Click to change">
<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" />
</span>
</span>
</template>
<script lang="ts">
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { INodeUi } from '@/Interface';
import mixins from 'vue-typed-mixins';
export default mixins(genericHelpers).extend({
name: 'DisplayWithChange',
props: {
keyName: String,
},
computed: {
node (): INodeUi {
return this.$store.getters.activeNode;
},
currentValue (): string {
const parameterNameParts = this.keyName.split('.');
const getDescendantProp = (obj: object, path: string): string => {
// @ts-ignore
return path.split('.').reduce((acc, part) => acc && acc[part], obj);
};
return getDescendantProp(this.node, this.keyName);
},
},
watch: {
currentValue (val) {
// Deactivate when the data to edit changes
// (like when a different node gets selected)
this.editActive = false;
},
},
data: () => {
return {
editActive: false,
newValue: '',
};
},
methods: {
noOp () {},
startEdit () {
if (this.isReadOnly === true) {
return;
}
this.editActive = true;
this.newValue = this.currentValue;
setTimeout(() => {
(this.$refs.inputField as HTMLInputElement).focus();
});
},
cancelEdit () {
this.editActive = false;
},
setValue () {
const sendData = {
value: this.newValue,
name: this.keyName,
};
this.$emit('valueChanged', sendData);
this.editActive = false;
},
},
});
</script>
<style lang="scss">
.static-text-wrapper {
line-height: 1.4em;
font-weight: 600;
.static-text {
position: relative;
top: 1px;
&:hover {
border-bottom: 1px dashed #555;
cursor: text;
}
}
input {
font-weight: 600;
&.edit-field {
background: none;
border: none;
font-size: 1em;
color: #555;
border-bottom: 1px dashed #555;
width: calc(100% - 130px);
}
&.edit-field:focus {
outline-offset: unset;
outline: none;
}
}
.icons {
margin-left: 0.6em;
}
}
</style>

View File

@@ -0,0 +1,624 @@
<template>
<span>
<el-dialog :visible="dialogVisible" append-to-body width="80%" :title="`Workflow Executions (${combinedExecutions.length}/${combinedExecutionsCount})`" :before-close="closeDialog">
<div class="filters">
<el-row>
<el-col :span="4" class="filter-headline">
Filters:
</el-col>
<el-col :span="6">
<el-select v-model="filter.workflowId" placeholder="Select Workflow" size="small" filterable @change="handleFilterChanged">
<el-option
v-for="item in workflows"
:key="item.id"
:label="item.name"
:value="item.id">
</el-option>
</el-select>
</el-col>
<el-col :span="3">&nbsp;
</el-col>
<el-col :span="3" class="filter-headline">
Auto-Refresh:
</el-col>
<el-col :span="4">
<el-select v-model="autoRefresh.time" placeholder="Select Refresh Time" size="small" filterable @change="handleRefreshTimeChanged">
<el-option
v-for="item in autoRefresh.options"
:key="item.value"
:label="item.name"
:value="item.value">
</el-option>
</el-select>
</el-col>
<el-col :span="4">
<el-button title="Refresh" @click="refreshData()" :disabled="isDataLoading" size="small" type="success" class="refresh-button">
<font-awesome-icon icon="sync" /> Manual Refresh
</el-button>
</el-col>
</el-row>
</div>
<div class="selection-options">
<span v-if="checkAll === true || isIndeterminate === true">
Selected: {{numSelected}}/{{finishedExecutionsCount}}
<el-button type="danger" title="Delete Selected" icon="el-icon-delete" size="mini" @click="handleDeleteSelected" circle></el-button>
</span>
</div>
<el-table :data="combinedExecutions" stripe v-loading="isDataLoading" :row-class-name="getRowClass" @row-click="handleRowClick">
<el-table-column label="" width="30">
<!-- eslint-disable-next-line vue/no-unused-vars -->
<template slot="header" slot-scope="scope">
<el-checkbox :indeterminate="isIndeterminate" v-model="checkAll" @change="handleCheckAllChange">Check all</el-checkbox>
</template>
<template slot-scope="scope">
<el-checkbox v-if="scope.row.stoppedAt !== undefined" :value="selectedItems[scope.row.id.toString()] || checkAll" @change="handleCheckboxChanged(scope.row.id)" >Check all</el-checkbox>
</template>
</el-table-column>
<el-table-column property="startedAt" label="Started At / ID" width="205">
<template slot-scope="scope">
{{convertToDisplayDate(scope.row.startedAt)}}<br />
<small>ID: {{scope.row.id}}</small>
</template>
</el-table-column>
<el-table-column property="workflowName" label="Name">
<template slot-scope="scope">
<span class="workflow-name">
{{scope.row.workflowName}}
</span>
<span v-if="scope.row.stoppedAt === undefined">
(running)
</span>
<span v-if="scope.row.retryOf !== undefined">
<br /><small>Retry of "{{scope.row.retryOf}}"</small>
</span>
<span v-else-if="scope.row.retrySuccessId !== undefined">
<br /><small>Success retry "{{scope.row.retrySuccessId}}"</small>
</span>
</template>
</el-table-column>
<el-table-column label="Status" width="120">
<template slot-scope="scope">
<el-tooltip placement="top" effect="light">
<div slot="content" v-html="statusTooltipText(scope.row)"></div>
<span class="status-badge running" v-if="scope.row.stoppedAt === undefined">
Running
</span>
<span class="status-badge success" v-else-if="scope.row.finished">
Success
</span>
<span class="status-badge error" v-else>
Error
</span>
</el-tooltip>
<el-button class="retry-button" circle v-if="scope.row.stoppedAt !== undefined && !scope.row.finished && scope.row.retryOf === undefined && scope.row.retrySuccessId === undefined" @click.stop="retryExecution(scope.row)" type="text" size="small" title="Retry execution">
<font-awesome-icon icon="redo" />
</el-button>
</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">
<template slot-scope="scope">
<span v-if="scope.row.stoppedAt === undefined">
<font-awesome-icon icon="spinner" spin />
{{(new Date().getTime() - new Date(scope.row.startedAt).getTime())/1000}} sec.
</span>
<span v-else>
{{(scope.row.stoppedAt - scope.row.startedAt) / 1000}} sec.
</span>
</template>
</el-table-column>
<el-table-column label="" width="100" align="center">
<template slot-scope="scope">
<span v-if="scope.row.stoppedAt === undefined">
<el-button circle title="Stop Execution" @click.stop="stopExecution(scope.row.id)" :loading="stoppingExecutions.includes(scope.row.id)" size="mini">
<font-awesome-icon icon="stop" />
</el-button>
</span>
<span v-else>
<el-button circle title="Open Past Execution" @click.stop="displayExecution(scope.row)" size="mini">
<font-awesome-icon icon="folder-open" />
</el-button>
</span>
</template>
</el-table-column>
</el-table>
<div class="load-more" v-if="finishedExecutionsCount > finishedExecutions.length">
<el-button title="Load More" @click="loadMore()" size="small" :disabled="isDataLoading">
<font-awesome-icon icon="sync" /> Load More
</el-button>
</div>
</el-dialog>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { restApi } from '@/components/mixins/restApi';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import {
IExecutionsCurrentSummaryExtended,
IExecutionDeleteFilter,
IExecutionsListResponse,
IExecutionShortResponse,
IExecutionsStopData,
IExecutionsSummary,
IWorkflowShortResponse,
} from '@/Interface';
import {
IDataObject,
} from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
restApi,
showMessage,
).extend({
name: 'ExecutionsList',
props: [
'dialogVisible',
],
components: {
WorkflowActivator,
},
data () {
return {
activeExecutions: [] as IExecutionsCurrentSummaryExtended[],
finishedExecutions: [] as IExecutionsSummary[],
finishedExecutionsCount: 0,
checkAll: false,
autoRefresh: {
timer: undefined as NodeJS.Timeout | undefined,
time: -1,
options: [
{
name: 'Deactivated',
value: -1,
},
{
name: '5 Seconds',
value: 5,
},
{
name: '10 Seconds',
value: 10,
},
{
name: '15 Seconds',
value: 15,
},
{
name: '30 Seconds',
value: 30,
},
{
name: '1 Minute',
value: 60,
},
{
name: '5 Minutes',
value: 300,
},
],
},
filter: {
workflowId: 'ALL',
},
isDataLoading: false,
requestItemsPerRequest: 10,
selectedItems: {} as { [key: string]: boolean; },
stoppingExecutions: [] as string[],
workflows: [] as IWorkflowShortResponse[],
};
},
computed: {
combinedExecutions (): IExecutionsSummary[] {
const returnData: IExecutionsSummary[] = [];
// The active executions do not have the workflow-names yet so add them
for (const executionData of this.activeExecutions) {
executionData.workflowName = this.getWorkflowName(executionData.workflowId);
returnData.push(executionData);
}
returnData.push.apply(returnData, this.finishedExecutions);
return returnData;
},
combinedExecutionsCount (): number {
return this.activeExecutions.length + this.finishedExecutionsCount;
},
numSelected (): number {
if (this.checkAll === true) {
return this.finishedExecutionsCount;
}
return Object.keys(this.selectedItems).length;
},
isIndeterminate (): boolean {
if (this.checkAll === true) {
return false;
}
if (this.numSelected > 0) {
return true;
}
return false;
},
workflowFilter (): IDataObject {
const filter: IDataObject = {};
if (this.filter.workflowId !== 'ALL') {
filter.workflowId = this.filter.workflowId;
}
return filter;
},
},
watch: {
dialogVisible (newValue, oldValue) {
if (newValue) {
this.openDialog();
} else {
this.handleRefreshTimeChanged(-1);
}
},
},
methods: {
closeDialog () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
},
displayExecution (execution: IExecutionShortResponse) {
this.$router.push({
name: 'ExecutionById',
params: { id: execution.id },
});
this.closeDialog();
},
handleCheckAllChange () {
if (this.checkAll === false) {
Vue.set(this, 'selectedItems', {});
}
},
handleCheckboxChanged (executionId: string) {
if (this.selectedItems[executionId]) {
Vue.delete(this.selectedItems, executionId);
} else {
Vue.set(this.selectedItems, executionId, true);
}
},
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!');
if (deleteExecutions === false) {
return;
}
this.isDataLoading = true;
const sendData: IExecutionDeleteFilter = {};
if (this.checkAll === true) {
sendData.deleteBefore = this.finishedExecutions[0].startedAt as number;
} else {
sendData.ids = Object.keys(this.selectedItems);
}
sendData.filters = this.workflowFilter;
try {
await this.restApi().deleteExecutions(sendData);
} catch (error) {
this.isDataLoading = false;
this.$showError(error, 'Problem deleting executions', 'There was a problem deleting the executions:');
return;
}
this.isDataLoading = false;
this.$showMessage({
title: 'Execution deleted',
message: 'The executions got deleted!',
type: 'success',
});
Vue.set(this, 'selectedItems', {});
this.checkAll = false;
this.refreshData();
},
handleFilterChanged () {
this.refreshData();
},
getRowClass (data: IDataObject): string {
const classes: string[] = ['clickable'];
if ((data.row as IExecutionsSummary).stoppedAt === undefined) {
classes.push('currently-running');
}
return classes.join(' ');
},
getWorkflowName (workflowId: string): string {
const workflow = this.workflows.find((data) => data.id === workflowId);
if (workflow === undefined) {
return '<UNSAVED WORKFLOW>';
}
return workflow.name;
},
async loadActiveExecutions (): Promise<void> {
this.activeExecutions = await this.restApi().getCurrentExecutions(this.workflowFilter);
},
async loadFinishedExecutions (): Promise<void> {
const data = await this.restApi().getPastExecutions(this.workflowFilter, this.requestItemsPerRequest);
this.finishedExecutions = data.results;
this.finishedExecutionsCount = data.count;
},
async loadMore () {
// Deactivate the auto-refresh because else the newly displayed
// data would be lost with the next automatic refresh
this.autoRefresh.time = -1;
this.handleRefreshTimeChanged();
this.isDataLoading = true;
const filter = this.workflowFilter;
let lastStartedAt: number | undefined;
if (this.finishedExecutions.length !== 0) {
const lastItem = this.finishedExecutions.slice(-1)[0];
lastStartedAt = lastItem.startedAt as number;
}
let data: IExecutionsListResponse;
try {
data = await this.restApi().getPastExecutions(filter, this.requestItemsPerRequest, lastStartedAt);
} catch (error) {
this.isDataLoading = false;
this.$showError(error, 'Problem loading workflows', 'There was a problem loading the workflows:');
return;
}
this.finishedExecutions.push.apply(this.finishedExecutions, data.results);
this.finishedExecutionsCount = data.count;
this.isDataLoading = false;
},
async loadWorkflows () {
try {
const workflows = await this.restApi().getWorkflows();
workflows.sort((a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
return -1;
}
if (a.name.toLowerCase() > b.name.toLowerCase()) {
return 1;
}
return 0;
});
// @ts-ignore
workflows.unshift({
id: 'ALL',
name: 'All',
});
Vue.set(this, 'workflows', workflows);
} catch (error) {
this.$showError(error, 'Problem loading workflows', 'There was a problem loading the workflows:');
}
},
openDialog () {
Vue.set(this, 'selectedItems', {});
this.filter.workflowId = 'ALL';
this.checkAll = false;
this.loadWorkflows();
this.refreshData();
this.handleRefreshTimeChanged();
},
handleRefreshTimeChanged (manualOverwrite?: number) {
if (this.autoRefresh.timer !== undefined) {
// Make sure the old timer gets removed
clearInterval(this.autoRefresh.timer);
this.autoRefresh.timer = undefined;
}
const timerValue = manualOverwrite !== undefined ? manualOverwrite : this.autoRefresh.time;
if (timerValue === -1) {
// No timer should be set
return;
}
// Create the new interval timer
this.autoRefresh.timer = setInterval(() => {
this.refreshData();
}, timerValue * 1000);
},
async retryExecution (execution: IExecutionShortResponse) {
this.isDataLoading = true;
try {
const data = await this.restApi().retryExecution(execution.id);
if (data.finished === true) {
this.$showMessage({
title: 'Retry successful',
message: 'The retry was successful!',
type: 'success',
});
} else {
this.$showMessage({
title: 'Retry unsuccessful',
message: 'The retry was not successful!',
type: 'error',
});
}
this.refreshData();
this.isDataLoading = false;
} catch (error) {
this.$showError(error, 'Problem with retry', 'There was a problem with the retry:');
this.isDataLoading = false;
this.refreshData();
}
},
async refreshData () {
this.isDataLoading = true;
try {
const activeExecutionsPromise = this.loadActiveExecutions();
const finishedExecutionsPromise = this.loadFinishedExecutions();
await Promise.all([activeExecutionsPromise, finishedExecutionsPromise]);
} catch (error) {
this.$showError(error, 'Problem loading', 'There was a problem loading the data:');
}
this.isDataLoading = false;
},
handleRowClick (entry: IExecutionsSummary, event: Event, column: any) { // tslint:disable-line:no-any
if (column.label === '') {
// Ignore all clicks in the first and last row
return;
}
if (this.selectedItems[entry.id]) {
Vue.delete(this.selectedItems, entry.id);
} else {
Vue.set(this.selectedItems, entry.id, true);
}
},
statusTooltipText (entry: IExecutionsSummary): string {
if (entry.stoppedAt === undefined) {
return 'The worklow is currently executing.';
} else if (entry.finished === true) {
return 'The worklow execution was successful.';
} else if (entry.retryOf !== undefined) {
return `The workflow execution was a retry of "${entry.retryOf}" and did fail.<br />New retries have to be started from the original execution.`;
} else if (entry.retrySuccessId !== undefined) {
return `The workflow execution did fail but the retry "${entry.retrySuccessId}" was successful.`;
} else {
return 'The workflow execution did fail.';
}
},
async stopExecution (executionId: string) {
try {
// Add it to the list of currently stopping executions that we
// can show the user in the UI that it is in progress
this.stoppingExecutions.push(executionId);
const stopData: IExecutionsStopData = await this.restApi().stopCurrentExecution(executionId);
// Remove it from the list of currently stopping executions
const index = this.stoppingExecutions.indexOf(executionId);
this.stoppingExecutions.splice(index, 1);
this.$showMessage({
title: 'Execution stopped',
message: `The execution with the id "${executionId}" got stopped!`,
type: 'success',
});
this.refreshData();
} catch (error) {
this.$showError(error, 'Problem stopping execution', 'There was a problem stopping the execuction:');
}
},
},
});
</script>
<style scoped lang="scss">
.filters {
line-height: 2em;
.refresh-button {
position: absolute;
right: 0;
}
}
.load-more {
margin: 2em 0 0 0;
width: 100%;
text-align: center;
}
.retry-button {
color: $--custom-error-text;
background-color: $--custom-error-background;
margin-left: 5px;
}
.selection-options {
height: 2em;
}
.status-badge {
position: relative;
display: inline-block;
padding: 0 10px;
height: 30px;
line-height: 30px;
border-radius: 15px;
text-align: center;
font-weight: 400;
&.error {
background-color: $--custom-error-background;
color: $--custom-error-text;
}
&.running {
background-color: $--custom-running-background;
color: $--custom-running-text;
}
&.success {
background-color: $--custom-success-background;
color: $--custom-success-text;
}
}
.workflow-name {
font-weight: bold;
}
</style>
<style lang="scss">
.currently-running {
background-color: $--color-primary-light !important;
}
.el-table tr:hover.currently-running td {
background-color: #907070 !important;
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<div v-if="dialogVisible" @keydown.stop>
<el-dialog :visible="dialogVisible" custom-class="expression-dialog" append-to-body width="80%" title="Edit Expression" :before-close="closeDialog">
<el-row>
<el-col :span="8">
<div class="header-side-menu">
<div class="headline">
Edit Expression
</div>
<div class="sub-headline">
Variable Selector
</div>
</div>
<div class="variable-selector">
<variable-selector :path="path" @itemSelected="itemSelected"></variable-selector>
</div>
</el-col>
<el-col :span="16" class="right-side">
<div class="expression-editor-wrapper">
<div class="editor-description">
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>
</div>
</div>
<div class="expression-result-wrapper">
<div class="editor-description">
Result
</div>
<expression-input :parameter="parameter" resolvedValue="true" rows="8" :value="value" :path="path"></expression-input>
</div>
</el-col>
</el-row>
</el-dialog>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import ExpressionInput from '@/components/ExpressionInput.vue';
import VariableSelector from '@/components/VariableSelector.vue';
import { IVariableItemSelected } from '@/Interface';
import {
Workflow,
} from 'n8n-workflow';
export default Vue.extend({
name: 'ExpressionEdit',
props: [
'dialogVisible',
'parameter',
'path',
'value',
],
components: {
ExpressionInput,
VariableSelector,
},
data () {
return {
};
},
methods: {
valueChanged (value: string) {
this.$emit('valueChanged', value);
},
closeDialog () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
},
itemSelected (eventData: IVariableItemSelected) {
(this.$refs.inputFieldExpression as any).itemSelected(eventData); // tslint:disable-line:no-any
},
},
});
</script>
<style scoped lang="scss">
.editor-description {
font-weight: bold;
padding: 0 0 0.5em 0.2em;;
}
.expression-result-wrapper,
.expression-editor-wrapper {
padding: 10px;
}
.expression-result-wrapper {
margin-top: 1em;
}
/deep/ .expression-dialog {
.el-dialog__header {
padding: 0;
}
.el-dialog__title {
display: none;
}
.el-dialog__body {
padding: 0;
}
.right-side {
background-color: #f9f9f9;
}
}
.header-side-menu {
padding: 1em 0 0.5em 1.8em;
background-color: $--custom-window-sidebar-top;
color: #555;
border-bottom: 1px solid $--color-primary;
margin-bottom: 1em;
.headline {
font-size: 1.35em;
font-weight: 600;
}
.sub-headline {
font-weight: 600;
font-size: 1.1em;
text-align: center;
padding-top: 1.5em;
color: $--color-primary;
}
}
.variable-selector {
margin: 0 1em;
}
</style>

View File

@@ -0,0 +1,360 @@
<template>
<div>
<div ref="expression-editor" :style="editorStyle" @keydown.stop></div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import 'quill/dist/quill.core.css';
import Quill, { DeltaOperation } from 'quill';
// @ts-ignore
import AutoFormat, { AutoformatHelperAttribute } from 'quill-autoformat';
import {
NodeParameterValue,
Workflow,
WorkflowDataProxy,
} from 'n8n-workflow';
import {
IExecutionResponse,
IVariableItemSelected,
IVariableSelectorOption,
} from '@/Interface';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(
workflowHelpers,
)
.extend({
name: 'ExpressionInput',
props: [
'rows',
'value',
'parameter',
'path',
'resolvedValue',
],
data () {
return {
editor: null as null | Quill,
};
},
computed: {
editorStyle () {
let rows = 1;
if (this.rows) {
rows = parseInt(this.rows, 10);
}
return {
'height': Math.max((rows * 26 + 10), 40) + 'px',
};
},
workflow (): Workflow {
return this.getWorkflow();
},
},
watch: {
value () {
if (this.resolvedValue) {
// When resolved value gets displayed update the input automatically
this.initValue();
}
},
},
mounted () {
const that = this;
// tslint:disable-next-line
const Inline = Quill.import('blots/inline');
class VariableField extends Inline {
static create (value: string) {
const node = super.create(value);
node.setAttribute('data-value', value);
node.setAttribute('class', 'variable');
return node;
}
static formats (domNode: HTMLElement) {
// For the not resolved one the value can be read directly from the dom
let variableName = domNode.innerHTML.trim();
if (that.resolvedValue) {
// For the resolve done it has to get the one from creation.
// It will not update on change but because the init runs on every change it does not really matter
variableName = domNode.getAttribute('data-value') as string;
}
const newClasses = that.getPlaceholderClasses(variableName);
if (domNode.getAttribute('class') !== newClasses) {
// Only update when it changed else we get an endless loop!
domNode.setAttribute('class', newClasses);
}
return true;
}
}
VariableField.blotName = 'variable';
VariableField.className = 'variable';
VariableField.tagName = 'span';
Quill.register({
'formats/variable': VariableField,
});
AutoFormat.DEFAULTS = {
expression: {
trigger: /[\w\s]/,
find: /\{\{[^\s,;:!?}]+\}\}/i,
format: 'variable',
},
};
this.editor = new Quill(this.$refs['expression-editor'] as Element, {
readOnly: !!this.resolvedValue,
modules: {
autoformat: {},
},
});
this.editor.root.addEventListener('blur', (event: Event) => {
this.$emit('blur', event);
});
this.initValue();
if (!this.resolvedValue) {
// Only call update when not resolved value gets displayed
this.setFocus();
this.editor.on('text-change', () => this.update());
}
},
methods: {
// ------------------------------- EDITOR -------------------------------
customizeVariable (variableName: string) {
const returnData = {
classes: [] as string[],
message: variableName as string,
};
let value;
try {
value = this.resolveExpression(`=${variableName}`);
if (value !== undefined) {
returnData.classes.push('valid');
} else {
returnData.classes.push('invalid');
}
} catch (e) {
returnData.classes.push('invalid');
}
return returnData;
},
// Resolves the given variable. If it is not valid it will return
// an error-string.
resolveParameterString (variableName: string) {
let returnValue;
try {
returnValue = this.resolveExpression(`=${variableName}`);
} catch (e) {
return 'invalid';
}
if (returnValue === undefined) {
return 'not found';
}
return returnValue;
},
getPlaceholderClasses (variableName: string) {
const customizeData = this.customizeVariable(variableName);
return 'variable ' + customizeData.classes.join(' ');
},
getValue () {
if (!this.editor) {
return '';
}
const content = this.editor.getContents();
if (!content || !content.ops) {
return '';
}
let returnValue = '';
// Convert the editor operations into a string
content.ops.forEach((item: DeltaOperation) => {
if (!item.insert) {
return;
}
returnValue += item.insert;
});
// For some unknown reason does the Quill always return a "\n"
// at the end. Remove it here manually
return '=' + returnValue.replace(/\s+$/g, '');
},
setFocus () {
// TODO: There is a bug that when opening ExpressionEditor and typing directly it shows the first letter and
// then adds the second letter in from of the first on
this.editor!.focus();
},
itemSelected (eventData: IVariableItemSelected) {
// We can only get the selection if editor is in focus so make
// sure it is
this.editor!.focus();
const selection = this.editor!.getSelection();
let addIndex = null;
if (selection) {
addIndex = selection.index;
}
if (addIndex) {
// If we have a location to add variable to add it there
this.editor!.insertText(addIndex, `{{${eventData.variable}}}`, 'variable', true);
this.update();
} else {
// If no position got found add it to end
let newValue = this.value;
if (newValue !== '=') {
newValue += ` `;
}
newValue += `{{${eventData.variable}}}\n`;
this.$emit('change', newValue);
if (!this.resolvedValue) {
Vue.nextTick(() => {
this.initValue();
});
}
}
},
initValue () {
if (!this.value) {
return;
}
let currentValue = this.value;
if (currentValue.charAt(0) === '=') {
currentValue = currentValue.slice(1);
}
// Convert the expression string into a Quill Operations
const editorOperations: DeltaOperation[] = [];
currentValue.replace(/\{\{(.*?)\}\}/ig, '*^^%#_@$1*^^%#_@').split('*^^%#_@').forEach((value: string) => {
if (!value) {
} else if (value.charAt(0) === '^') {
// Is variable
let displayValue = `{{${value.slice(1)}}}` as string | number | boolean;
if (this.resolvedValue) {
displayValue = this.resolveParameterString(displayValue.toString()) as NodeParameterValue;
}
editorOperations.push({
attributes: {
variable: `{{${value.slice(1)}}}`,
},
insert: displayValue.toString(),
});
} else {
// Is text
editorOperations.push({
insert: value,
});
}
});
// @ts-ignore
this.editor!.setContents(editorOperations);
},
update () {
this.$emit('input', this.getValue());
this.$emit('change', this.getValue());
},
},
});
</script>
<style lang="scss">
.variable-wrapper {
text-decoration: none;
}
.variable-value {
font-weight: bold;
color: #000;
background-color: #c0c0c0;
padding: 3px;
border-radius: 3px;
}
.variable-delete {
position: relative;
left: -3px;
top: -8px;
display: none;
color: #fff;
font-weight: bold;
padding: 2px 4px;
}
.variable-wrapper:hover .variable-delete {
display: inline;
background-color: #AA2200;
border-radius: 5px;
}
.variable {
font-weight: bold;
color: #000;
background-color: #c0c0c0;
padding: 3px;
border-radius: 3px;
margin: 0 2px;
&:first-child {
margin-left: 0;
}
&.invalid {
background-color: #e25e5e;
}
&.valid {
background-color: #37ac37;
}
}
.ql-editor {
padding: 0.5em 1em;
}
.ql-disabled .ql-editor {
border-width: 1px;
border: 1px dashed $--custom-expression-text;
color: $--custom-expression-text;
background-color: $--custom-expression-background;
cursor: not-allowed;
}
.ql-disabled .ql-editor .variable {
color: #303030;
}
</style>

View File

@@ -0,0 +1,235 @@
<template>
<div @keydown.stop class="fixed-collection-parameter">
<div v-if="getProperties.length === 0" class="no-items-exist">
Currently no items exist
</div>
<div v-for="property in getProperties" :key="property.name" class="fixed-collection-parameter-property">
<div class="parameter-name" :title="property.displayName">{{property.displayName}}:</div>
<div v-if="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 clickable" title="Delete" v-if="!isReadOnly">
<font-awesome-icon icon="trash" class="reset-icon clickable" title="Delete Item" @click="deleteOption(property.name, index)" />
</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 clickable" title="Delete" 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>
</div>
<div v-if="parameterOptions.length > 0 && !isReadOnly">
<el-button v-if="parameter.options.length === 1" size="small" class="add-option" @click="optionSelected(parameter.options[0].name)">{{ getPlaceholderText }}</el-button>
<el-select v-else v-model="selectedOption" :placeholder="getPlaceholderText" size="small" class="add-option" @change="optionSelected" filterable>
<el-option
v-for="item in parameterOptions"
:key="item.name"
:label="item.displayName"
:value="item.name">
</el-option>
</el-select>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IUpdateInformation,
} from '@/Interface';
import {
INodeParameters,
INodePropertyCollection,
} from 'n8n-workflow';
import { get } from 'lodash';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(genericHelpers)
.extend({
name: 'FixedCollectionParameter',
props: [
'nodeValues', // INodeParameters
'parameter', // INodeProperties
'path', // string
'values', // INodeParameters
],
data () {
return {
selectedOption: undefined,
};
},
computed: {
getPlaceholderText (): string {
return this.parameter.placeholder ? this.parameter.placeholder : 'Choose option to add';
},
getProperties (): INodePropertyCollection[] {
const returnProperties = [];
let tempProperties;
for (const name of this.propertyNames) {
tempProperties = this.getOptionProperties(name);
if (tempProperties !== undefined) {
returnProperties.push(tempProperties);
}
}
return returnProperties;
},
multipleValues (): boolean {
if (this.parameter.typeOptions !== undefined && this.parameter.typeOptions.multipleValues === true) {
return true;
}
return false;
},
parameterOptions (): INodePropertyCollection[] {
if (this.multipleValues === true) {
return this.parameter.options;
}
return (this.parameter.options as INodePropertyCollection[]).filter((option) => {
return !this.propertyNames.includes(option.name);
});
},
propertyNames (): string[] {
if (this.values) {
return Object.keys(this.values);
}
return [];
},
},
methods: {
deleteOption (optionName: string, index?: number) {
const parameterData = {
name: this.getPropertyPath(optionName, index),
value: null,
};
this.$emit('valueChanged', parameterData);
},
getPropertyPath (name: string, index?: number) {
return `${this.path}.${name}` + (index !== undefined ? `[${index}]` : '');
},
getOptionProperties (optionName: string): INodePropertyCollection | undefined {
for (const option of this.parameter.options) {
if (option.name === optionName) {
return option;
}
}
return undefined;
},
optionSelected (optionName: string) {
const option = this.getOptionProperties(optionName);
if (option === undefined) {
return;
}
const name = `${this.path}.${option.name}`;
let parameterData;
const newParameterValue: INodeParameters = {};
for (const optionParameter of option.values) {
if (optionParameter.typeOptions !== undefined && optionParameter.typeOptions.multipleValues === true) {
// Multiple values are allowed so append option to array
newParameterValue[optionParameter.name] = get(this.nodeValues, `${this.path}.${optionParameter.name}`, []);
(newParameterValue[optionParameter.name] as INodeParameters[]).push(JSON.parse(JSON.stringify(optionParameter.default)));
} else {
// Add a new option
newParameterValue[optionParameter.name] = JSON.parse(JSON.stringify(optionParameter.default));
}
}
let newValue;
if (this.multipleValues === true) {
newValue = get(this.nodeValues, name, []);
newValue.push(newParameterValue);
} else {
newValue = newParameterValue;
}
parameterData = {
name,
value: newValue,
};
this.$emit('valueChanged', parameterData);
this.selectedOption = undefined;
},
valueChanged (parameterData: IUpdateInformation) {
this.$emit('valueChanged', parameterData);
},
},
beforeCreate: function () { // tslint:disable-line
// Because we have a circular dependency on ParameterInputList import it here
// to not break Vue.
this.$options!.components!.ParameterInputList = require('./ParameterInputList.vue').default;
},
});
</script>
<style scoped lang="scss">
.add-option {
width: 100%;
}
.fixed-collection-parameter {
padding: 0 0 0 1em;
}
.fixed-collection-parameter-property {
margin: 0.5em 0;
padding: 0.5em 0;
.parameter-name {
border-bottom: 1px solid #999;
}
}
.delete-option {
display: none;
position: absolute;
z-index: 999;
color: #f56c6c;
left: 0;
top: 0;
}
.parameter-item-wrapper:hover > .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;
}
}
}
.no-items-exist {
margin: 0.8em 0;
}
</style>

View File

@@ -0,0 +1,314 @@
<template>
<div>
<div class="main-header">
<input type="file" ref="importFile" style="display: none" v-on:change="handleFileImport()">
<div class="top-menu">
<div class="center-item">
<span v-if="isExecutionPage">
Execution Id:
<span v-if="isExecutionPage" class="execution-name">
<strong>{{executionId}}</strong>&nbsp;
<font-awesome-icon icon="check" class="execution-icon success" v-if="executionFinished" title="Execution was successful" />
<font-awesome-icon icon="times" class="execution-icon error" v-else title="Execution did fail" />
</span>
of Workflow
<span class="workflow-name clickable" title="Open Workflow">
<span @click="openWorkflow(workflowExecution.workflowId)">"{{workflowName}}"</span>
</span>
</span>
<span index="workflow-name" class="current-workflow" v-if="!isReadOnly">
<span v-if="currentWorkflow">Workflow: <span class="workflow-name">{{workflowName}}</span></span>
<span v-else class="workflow-not-saved">Workflow not saved!</span>
</span>
<span class="saving-workflow" v-if="isWorkflowSaving">
<font-awesome-icon icon="spinner" spin />
Saving...
</span>
</div>
<div class="clear-execution clickable" v-if="!isReadOnly && workflowExecution && !workflowRunning" @click="clearExecutionData()" title="Deletes the current Execution Data.">
<font-awesome-icon icon="trash" class="clear-execution-icon" />
</div>
<div class="push-connection-lost" v-if="!isPushConnectionActive">
<el-tooltip placement="bottom-end" effect="light">
<div slot="content">
Server connection could not be established.<br />
The server is down or there is a connection problem.<br />
It will reconnect automatically as soon as the backend can be reached.
</div>
<span>
<font-awesome-icon icon="exclamation-triangle" />&nbsp;
Connection lost
</span>
</el-tooltip>
</div>
<div class="workflow-active" v-else-if="!isReadOnly">
Active:
<workflow-activator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflow" :disabled="!currentWorkflow"/>
</div>
<div class="read-only" v-if="isReadOnly">
<el-tooltip placement="bottom-end" effect="light">
<div slot="content">
A past execution gets displayed. For that reason no data<br />
can be changed. To make changes or to execute it again open<br />
the workflow by clicking on it`s name on the left.
</div>
<span>
<font-awesome-icon icon="exclamation-triangle" />
Read only
</span>
</el-tooltip>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IExecutionResponse,
IExecutionsStopData,
IWorkflowDataUpdate,
} from '../Interface';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { pushConnection } from '@/components/mixins/pushConnection';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { saveAs } from 'file-saver';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
pushConnection,
restApi,
showMessage,
workflowHelpers,
)
.extend({
name: 'MainHeader',
components: {
WorkflowActivator,
},
computed: {
executionId (): string | undefined {
return this.$route.params.id;
},
executionFinished (): boolean {
if (!this.isExecutionPage) {
// We are not on an execution page so return false
return false;
}
const fullExecution = this.$store.getters.getWorkflowExecution;
if (fullExecution === null) {
// No execution loaded so return also false
return false;
}
if (fullExecution.finished === true) {
return true;
}
return false;
},
isExecutionPage (): boolean {
if (['ExecutionById'].includes(this.$route.name as string)) {
return true;
}
return false;
},
isPushConnectionActive (): boolean {
return this.$store.getters.pushConnectionActive;
},
isWorkflowActive (): boolean {
return this.$store.getters.isActive;
},
isWorkflowSaving (): boolean {
return this.$store.getters.isActionActive('workflowSaving');
},
currentWorkflow (): string {
return this.$route.params.name;
},
workflowExecution (): IExecutionResponse | null {
return this.$store.getters.getWorkflowExecution;
},
workflowName (): string {
return this.$store.getters.workflowName;
},
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
},
methods: {
clearExecutionData () {
this.$store.commit('setWorkflowExecutionData', null);
this.updateNodesExecutionIssues();
},
async openWorkflow (workflowId: string) {
// Change to other workflow
this.$router.push({
name: 'NodeViewExisting',
params: { name: workflowId },
});
},
},
async mounted () {
// Initialize the push connection
this.pushConnect();
},
beforeDestroy () {
this.pushDisconnect();
},
});
</script>
<style lang="scss">
.el-menu--horizontal>.el-menu-item,
.el-menu--horizontal>.el-submenu .el-submenu__title,
.el-menu-item {
height: 65px;
line-height: 65px;
}
.el-submenu .el-submenu__title,
.el-menu--horizontal>.el-menu-item,
.el-menu.el-menu--horizontal {
border: none !important;
}
.el-menu--popup-bottom-start {
margin-top: 0px;
border-top: 1px solid #464646;
border-radius: 0 0 2px 2px;
}
.main-header {
position: fixed;
top: 0;
background-color: #fff;
height: 65px;
width: 100%;
}
.top-menu {
position: relative;
font-size: 0.9em;
width: 100%;
font-weight: 400;
.center-item {
margin: 0 auto;
text-align: center;
line-height: 65px;
.saving-workflow {
display: inline-block;
margin-left: 2em;
padding: 0 15px;
color: $--color-primary;
background-color: $--color-primary-light;
line-height: 30px;
height: 30px;
border-radius: 15px;
}
}
.read-only {
position: absolute;
top: 0;
line-height: 65px;
margin-right: 5em;
right: 0;
color: $--color-primary;
}
.push-connection-lost {
position: absolute;
top: 0;
line-height: 65px;
margin-right: 5em;
right: 0;
color: $--color-primary;
}
.workflow-active {
position: absolute;
top: 0;
line-height: 65px;
margin-right: 5em;
right: 0;
}
.workflow-name {
color: $--color-primary;
}
}
</style>
<style scoped lang="scss">
.current-execution,
.current-workflow {
vertical-align: top;
}
.execution-icon.error,
.workflow-not-saved {
color: #FF2244;
}
.execution-icon.success {
color: #22FF44;
}
.menu-separator-bottom {
border-bottom: 1px solid #707070;
}
.menu-separator-top {
border-top: 1px solid #707070;
}
.clear-execution {
position: absolute;
top: calc(50% - 19px);
line-height: 65px;
right: 200px;
width: 38px;
height: 38px;
line-height: 38px;
font-size: 18px;
text-align: center;
border-radius: 19px;
background-color: $--color-primary-light;
color: $--color-primary;
&:hover {
transform: scale(1.1);
}
.clear-execution-icon {
color: #f56c6c;
}
}
</style>

View File

@@ -0,0 +1,459 @@
<template>
<div id="side-menu">
<executions-list :dialogVisible="executionsListDialogVisible" @closeDialog="closeExecutionsListOpenDialog"></executions-list>
<credentials-list :dialogVisible="credentialOpenDialogVisible" @closeDialog="closeCredentialOpenDialog"></credentials-list>
<credentials-edit :dialogVisible="credentialNewDialogVisible" @closeDialog="closeCredentialNewDialog"></credentials-edit>
<workflow-open @openWorkflow="openWorkflow" :dialogVisible="workflowOpenDialogVisible" @closeDialog="closeWorkflowOpenDialog"></workflow-open>
<workflow-settings :dialogVisible="workflowSettingsDialogVisible" @closeDialog="closeWorkflowSettingsDialog"></workflow-settings>
<input type="file" ref="importFile" style="display: none" v-on:change="handleFileImport()">
<div class="side-menu-wrapper" :class="{expanded: !isCollapsed}">
<div id="collapse-change-button" class="clickable" @click="isCollapsed=!isCollapsed">
<font-awesome-icon icon="angle-right" class="icon" />
</div>
<el-menu default-active="workflow" @select="handleSelect" :collapse="isCollapsed">
<el-menu-item index="logo" class="logo-item">
<img src="/n8n-icon-small.png" class="icon" alt="n8n.io"/>
<a href="https://n8n.io" class="logo-text" target="_blank" slot="title">
n8n.io
</a>
</el-menu-item>
<el-submenu index="workflow">
<template slot="title">
<font-awesome-icon icon="network-wired"/>&nbsp;
<span slot="title" class="item-title-root">Workflows</span>
</template>
<el-menu-item index="workflow-new">
<template slot="title">
<font-awesome-icon icon="file"/>&nbsp;
<span slot="title" class="item-title">New</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-open">
<template slot="title">
<font-awesome-icon icon="folder-open"/>&nbsp;
<span slot="title" class="item-title">Open</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-save" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="save"/>
<span slot="title" class="item-title">Save</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-save-as">
<template slot="title">
<font-awesome-icon icon="copy"/>
<span slot="title" class="item-title">Save As</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-delete" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="trash"/>
<span slot="title" class="item-title">Delete</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-download">
<template slot="title">
<font-awesome-icon icon="file-download"/>
<span slot="title" class="item-title">Download</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-import-url">
<template slot="title">
<font-awesome-icon icon="cloud"/>
<span slot="title" class="item-title">Import from URL</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-import-file">
<template slot="title">
<font-awesome-icon icon="hdd"/>
<span slot="title" class="item-title">Import from File</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-settings" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="cog"/>
<span slot="title" class="item-title">Settings</span>
</template>
</el-menu-item>
</el-submenu>
<el-submenu index="credentials">
<template slot="title">
<font-awesome-icon icon="key"/>&nbsp;
<span slot="title" class="item-title-root">Credentials</span>
</template>
<el-menu-item index="credentials-new">
<template slot="title">
<font-awesome-icon icon="file"/>
<span slot="title" class="item-title">New</span>
</template>
</el-menu-item>
<el-menu-item index="credentials-open">
<template slot="title">
<font-awesome-icon icon="folder-open"/>
<span slot="title" class="item-title">Open</span>
</template>
</el-menu-item>
</el-submenu>
<el-menu-item index="executions">
<font-awesome-icon icon="tasks"/>&nbsp;
<span slot="title" class="item-title-root">Executions</span>
</el-menu-item>
</el-menu>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IExecutionResponse,
IExecutionsStopData,
IWorkflowDataUpdate,
} from '../Interface';
import CredentialsEdit from '@/components/CredentialsEdit.vue';
import CredentialsList from '@/components/CredentialsList.vue';
import ExecutionsList from '@/components/ExecutionsList.vue';
import WorkflowOpen from '@/components/WorkflowOpen.vue';
import WorkflowSettings from '@/components/WorkflowSettings.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { workflowRun } from '@/components/mixins/workflowRun';
import { saveAs } from 'file-saver';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
restApi,
showMessage,
workflowHelpers,
workflowRun,
)
.extend({
name: 'MainHeader',
components: {
CredentialsEdit,
CredentialsList,
ExecutionsList,
WorkflowOpen,
WorkflowSettings,
},
data () {
return {
isCollapsed: true,
credentialNewDialogVisible: false,
credentialOpenDialogVisible: false,
executionsListDialogVisible: false,
stopExecutionInProgress: false,
workflowOpenDialogVisible: false,
workflowSettingsDialogVisible: false,
};
},
computed: {
exeuctionId (): string | undefined {
return this.$route.params.id;
},
executionFinished (): boolean {
if (!this.isExecutionPage) {
// We are not on an exeuction page so return false
return false;
}
const fullExecution = this.$store.getters.getWorkflowExecution;
if (fullExecution === null) {
// No exeuction loaded so return also false
return false;
}
if (fullExecution.finished === true) {
return true;
}
return false;
},
executionWaitingForWebhook (): boolean {
return this.$store.getters.executionWaitingForWebhook;
},
isExecutionPage (): boolean {
if (['ExecutionById'].includes(this.$route.name as string)) {
return true;
}
return false;
},
isWorkflowActive (): boolean {
return this.$store.getters.isActive;
},
currentWorkflow (): string {
return this.$route.params.name;
},
workflowExecution (): IExecutionResponse | null {
return this.$store.getters.getWorkflowExecution;
},
workflowName (): string {
return this.$store.getters.workflowName;
},
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
},
methods: {
clearExecutionData () {
this.$store.commit('setWorkflowExecutionData', null);
this.updateNodesExecutionIssues();
},
closeWorkflowOpenDialog () {
this.workflowOpenDialogVisible = false;
},
closeWorkflowSettingsDialog () {
this.workflowSettingsDialogVisible = false;
},
closeExecutionsListOpenDialog () {
this.executionsListDialogVisible = false;
},
closeCredentialOpenDialog () {
this.credentialOpenDialogVisible = false;
},
closeCredentialNewDialog () {
this.credentialNewDialogVisible = false;
},
async stopExecution () {
const executionId = this.$store.getters.activeExecutionId;
if (executionId === null) {
return;
}
try {
this.stopExecutionInProgress = true;
const stopData: IExecutionsStopData = await this.restApi().stopCurrentExecution(executionId);
this.$showMessage({
title: 'Execution stopped',
message: `The execution with the id "${executionId}" got stopped!`,
type: 'success',
});
} catch (error) {
this.$showError(error, 'Problem stopping execution', 'There was a problem stopping the execuction:');
}
this.stopExecutionInProgress = false;
},
async openWorkflow (workflowId: string) {
// Change to other workflow
this.$router.push({
name: 'NodeViewExisting',
params: { name: workflowId },
});
this.workflowOpenDialogVisible = false;
},
async handleFileImport () {
const reader = new FileReader();
reader.onload = (event: ProgressEvent) => {
const data = (event.target as FileReader).result;
let worflowData: IWorkflowDataUpdate;
try {
worflowData = JSON.parse(data as string);
} catch (error) {
this.$showMessage({
title: 'Could not import file',
message: `The file does not contain valid JSON data.`,
type: 'error',
});
return;
}
this.$root.$emit('importWorkflowData', { data: worflowData });
};
const input = this.$refs.importFile as HTMLInputElement;
if (input !== null && input.files !== null && input.files.length !== 0) {
reader.readAsText(input!.files[0]!);
}
},
async handleSelect (key: string, keyPath: string) {
if (key === 'workflow-open') {
this.workflowOpenDialogVisible = true;
} else if (key === 'workflow-import-file') {
(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,
});
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!');
if (deleteConfirmed === false) {
return;
}
let result;
try {
result = await this.restApi().deleteWorkflow(this.currentWorkflow);
} catch (error) {
this.$showError(error, 'Problem deleting the workflow', 'There was a problem deleting the workflow:');
return;
}
this.$showMessage({
title: 'Workflow got deleted',
message: `The workflow "${this.workflowName}" got deleted!`,
type: 'success',
});
this.$router.push({ name: 'NodeViewNew' });
} else if (key === 'workflow-download') {
const workflowData = await this.getWorkflowDataToSave();
const blob = new Blob([JSON.stringify(workflowData, null, 2)], {
type: 'application/json;charset=utf-8',
});
let workflowName = this.$store.getters.workflowName || 'unsaved_workflow';
workflowName = workflowName.replace(/[^a-z0-9]/gi, '_');
saveAs(blob, workflowName + '.json');
} else if (key === 'workflow-save') {
this.saveCurrentWorkflow();
} else if (key === 'workflow-save-as') {
this.saveCurrentWorkflow(true);
} else if (key === 'workflow-settings') {
this.workflowSettingsDialogVisible = true;
} else if (key === 'workflow-new') {
this.$router.push({ name: 'NodeViewNew' });
this.$showMessage({
title: 'Workflow created',
message: 'A new workflow got created!',
type: 'success',
});
} else if (key === 'credentials-open') {
this.credentialOpenDialogVisible = true;
} else if (key === 'credentials-new') {
this.credentialNewDialogVisible = true;
} else if (key === 'execution-open-workflow') {
if (this.workflowExecution !== null) {
this.openWorkflow(this.workflowExecution.workflowId as string);
}
} else if (key === 'executions') {
this.executionsListDialogVisible = true;
}
},
},
async mounted () {
this.$root.$on('openWorkflowDialog', async () => {
this.workflowOpenDialogVisible = true;
});
},
});
</script>
<style lang="scss">
#collapse-change-button {
position: absolute;
z-index: 10;
top: 55px;
left: 25px;
text-align: right;
line-height: 24px;
height: 20px;
width: 20px;
background-color: #fff;
border: none;
border-radius: 15px;
-webkit-transition-duration: 0.5s;
-moz-transition-duration: 0.5s;
-o-transition-duration: 0.5s;
transition-duration: 0.5s;
-webkit-transition-property: -webkit-transform;
-moz-transition-property: -moz-transform;
-o-transition-property: -o-transform;
transition-property: transform;
overflow: hidden;
.icon {
position: relative;
left: -5px;
top: -2px;
}
}
#collapse-change-button:hover {
transform: scale(1.1);
}
.el-menu-item.logo-item {
background-color: $--color-primary !important;
height: 65px;
.icon {
position: relative;
height: 23px;
left: -10px;
top: -2px;
}
}
a.logo-text {
position: relative;
top: -3px;
left: 5px;
font-weight: bold;
color: #fff;
text-decoration: none;
}
.expanded #collapse-change-button {
-webkit-transform: translateX(60px) rotate(180deg);
-moz-transform: translateX(60px) rotate(180deg);
-o-transform: translateX(60px) rotate(180deg);
transform: translateX(60px) rotate(180deg);
}
#side-menu {
position: fixed;
height: 100%;
.el-menu {
height: 100%;
}
}
.side-menu-wrapper {
height: 100%;
width: 65px;
&.expanded {
width: 200px;
}
}
</style>

View File

@@ -0,0 +1,171 @@
<template>
<div @keydown.stop class="duplicate-parameter">
<div class="parameter-name">
{{parameter.displayName}}:
</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" title="Delete Item" @click="deleteItem(index)">
<font-awesome-icon icon="trash" />
</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" />
</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>
<el-button v-if="!isReadOnly" size="small" class="add-item" @click="addItem()">{{ addButtonText }}</el-button>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IUpdateInformation,
} from '@/Interface';
import CollectionParameter from '@/components/CollectionParameter.vue';
import ParameterInput from '@/components/ParameterInput.vue';
import { get } from 'lodash';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(genericHelpers)
.extend({
name: 'MultipleParameter',
components: {
CollectionParameter,
ParameterInput,
},
props: [
'nodeValues', // NodeParameters
'parameter', // NodeProperties
'path', // string
'values', // NodeParameters[]
],
computed: {
addButtonText (): string {
return (this.parameter.typeOptions && this.parameter.typeOptions.multipleValueButtonText) ? this.parameter.typeOptions.multipleValueButtonText : 'Add item';
},
hideDelete (): boolean {
return this.parameter.options.length === 1;
},
},
methods: {
addItem () {
const name = this.getPath();
let currentValue = get(this.nodeValues, name);
if (currentValue === undefined) {
currentValue = [];
}
currentValue.push(JSON.parse(JSON.stringify(this.parameter.default)));
const parameterData = {
name,
value: currentValue,
};
this.$emit('valueChanged', parameterData);
},
deleteItem (index: number) {
const parameterData = {
name: this.getPath(index),
value: null,
};
this.$emit('valueChanged', parameterData);
},
getPath (index?: number): string {
return this.path + (index !== undefined ? `[${index}]` : '');
},
valueChanged (parameterData: IUpdateInformation) {
this.$emit('valueChanged', parameterData);
},
},
});
</script>
<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;
}
.add-item {
width: 100%;
}
.delete-item {
display: none;
position: absolute;
left: 0.1em;
top: .3em;
z-index: 999;
color: #f56c6c;
:hover {
color: #ff0000;
}
}
.duplicate-parameter {
margin-top: 0.5em;
.parameter-name {
border-bottom: 1px solid #999;
}
}
/deep/ .duplicate-parameter-item {
position: relative;
margin-top: 0.5em;
padding-top: 0.5em;
.multi > .delete-item{
top: 0.1em;
}
}
/deep/ .duplicate-parameter-input-item {
margin: 0.5em 0 0.25em 1em;
}
/deep/ .duplicate-parameter-item + .duplicate-parameter-item {
.collection-parameter-wrapper {
border-top: 1px dashed #999;
padding-top: 0.5em;
}
}
.no-items-exist {
margin: 0 0 1em 0;
}
</style>
<style>
.duplicate-parameter-item:hover > .delete-item {
display: inline;
}
.duplicate-parameter-item .multi > .delete-item{
top: 0.1em;
}
</style>

View File

@@ -0,0 +1,406 @@
<template>
<div class="node-default" :style="nodeStyle" :class="nodeClass" :ref="data.name" @dblclick="setNodeActive" @click.left="mouseLeftClick">
<div v-if="hasIssues" class="node-info-icon node-issues">
<el-tooltip placement="top" effect="light">
<div slot="content" v-html="nodeIssues"></div>
<font-awesome-icon icon="exclamation-triangle" />
</el-tooltip>
</div>
<el-badge v-else :hidden="workflowDataItems === 0" class="node-info-icon data-count" :value="workflowDataItems"></el-badge>
<div class="node-executing-info" title="Node is executing">
<font-awesome-icon icon="spinner" spin />
</div>
<div class="node-execute" v-if="!isReadOnly && !workflowRunning">
<font-awesome-icon class="execute-icon" @click.stop.left="executeNode" icon="play-circle" title="Execute Node"/>
</div>
<div class="node-options" v-if="!isReadOnly">
<div @click.stop.left="deleteNode" class="option indent" title="Delete Node" >
<font-awesome-icon icon="trash" />
</div>
<div @click.stop.left="duplicateNode" class="option" title="Duplicate Node" >
<font-awesome-icon icon="clone" />
</div>
<div @click.stop.left="disableNode" class="option indent" title="Activate/Deactivate Node" >
<font-awesome-icon :icon="nodeDisabledIcon" />
</div>
</div>
<NodeIcon class="node-icon" :nodeType="nodeType" :style="nodeIconStyle"/>
<div class="node-name" :title="data.name">
{{data.name}}
</div>
<div v-if="nodeOperation !== null" class="node-operation" :title="nodeOperation">
{{nodeOperation}}
</div>
<div class="node-edit" @click.left.stop="setNodeActive" title="Edit Node">
<font-awesome-icon icon="pen" />
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { nodeBase } from '@/components/mixins/nodeBase';
import {
INodeIssueObjectProperty,
INodePropertyOptions,
INodeTypeDescription,
ITaskData,
NodeHelpers,
} from 'n8n-workflow';
import NodeIcon from '@/components/NodeIcon.vue';
import mixins from 'vue-typed-mixins';
export default mixins(nodeBase).extend({
name: 'Node',
components: {
NodeIcon,
},
computed: {
workflowResultDataNode (): ITaskData[] | null {
return this.$store.getters.getWorkflowResultDataByNodeName(this.data.name);
},
workflowDataItems () {
if (this.workflowResultDataNode === null) {
return 0;
}
return this.workflowResultDataNode.length;
},
isExecuting (): boolean {
return this.$store.getters.executingNode === this.data.name;
},
nodeIconStyle (): object {
return {
color: this.data.disabled ? '#ccc' : this.data.color,
};
},
nodeType (): INodeTypeDescription | null {
return this.$store.getters.nodeType(this.data.type);
},
nodeClass () {
const classes = [];
if (this.data.disabled) {
classes.push('disabled');
}
if (this.nodeOperation) {
classes.push('has-operation');
}
if (this.isExecuting) {
classes.push('executing');
}
if (this.workflowDataItems !== 0) {
classes.push('has-data');
}
return classes;
},
nodeIssues (): string {
if (this.data.issues === undefined) {
return '';
}
const nodeIssues = NodeHelpers.nodeIssuesToString(this.data.issues, this.data);
return 'Issues:<br />&nbsp;&nbsp;- ' + nodeIssues.join('<br />&nbsp;&nbsp;- ');
},
nodeDisabledIcon (): string {
if (this.data.disabled === false) {
return 'pause';
} else {
return 'play';
}
},
nodeOperation (): string | null {
if (this.data.parameters.operation !== undefined) {
const operation = this.data.parameters.operation as string;
if (this.nodeType === null) {
return operation;
}
const operationData = this.nodeType.properties.find((property) => {
return property.name === 'operation';
});
if (operationData === undefined) {
return operation;
}
if (operationData.options === undefined) {
return operation;
}
const optionData = operationData.options.find((option) => {
return (option as INodePropertyOptions).value === this.data.parameters.operation;
});
if (optionData === undefined) {
return operation;
}
return optionData.name;
}
return null;
},
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
},
data () {
return {
};
},
methods: {
disableNode () {
// Toggle disabled flag
const updateInformation = {
name: this.data.name,
properties: {
disabled: !this.data.disabled,
},
};
this.$store.commit('updateNodeProperties', updateInformation);
},
executeNode () {
this.$emit('runWorkflow', this.data.name);
},
deleteNode () {
Vue.nextTick(() => {
// Wait a tick else vue causes problems because the data is gone
this.$emit('removeNode', this.data.name);
});
},
duplicateNode () {
Vue.nextTick(() => {
// Wait a tick else vue causes problems because the data is gone
this.$emit('duplicateNode', this.data.name);
});
},
setNodeActive () {
this.$store.commit('setActiveNode', this.data.name);
},
},
});
</script>
<style lang="scss">
.node-default {
position: absolute;
width: 160px;
height: 50px;
background-color: #fff;
border-radius: 25px;
text-align: center;
z-index: 24;
cursor: pointer;
color: #444;
line-height: 50px;
font-size: 0.8em;
font-weight: 600;
border: 1px dashed grey;
&.has-data {
border-style: solid;
}
&.has-operation {
line-height: 38px;
.node-info-icon {
top: -22px;
&.data-count {
top: -15px;
}
}
}
&.disabled {
color: #a0a0a0;
text-decoration: line-through;
border: 1px solid #eee !important;
background-color: #eee;
}
&.executing {
background-color: $--color-primary-light !important;
border-color: $--color-primary !important;
.node-executing-info {
display: initial;
}
}
&:hover {
.node-execute {
display: initial;
}
.node-options {
display: initial;
}
}
.node-edit {
position: absolute;
top: 0;
right: 0;
width: 50px;
height: 100%;
font-size: 1.1em;
color: #ccc;
border-radius: 0 25px 25px 0;
&:hover {
color: #00cc00;
}
.svg-inline--fa {
height: 100%;
}
}
.node-execute {
display: none;
position: absolute;
right: -25px;
width: 45px;
line-height: 50px;
font-size: 1.5em;
text-align: right;
z-index: 10;
color: #aaa;
.execute-icon:hover {
color: $--color-primary;
}
}
.node-executing-info {
display: none;
position: absolute;
right: -35px;
top: 8px;
z-index: 12;
width: 30px;
height: 30px;
line-height: 30px;
font-size: 18px;
text-align: center;
border-radius: 15px;
background-color: $--color-primary-light;
color: $--color-primary;
}
.node-icon {
position: absolute;
top: 0;
height: 30px;
margin: 10px;
}
.node-info-icon {
position: absolute;
top: -28px;
right: 18px;
z-index: 10;
&.data-count {
top: -22px;
}
}
.node-issues {
width: 25px;
height: 25px;
font-size: 20px;
color: #ff0000;
}
.node-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0 37px;
}
.node-operation {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: -23px 20px 0 20px;
font-weight: 400;
color: $--custom-font-light;
font-size: 0.9em;
}
.node-options {
display: none;
position: absolute;
left: -28px;
width: 45px;
top: -8px;
line-height: 1.8em;
font-size: 12px;
text-align: left;
z-index: 10;
color: #aaa;
.option {
width: 20px;
text-align: center;
&:hover {
color: $--color-primary;
}
&.indent {
margin-left: 7px;
}
}
}
}
</style>
<style>
.el-badge__content {
border-width: 2px;
background-color: #67c23a;
}
.jtk-connector {
z-index:4;
}
.jtk-endpoint {
z-index:5;
}
.jtk-overlay {
z-index:6;
}
.jtk-endpoint.dropHover {
border: 2px solid #ff2244;
}
.node-default.jtk-drag-selected {
/* 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);
}
.disabled .node-icon img {
-webkit-filter: contrast(40%) brightness(1.5) grayscale(100%);
filter: contrast(40%) brightness(1.5) grayscale(100%);
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="node-item clickable" :class="{active: active}" @click="nodeTypeSelected(nodeType)">
<NodeIcon class="node-icon" :nodeType="nodeType"/>
<div class="name">
{{nodeType.displayName}}
</div>
<div class="description">
{{nodeType.description}}
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { INodeTypeDescription } from 'n8n-workflow';
import NodeIcon from '@/components/NodeIcon.vue';
export default Vue.extend({
name: 'NodeCreateItem',
components: {
NodeIcon,
},
props: [
'active',
'filter',
'nodeType',
],
data () {
return {
};
},
methods: {
nodeTypeSelected (nodeType: INodeTypeDescription) {
this.$emit('nodeTypeSelected', nodeType.name);
},
},
});
</script>
<style scoped lang="scss">
.node-item {
position: relative;
border-bottom: 1px solid #eee;
background-color: #fff;
padding: 6px;
border-left: 3px solid #fff;
&:hover {
border-left: 3px solid #ccc;
}
}
.active {
border-left: 3px solid $--color-primary;
}
.node-icon {
display: inline-block;
position: absolute;
left: 12px;
top: calc(50% - 15px);
}
.name {
font-weight: bold;
font-size: 0.9em;
padding-left: 50px;
}
.description {
margin-top: 3px;
line-height: 1.7em;
font-size: 0.8em;
padding-left: 50px;
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<div>
<div class="input-wrapper">
<el-input placeholder="Type to filter..." v-model="nodeFilter" ref="inputField" size="small" type="text" prefix-icon="el-icon-search" @keydown.native="nodeFilterKeyDown" clearable ></el-input>
</div>
<div class="type-selector">
<el-tabs v-model="selectedType" stretch>
<el-tab-pane label="Regular" name="Regular"></el-tab-pane>
<el-tab-pane label="Trigger" name="Trigger"></el-tab-pane>
<el-tab-pane label="All" name="All"></el-tab-pane>
</el-tabs>
</div>
<div class="node-create-list-wrapper">
<div class="node-create-list">
<div v-if="filteredNodeTypes.length === 0" class="no-results">
No node found which matches active filter!
</div>
<node-create-item :active="index === activeNodeTypeIndex" :nodeType="nodeType" v-for="(nodeType, index) in filteredNodeTypes" v-bind:key="nodeType.name" @nodeTypeSelected="nodeTypeSelected"></node-create-item>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { INodeTypeDescription } from 'n8n-workflow';
import NodeCreateItem from '@/components/NodeCreateItem.vue';
export default Vue.extend({
name: 'NodeCreateList',
components: {
NodeCreateItem,
},
data () {
return {
activeNodeTypeIndex: 0,
nodeFilter: '',
selectedType: 'Regular',
};
},
computed: {
nodeTypes (): INodeTypeDescription[] {
return this.$store.getters.allNodeTypes;
},
filteredNodeTypes () {
const filter = this.nodeFilter.toLowerCase();
const nodeTypes: INodeTypeDescription[] = this.$store.getters.allNodeTypes;
// Apply the filters
const returnData = nodeTypes.filter((nodeType) => {
if (filter && nodeType.displayName.toLowerCase().indexOf(filter) === -1) {
return false;
}
if (this.selectedType !== 'All') {
if (this.selectedType === 'Trigger' && !nodeType.group.includes('trigger')) {
return false;
} else if (this.selectedType === 'Regular' && nodeType.group.includes('trigger')) {
return false;
}
}
return true;
});
// Sort the node types
let textA, textB;
returnData.sort((a, b) => {
textA = a.displayName.toLowerCase();
textB = b.displayName.toLowerCase();
return (textA < textB) ? -1 : (textA > textB) ? 1 : 0;
});
return returnData;
},
},
watch: {
nodeFilter (newVal, oldVal) {
// Reset the index whenver the filter-value changes
this.activeNodeTypeIndex = 0;
},
},
methods: {
nodeFilterKeyDown (e: KeyboardEvent) {
const activeNodeType = this.filteredNodeTypes[this.activeNodeTypeIndex];
if (e.key === 'ArrowDown') {
this.activeNodeTypeIndex++;
// Make sure that we stop at the last nodeType
this.activeNodeTypeIndex = Math.min(this.activeNodeTypeIndex, this.filteredNodeTypes.length - 1);
} else if (e.key === 'ArrowUp') {
this.activeNodeTypeIndex--;
// Make sure that we do not get before the first nodeType
this.activeNodeTypeIndex = Math.max(this.activeNodeTypeIndex, 0);
} else if (e.key === 'Enter' && activeNodeType) {
console.log('enter');
this.nodeTypeSelected(activeNodeType.name);
}
if (!['Escape', 'Tab'].includes(e.key)) {
// We only want to propagate "Escape" as it closes the node-creator and
// "Tab" which toggles it
e.stopPropagation();
}
},
nodeTypeSelected (nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
},
});
</script>
<style scoped>
.node-create-list-wrapper {
position: absolute;
top: 160px;
left: 0px;
right: 0px;
bottom: 0;
overflow-y: auto;
overflow-x: hidden;
background-color: #fff;
}
.node-create-list {
position: relative;
width: 100%;
}
.group-name {
font-size: 0.9em;
padding: 15px 0 5px 10px;
}
.input-wrapper >>> .el-input__inner,
.input-wrapper >>> .el-input__inner:hover {
background-color: #fff;
}
.input-wrapper {
margin: 10px;
height: 35px;
}
.type-selector {
height: 50px;
text-align: center;
}
.type-selector >>> .el-tabs__nav {
padding-bottom: 10px;
}
.no-results {
margin: 20px 10px 0 10px;
line-height: 1.5em;
text-align: center;
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="node-creator-wrapper">
<transition name="el-zoom-in-top">
<div class="node-creator" v-show="active">
<div class="close-button clickable close-on-click" @click="closeCreator" title="Close">
<i class="el-icon-close close-on-click"></i>
</div>
<div class="header">
Create Node
</div>
<node-create-list v-if="active" ref="list" @nodeTypeSelected="nodeTypeSelected"></node-create-list>
</div>
</transition>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import NodeCreateList from '@/components/NodeCreateList.vue';
export default Vue.extend({
name: 'NodeCreator',
components: {
NodeCreateList,
},
props: [
'active',
],
data () {
return {
};
},
watch: {
active (newValue, oldValue) {
if (newValue === true) {
// Try to set focus directly on the filter-input-field
setTimeout(() => {
// @ts-ignore
if (this.$refs.list && this.$refs.list.$refs.inputField) {
// @ts-ignore
this.$refs.list.$refs.inputField.focus();
}
});
}
},
},
methods: {
closeCreator () {
this.$emit('closeNodeCreator');
},
nodeTypeSelected (nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
},
});
</script>
<style scoped lang="scss">
.close-button {
position: absolute;
top: 0;
left: -50px;
color: #fff;
background-color: $--custom-header-background;
border-radius: 18px 0 0 18px;
z-index: 110;
font-size: 1.7em;
text-align: center;
line-height: 50px;
height: 50px;
width: 50px;
.close-on-click {
color: #fff;
font-weight: 400;
&:hover {
transform: scale(1.2);
}
}
}
.node-creator {
position: fixed;
top: 65px;
right: 0;
width: 350px;
height: calc(100% - 65px);
background-color: #fff4f1;
z-index: 200;
color: #555;
.header {
font-size: 1.2em;
margin: 20px 15px;
height: 25px;
}
}
</style>

View File

@@ -0,0 +1,316 @@
<template>
<div v-if="credentialTypesNodeDescriptionDisplayed.length" class="node-credentials">
<credentials-edit :dialogVisible="credentialNewDialogVisible" :editCredentials="editCredentials" :setCredentialType="addType" :nodesInit="nodesInit" @closeDialog="closeCredentialNewDialog" @credentialsCreated="credentialsCreated" @credentialsUpdated="credentialsUpdated"></credentials-edit>
<div class="headline">
Credentials
</div>
<div v-for="credentialTypeDescription in credentialTypesNodeDescriptionDisplayed" :key="credentialTypeDescription.name" class="credential-data">
<el-row v-if="displayCredentials(credentialTypeDescription)">
<el-col :span="10" class="parameter-name">
{{credentialTypeNames[credentialTypeDescription.name]}}:
</el-col>
<el-col :span="12" class="parameter-value" :class="getIssues(credentialTypeDescription.name).length?'has-issues':''">
<div class="credential-issues">
<el-tooltip placement="top" effect="light">
<div slot="content" v-html="'Issues:<br />&nbsp;&nbsp;- ' + getIssues(credentialTypeDescription.name).join('<br />&nbsp;&nbsp;- ')"></div>
<font-awesome-icon icon="exclamation-triangle" />
</el-tooltip>
</div>
<div :style="credentialInputWrapperStyle(credentialTypeDescription.name)">
<el-select v-model="credentials[credentialTypeDescription.name]" :disabled="isReadOnly" @change="credentialSelected(credentialTypeDescription.name)" placeholder="Select Credential" size="small">
<el-option
v-for="(item, index) in credentialOptions[credentialTypeDescription.name]"
:key="item.name + '_' + index"
:label="item.name"
:value="item.name">
</el-option>
</el-select>
</div>
</el-col>
<el-col :span="2" class="parameter-value">
<font-awesome-icon v-if="credentials[credentialTypeDescription.name]" icon="pen" @click="updateCredentials(credentialTypeDescription.name)" class="update-credentials clickable" title="Update Credentials" />
</el-col>
</el-row>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { restApi } from '@/components/mixins/restApi';
import {
ICredentialsResponse,
INodeUi,
INodeUpdatePropertiesInformation,
IUpdateInformation,
} from '@/Interface';
import {
ICredentialType,
INodeCredentialDescription,
INodeTypeDescription,
} from 'n8n-workflow';
import CredentialsEdit from '@/components/CredentialsEdit.vue';
import ParameterInput from '@/components/ParameterInput.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
nodeHelpers,
restApi,
showMessage,
).extend({
name: 'NodeCredentials',
props: [
'node', // INodeUi
],
components: {
CredentialsEdit,
ParameterInput,
},
computed: {
credentialTypesNode (): string[] {
return this.credentialTypesNodeDescription
.map((credentialTypeDescription) => credentialTypeDescription.name);
},
credentialTypesNodeDescriptionDisplayed (): INodeCredentialDescription[] {
return this.credentialTypesNodeDescription
.filter((credentialTypeDescription) => {
return this.displayCredentials(credentialTypeDescription);
});
},
credentialTypesNodeDescription (): INodeCredentialDescription[] {
const node = this.node as INodeUi;
const activeNodeType = this.$store.getters.nodeType(node.type) as INodeTypeDescription;
if (activeNodeType && activeNodeType.credentials) {
return activeNodeType.credentials;
}
return [];
},
credentialTypeNames () {
const returnData: {
[key: string]: string;
} = {};
let credentialType: ICredentialType | null;
for (const credentialTypeName of this.credentialTypesNode) {
credentialType = this.$store.getters.credentialType(credentialTypeName);
returnData[credentialTypeName] = credentialType !== null ? credentialType.displayName : credentialTypeName;
}
return returnData;
},
},
data () {
return {
addType: undefined as string | undefined,
credentialNewDialogVisible: false,
credentialOptions: {} as { [key: string]: ICredentialsResponse[]; },
credentials: {} as {
[key: string]: string | undefined
},
editCredentials: null as object | null, // Credentials filter
newCredentialText: '- Create New -',
nodesInit: undefined as string[] | undefined,
};
},
watch: {
node () {
this.init();
},
},
methods: {
closeCredentialNewDialog () {
this.credentialNewDialogVisible = false;
},
async credentialsCreated (data: ICredentialsResponse) {
await this.credentialsUpdated(data);
},
credentialsUpdated (data: ICredentialsResponse) {
if (!this.credentialTypesNode.includes(data.type)) {
return;
}
this.init();
Vue.set(this.credentials, data.type, data.name);
// Makes sure that it does also get set correctly on the node not just the UI
this.credentialSelected(data.type);
this.closeCredentialNewDialog();
},
credentialInputWrapperStyle (credentialType: string) {
let deductWidth = 0;
const styles = {
width: '100%',
};
if (this.getIssues(credentialType).length) {
deductWidth += 20;
}
if (deductWidth !== 0) {
styles.width = `calc(100% - ${deductWidth}px)`;
}
return styles;
},
credentialSelected (credentialType: string) {
const credential = this.credentials[credentialType];
if (credential === this.newCredentialText) {
// New credentials should be created
this.addType = credentialType;
this.editCredentials = null;
this.nodesInit = [ (this.node as INodeUi).type ];
this.credentialNewDialogVisible = true;
this.credentials[credentialType] = undefined;
}
const node = this.node as INodeUi;
const updateInformation: INodeUpdatePropertiesInformation = {
name: node.name,
properties: {
credentials: JSON.parse(JSON.stringify(this.credentials)),
},
};
this.$emit('credentialSelected', updateInformation);
},
displayCredentials (credentialTypeDescription: INodeCredentialDescription): boolean {
if (credentialTypeDescription.displayOptions === undefined) {
// If it is not defined no need to do a proper check
return true;
}
return this.displayParameter(this.node.parameters, credentialTypeDescription, '');
},
getIssues (credentialTypeName: string): string[] {
const node = this.node as INodeUi;
if (node.issues === undefined || node.issues.credentials === undefined) {
return [];
}
if (!node.issues.credentials.hasOwnProperty(credentialTypeName)) {
return [];
}
return node.issues.credentials[credentialTypeName];
},
updateCredentials (credentialType: string): void {
const credentials = this.credentials[credentialType];
const name = this.credentials[credentialType];
const credentialData = this.credentialOptions[credentialType].find((optionData: ICredentialsResponse) => optionData.name === name);
if (credentialData === undefined) {
this.$showMessage({
title: 'Credentials not found',
message: `The credentials named "${name}" of type "${credentialType}" could not be found!`,
type: 'error',
});
return;
}
const editCredentials = {
id: credentialData.id,
name,
type: credentialType,
};
this.editCredentials = editCredentials;
this.addType = credentialType;
this.credentialNewDialogVisible = true;
},
init () {
const node = this.node as INodeUi;
const newOption = {
name: this.newCredentialText,
};
let options = [];
// Get the available credentials for each type
for (const credentialType of this.credentialTypesNode) {
options = this.$store.getters.credentialsByType(credentialType);
options.push(newOption as ICredentialsResponse);
Vue.set(this.credentialOptions, credentialType, options);
}
// Set the current node credentials
if (node.credentials) {
Vue.set(this, 'credentials', JSON.parse(JSON.stringify(node.credentials)));
} else {
Vue.set(this, 'credentials', {});
}
},
},
mounted () {
this.init();
},
});
</script>
<style lang="scss">
.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;
}
.el-input input:hover {
border-width: 1px;
border-color: #ff8080;
border-style: solid;
}
}
.headline {
font-weight: bold;
margin-bottom: 0.7em;
}
.parameter-name {
line-height: 2em;
font-weight: 400;
}
.update-credentials {
position: absolute;
top: 7px;
right: 3px;
}
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div class="node-icon-wrapper">
<div v-if="nodeIconData !== null" class="icon">
<img :src="nodeIconData.path" style="width: 100%; height: 100%;" v-if="nodeIconData.type === 'file'"/>
<font-awesome-icon :icon="nodeIconData.path" v-else-if="nodeIconData.type === 'fa'" />
</div>
<div v-else class="node-icon-placeholder">
{{nodeType !== null ? nodeType.displayName.charAt(0) : '?' }}
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
interface NodeIconData {
type: string;
path: string;
}
export default Vue.extend({
name: 'NodeIcon',
props: [
'nodeType',
],
computed: {
nodeIconData (): null | NodeIconData {
if (this.nodeType === null) {
return null;
}
const restUrl = this.$store.getters.getRestUrl;
if (this.nodeType.icon) {
let type, path;
[type, path] = this.nodeType.icon.split(':');
const returnData = {
type,
path,
};
if (type === 'file') {
returnData.path = restUrl + '/node-icon/' + this.nodeType.name;
}
return returnData;
}
return null;
},
},
});
</script>
<style lang="scss">
.node-icon-wrapper {
width: 30px;
height: 30px;
border-radius: 20px;
color: #444;
line-height: 30px;
font-size: 1.1em;
overflow: hidden;
background-color: #fff;
text-align: center;
font-size: 12px;
font-weight: bold;
.icon {
font-size: 1.6em;
}
.node-icon-placeholder {
font-size: 1.4em;
text-align: center;
}
}
</style>

View File

@@ -0,0 +1,540 @@
<template>
<div class="node-settings" @keydown.stop>
<div class="header-side-menu">
<span v-if="node">
<display-with-change :key-name="'name'" @valueChanged="valueChanged"></display-with-change>
</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.
</div>
<div class="node-parameters-wrapper" v-if="node && nodeValid">
<el-tabs stretch>
<el-tab-pane label="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">
The node does not have any parameters.
</div>
</el-tab-pane>
<el-tab-pane label="Node">
<parameter-input-full
:parameter="nodeSettingsParameterColor"
:value="nodeValues.color"
:displayOptions="false"
path="color"
@valueChanged="valueChanged"
/>
<div v-if="!isColorDefaultValue" class="color-reset-button-wrapper">
<font-awesome-icon icon="redo" @click="resetColor('color')" class="color-reset-button clickable" title="Reset node color" />
</div>
<parameter-input-full
:parameter="nodeSettingsParameterNotes"
:value="nodeValues.notes"
:displayOptions="false"
path="notes"
@valueChanged="valueChanged"
/>
<parameter-input-full
:parameter="nodeSettingsParameterContinueOnFail"
:value="nodeValues.continueOnFail"
:displayOptions="false"
path="continueOnFail"
@valueChanged="valueChanged"
/>
<parameter-input-list :parameters="parametersSetting" :nodeValues="nodeValues" path="parameters" @valueChanged="valueChanged" />
</el-tab-pane>
</el-tabs>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
INodeIssues,
INodeIssueData,
INodeIssueObjectProperty,
INodeTypeDescription,
INodeParameters,
INodeProperties,
NodeHelpers,
NodeParameterValue,
} from 'n8n-workflow';
import {
INodeUi,
INodeUpdatePropertiesInformation,
IUpdateInformation,
} from '@/Interface';
import DisplayWithChange from '@/components/DisplayWithChange.vue';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import ParameterInputList from '@/components/ParameterInputList.vue';
import NodeCredentials from '@/components/NodeCredentials.vue';
import NodeWebhooks from '@/components/NodeWebhooks.vue';
import { get } from 'lodash';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
nodeHelpers,
)
.extend({
name: 'NodeSettings',
components: {
DisplayWithChange,
NodeCredentials,
ParameterInputFull,
ParameterInputList,
NodeWebhooks,
},
computed: {
nodeType (): INodeTypeDescription | null {
const activeNode = this.node;
if (this.node) {
return this.$store.getters.nodeType(this.node.type);
}
return null;
},
headerStyle (): object {
if (!this.node) {
return {};
}
return {
'background-color': this.node.color,
};
},
node (): INodeUi {
return this.$store.getters.activeNode;
},
parametersSetting (): INodeProperties[] {
return this.parameters.filter((item) => {
return item.isNodeSetting;
});
},
parametersNoneSetting (): INodeProperties[] {
return this.parameters.filter((item) => {
return !item.isNodeSetting;
});
},
parameters (): INodeProperties[] {
if (this.nodeType === null) {
return [];
}
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');
},
},
data () {
return {
nodeValid: true,
nodeColor: null,
nodeValues: {
color: '#ff0000',
continueOnFail: false,
notes: '',
parameters: {},
} as INodeParameters,
nodeSettingsParameterNotes: {
displayName: 'Notes',
name: 'notes',
type: 'string',
typeOptions: {
rows: 5,
},
default: '',
description: 'Notes to save with the node.',
} as INodeProperties,
nodeSettingsParameterColor: {
displayName: 'Node Color',
name: 'color',
type: 'color',
default: '',
description: 'The color of the node in the flow.',
} as INodeProperties,
nodeSettingsParameterContinueOnFail: {
displayName: 'Continue On Fail',
name: 'continueOnFail',
type: 'boolean',
default: false,
description: 'If set and the node fails the workflow will simply continue running.<br />It will then simply pass through the input data so the workflow has<br />to be set up to handle the case that different data gets returned.',
} as INodeProperties,
};
},
watch: {
node (newNode, oldNode) {
this.setNodeValues();
},
},
methods: {
noOp () {},
resetColor () {
const activeNode = this.node as INodeUi;
const activeNodeType = this.nodeType;
if (activeNodeType !== null) {
this.setValue('color', activeNodeType.defaults.color as NodeParameterValue);
this.valueChanged({ name: 'color', value: activeNodeType.defaults.color } as IUpdateInformation);
}
},
setValue (name: string, value: NodeParameterValue) {
const nameParts = name.split('.');
let lastNamePart: string | undefined = nameParts.pop();
let isArray = false;
if (lastNamePart !== undefined && lastNamePart.includes('[')) {
// It incldues an index so we have to extract it
const lastNameParts = lastNamePart.match(/(.*)\[(\d+)\]$/);
if (lastNameParts) {
nameParts.push(lastNameParts[1]);
lastNamePart = lastNameParts[2];
isArray = true;
}
}
// Set the value via Vue.set that everything updates correctly in the UI
if (nameParts.length === 0) {
// Data is on top level
if (value === null) {
// Property should be deleted
// @ts-ignore
Vue.delete(this.nodeValues, lastNamePart);
} else {
// Value should be set
// @ts-ignore
Vue.set(this.nodeValues, lastNamePart, value);
}
} else {
// Data is on lewer level
if (value === null) {
// Property should be deleted
// @ts-ignore
let tempValue = get(this.nodeValues, nameParts.join('.')) as INodeParameters | NodeParameters[];
Vue.delete(tempValue as object, lastNamePart as string);
if (isArray === true && (tempValue as INodeParameters[]).length === 0) {
// If a value from an array got delete and no values are left
// delete also the parent
lastNamePart = nameParts.pop();
tempValue = get(this.nodeValues, nameParts.join('.')) as INodeParameters;
Vue.delete(tempValue as object, lastNamePart as string);
}
} else {
// Value should be set
if (typeof value === 'object') {
// @ts-ignore
Vue.set(get(this.nodeValues, nameParts.join('.')), lastNamePart, JSON.parse(JSON.stringify(value)));
} else {
// @ts-ignore
Vue.set(get(this.nodeValues, nameParts.join('.')), lastNamePart, value);
}
}
}
},
updateNodeCredentialIssues (node: INodeUi): void {
const fullNodeIssues: INodeIssues | null = this.getNodeCredentialIssues(node);
let newIssues: INodeIssueObjectProperty | null = null;
if (fullNodeIssues !== null) {
newIssues = fullNodeIssues.credentials!;
}
this.$store.commit('setNodeIssue', {
node: node.name,
type: 'credentials',
value: newIssues,
} as INodeIssueData);
},
credentialSelected (updateInformation: INodeUpdatePropertiesInformation) {
// Update the values on the node
this.$store.commit('updateNodeProperties', updateInformation);
const node = this.$store.getters.nodeByName(updateInformation.name);
// Update the issues
this.updateNodeCredentialIssues(node);
},
valueChanged (parameterData: IUpdateInformation) {
let newValue: NodeParameterValue;
if (parameterData.hasOwnProperty('value')) {
// New value is given
newValue = parameterData.value;
} else {
// Get new value from nodeData where it is set already
newValue = get(this.nodeValues, parameterData.name) as NodeParameterValue;
}
if (newValue !== undefined) {
// 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);
this.setValue(parameterData.name, newValue);
const updateInformation = {
name: node.name,
key: parameterData.name,
value: newValue,
};
if (parameterData.name === 'name') {
// Name of node changed so we have to set also the new node name as active
const sendData = {
value: newValue,
oldValue: nodeNameBefore,
name: parameterData.name,
};
this.$emit('valueChanged', sendData);
this.$store.commit('setActiveNode', newValue);
} else {
// For all changes except renames we commit the change. For
// renames that happens in NodeView
this.$store.commit('setNodeParameter', updateInformation);
const nodeType = this.$store.getters.nodeType(node.type);
const fullNodeIssues: INodeIssues | null = NodeHelpers.getNodeParametersIssues(nodeType.properties, node);
let newIssues: INodeIssueObjectProperty | null = null;
if (fullNodeIssues !== null) {
newIssues = fullNodeIssues.parameters!;
}
this.$store.commit('setNodeIssue', {
node: node.name,
type: 'parameters',
value: newIssues,
} as INodeIssueData);
this.updateNodeCredentialIssues(node);
}
}
},
/**
* Sets the values of the active node in the internal settings variables
*/
setNodeValues () {
if (!this.node) {
// No node selected
return;
}
if (this.nodeType !== null) {
this.nodeValid = true;
if (this.node.color) {
Vue.set(this.nodeValues, 'color', this.node.color);
} else {
Vue.set(this.nodeValues, 'color', '#ff0000');
}
if (this.node.notes) {
Vue.set(this.nodeValues, 'notes', this.node.notes);
}
if (this.node.continueOnFail) {
Vue.set(this.nodeValues, 'continueOnFail', this.node.continueOnFail);
}
Vue.set(this.nodeValues, 'parameters', JSON.parse(JSON.stringify(this.node.parameters)));
} else {
this.nodeValid = false;
}
},
},
mounted () {
this.setNodeValues();
},
});
</script>
<style lang="scss">
.node-settings {
position: absolute;
left: 0;
width: 350px;
height: 100%;
border: none;
z-index: 200;
font-size: 0.8em;
color: #555;
border-radius: 2px 0 0 2px;
textarea {
font-size: 0.9em;
line-height: 1.5em;
margin: 0.2em 0;
}
textarea:hover {
line-height: 1.5em;
}
.header-side-menu {
padding: 1em 0 1em 1.8em;
font-size: 1.35em;
background-color: $--custom-window-sidebar-top;
color: #555;
}
.node-is-not-valid {
padding: 10px;
}
.node-parameters-wrapper {
height: calc(100% - 110px);
.el-tabs__header {
background-color: #fff5f2;
line-height: 2em;
}
.el-tabs {
height: 100%;
.el-tabs__content {
height: calc(100% - 17px);
overflow-y: auto;
.el-tab-pane {
margin: 0 1em;
}
}
}
.el-tabs__nav {
padding-bottom: 1em;
}
.add-option > .el-input input::placeholder {
color: #fff;
font-weight: 600;
}
.el-button,
.add-option > .el-input .el-input__inner,
.add-option > .el-input .el-input__inner:hover
{
background-color: $--color-primary;
color: #fff;
text-align: center;
height: 38px;
}
.el-button,
.add-option > .el-input .el-input__inner
{
border: 1px solid $--color-primary;
border-radius: 17px;
height: 38px;
}
}
.el-input-number,
input.el-input__inner {
font-size: 0.9em;
line-height: 28px;
height: 28px;
}
.el-input-number {
padding: 0 10px;
}
.el-input--prefix .el-input__inner {
padding: 0 28px;
}
.el-input__prefix {
left: 2px;
top: 1px;
}
.el-select.add-option .el-input .el-select__caret {
color: #fff;
}
}
.parameter-content {
font-size: 0.9em;
margin-right: -15px;
margin-left: -15px;
input {
width: calc(100% - 35px);
padding: 5px;
}
select {
width: calc(100% - 20px);
padding: 5px;
}
&:before {
display: table;
content: " ";
position: relative;
box-sizing: border-box;
clear: both;
}
}
.parameter-wrapper {
line-height: 2.7em;
padding: 0 1em;
}
.parameter-name {
line-height: 2.7em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.color-reset-button-wrapper {
position: relative;
}
.color-reset-button {
position: absolute;
right: 7px;
top: -25px;
}
.parameter-value {
input.expression {
border-style: dashed;
border-color: #ff9600;
display: inline-block;
position: relative;
width: 100%;
box-sizing:border-box;
background-color: #793300;
}
}
</style>

View File

@@ -0,0 +1,222 @@
<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'">
<font-awesome-icon icon="angle-up" class="minimize-button minimize-icon" />
Webhook URLs
</div>
<el-collapse-transition>
<div class="node-webhooks" v-if="!isMinimized">
<div class="url-selection">
<el-row>
<el-col :span="10" class="mode-selection-headline">
Display URL for:
</el-col>
<el-col :span="14">
<el-radio-group v-model="showUrlFor" size="mini">
<el-radio-button label="Production"></el-radio-button>
<el-radio-button label="Test"></el-radio-button>
</el-radio-group>
</el-col>
</el-row>
</div>
<el-tooltip v-for="(webhook, index) in webhooksNode" :key="index" class="item" effect="light" content="Click to copy Webhook URL" placement="left">
<div class="webhook-wrapper">
<div class="http-field">
<div class="http-method">
{{getValue(webhook, 'httpMethod')}}<br />
</div>
</div>
<div class="url-field">
<div class="webhook-url left-ellipsis clickable" @click="copyWebhookUrl(webhook)">
{{getWebhookUrl(webhook, 'path')}}<br />
</div>
</div>
</div>
</el-tooltip>
</div>
</el-collapse-transition>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IWebhookDescription,
NodeHelpers,
Workflow,
} from 'n8n-workflow';
import { copyPaste } from '@/components/mixins/copyPaste';
import { showMessage } from '@/components/mixins/showMessage';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(
copyPaste,
workflowHelpers,
)
.extend({
name: 'NodeWebhooks',
props: [
'node', // NodeUi
'nodeType', // NodeTypeDescription
],
data () {
return {
isMinimized: true,
showUrlFor: 'Production',
};
},
computed: {
webhooksNode (): IWebhookDescription[] {
if (this.nodeType === null || this.nodeType.webhooks === undefined) {
return [];
}
return this.nodeType.webhooks;
},
},
methods: {
copyWebhookUrl (webhookData: IWebhookDescription): void {
const webhookUrl = this.getWebhookUrl(webhookData);
this.copyToClipboard(webhookUrl);
this.$showMessage({
title: 'Copied',
message: `The webhook URL got copied!`,
type: 'success',
});
},
getValue (webhookData: IWebhookDescription, key: string): string {
if (webhookData[key] === undefined) {
return 'empty';
}
try {
return this.resolveExpression(webhookData[key] as string) as string;
} catch (e) {
return '[INVALID EXPRESSION]';
}
},
getWebhookUrl (webhookData: IWebhookDescription): string {
let baseUrl = this.$store.getters.getWebhookUrl;
if (this.showUrlFor === 'Test') {
baseUrl = this.$store.getters.getWebhookTestUrl;
}
const workflowId = this.$store.getters.workflowId;
const path = this.getValue(webhookData, 'path');
return NodeHelpers.getNodeWebhookUrl(baseUrl, workflowId, this.node, path);
},
},
watch: {
node () {
this.isMinimized = false;
},
},
});
</script>
<style scoped lang="scss">
.webhoooks {
padding: 0.7em;
font-size: 0.9em;
margin: 0.5em 0;
border-bottom: 1px solid #ccc;
.headline {
color: $--color-primary;
font-weight: 600;
}
}
.http-field {
position: absolute;
width: 50px;
display: inline-block;
top: calc(50% - 8px)
}
.http-method {
background-color: green;
width: 40px;
height: 16px;
line-height: 16px;
margin-left: 5px;
text-align: center;
border-radius: 2px;
font-size: 0.8em;
font-weight: 600;
color: #fff;
}
.minimize-icon {
font-size: 1.3em;
margin-right: 0.5em;
}
.mode-selection-headline {
line-height: 1.8em;
}
.node-webhooks {
margin-left: 1em;
}
.url-field {
display: inline-block;
width: calc(100% - 60px);
margin-left: 50px;
}
.url-selection {
margin-top: 1em;
}
.minimize-button {
display: inline-block;
-webkit-transition-duration: 0.5s;
-moz-transition-duration: 0.5s;
-o-transition-duration: 0.5s;
transition-duration: 0.5s;
-webkit-transition-property: -webkit-transform;
-moz-transition-property: -moz-transform;
-o-transition-property: -o-transform;
transition-property: transform;
}
.expanded .minimize-button {
-webkit-transform: rotate(180deg);
-moz-transform: rotate(180deg);
-o-transform: rotate(180deg);
transform: rotate(180deg);
}
.webhook-url {
position: relative;
top: 0;
width: 100%;
font-size: 0.9em;
white-space: normal;
overflow: visible;
text-overflow: initial;
color: #404040;
padding: 0.5em;
text-align: left;
direction: ltr;
word-break: break-all;
}
.webhook-wrapper {
position: relative;
margin: 1em 0 0.5em 0;
background-color: #fff;
padding: 2px 0;
border-radius: 3px;
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<div class="wrapper">
<div class="scroll">
<div class="content">
<slot></slot>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend(
{
name: 'PageContentWrapper',
}
);
</script>
<style scoped>
.wrapper {
padding-top: 40px;
position: absolute;
width: 100%;
height: calc(100% - 40px);
}
.scroll {
overflow-y: auto;
width: 100%;
height: 100%;
}
.content {
padding: 1em;
height: 100%;
}
</style>

View File

@@ -0,0 +1,633 @@
<template>
<div @keydown.stop :class="parameterInputClasses">
<expression-edit :dialogVisible="expressionEditDialogVisible" :value="value" :parameter="parameter" :path="path" @closeDialog="closeExpressionEditDialog" @valueChanged="expressionUpdated"></expression-edit>
<text-edit :dialogVisible="textEditDialogVisible" :value="value" :parameter="parameter" @closeDialog="closeTextEditDialog" @valueChanged="expressionUpdated"></text-edit>
<div class="parameter-input" :style="parameterInputWrapperStyle">
<el-input v-if="['json', 'string'].includes(parameter.type)" ref="inputField" size="small" :type="getStringInputType" :rows="getArgument('rows')" :value="displayValue" :disabled="isReadOnly" @change="valueChanged" @keydown.stop @focus="setFocus" :title="displayTitle" :placeholder="isValueExpression?'':parameter.placeholder">
<font-awesome-icon v-if="!isValueExpression && !isReadOnly" slot="suffix" icon="external-link-alt" class="edit-window-button clickable" title="Open Edit Window" @click="textEditDialogVisible = true" />
</el-input>
<div v-else-if="parameter.type === 'dateTime'">
<el-date-picker
v-model="tempValue"
ref="inputField"
type="datetime"
size="small"
:value="displayValue"
:title="displayTitle"
:disabled="isReadOnly"
:placeholder="parameter.placeholder?parameter.placeholder:'Select date and time'"
:picker-options="dateTimePickerOptions"
@change="valueChanged"
@focus="setFocus"
@keydown.stop
>
</el-date-picker>
</div>
<div v-else-if="parameter.type === 'number'">
<!-- <el-slider :value="value" @input="valueChanged"></el-slider> -->
<el-input-number ref="inputField" size="small" :value="displayValue" :max="getArgument('maxValue')" :min="getArgument('minValue')" :precision="getArgument('numberPrecision')" :step="getArgument('numberStepSize')" :disabled="isReadOnly" @change="valueChanged" @focus="setFocus" @keydown.stop :title="displayTitle" :placeholder="parameter.placeholder"></el-input-number>
</div>
<el-select
v-else-if="parameter.type === 'options'"
ref="inputField"
size="small"
filterable
:value="displayValue"
:loading="remoteParameterOptionsLoading"
:disabled="isReadOnly || remoteParameterOptionsLoading"
:title="displayTitle"
@change="valueChanged"
@keydown.stop
@focus="setFocus"
>
<el-option
v-for="option in parameterOptions"
:value="option.value"
:key="option.value"
:label="option.name"
>
<div class="option-headline">{{ option.name }}</div>
<div v-if="option.description" class="option-description" v-html="option.description"></div>
</el-option>
</el-select>
<el-select multiple v-else-if="parameter.type === 'multiOptions'" ref="inputField" size="small" :value="displayValue" filterable :disabled="isReadOnly" @change="valueChanged" @keydown.stop @focus="setFocus" :title="displayTitle" >
<el-option v-for="option in parameter.options" :value="option.value" :key="option.value" :label="option.name" >
<div class="option-headline">{{ option.name }}</div>
<div v-if="option.description" class="option-description" v-html="option.description"></div>
</el-option>
</el-select>
<div v-else-if="parameter.type === 'color'" ref="inputField" class="color-input">
<el-color-picker :value="displayValue" :disabled="isReadOnly" @change="valueChanged" size="small" class="color-picker" @focus="setFocus" :title="displayTitle" ></el-color-picker>
<el-input size="small" type="text" :value="displayValue" :disabled="isReadOnly" @change="valueChanged" @keydown.stop @focus="setFocus" :title="displayTitle" ></el-input>
</div>
<div v-else-if="parameter.type === 'boolean'">
<el-switch ref="inputField" :value="displayValue" @change="valueChanged" active-color="#13ce66" :disabled="isValueExpression || isReadOnly"></el-switch>
<div class="expression-info clickable" @click="expressionEditDialogVisible = true">Edit Expression</div>
</div>
</div>
<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"/>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="addExpression" v-if="!isValueExpression">Add Expression</el-dropdown-item>
<el-dropdown-item command="removeExpression" v-if="isValueExpression">Remove Expression</el-dropdown-item>
<el-dropdown-item command="resetValue" :disabled="isDefault" divided>Reset Value</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
<div class="parameter-issues" v-if="getIssues.length">
<el-tooltip placement="top" effect="light">
<div slot="content" v-html="'Issues:<br />&nbsp;&nbsp;- ' + getIssues.join('<br />&nbsp;&nbsp;- ')"></div>
<font-awesome-icon icon="exclamation-triangle" />
</el-tooltip>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
INodeUi,
IVariableItemSelected,
IVariableSelectorOption,
} from '@/Interface';
import {
NodeHelpers,
NodeParameterValue,
INodePropertyOptions,
Workflow,
} from 'n8n-workflow';
import ExpressionEdit from '@/components/ExpressionEdit.vue';
import TextEdit from '@/components/TextEdit.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
nodeHelpers,
showMessage,
workflowHelpers,
)
.extend({
name: 'ParameterInput',
components: {
ExpressionEdit,
TextEdit,
},
props: [
'displayOptions', // boolean
'parameter', // NodeProperties
'path', // string
'value',
'isCredential', // boolean
],
data () {
return {
nodeName: '',
expressionAddOperation: 'set' as 'add' | 'set',
expressionEditDialogVisible: false,
remoteParameterOptions: [] as INodePropertyOptions[],
remoteParameterOptionsLoading: false,
remoteParameterOptionsLoadingIssues: null as string | null,
textEditDialogVisible: false,
tempValue: '', // el-date-picker does not seem to work without v-model so add one
dateTimePickerOptions: {
shortcuts: [
{
text: 'Today',
// tslint:disable-next-line:no-any
onClick (picker: any) {
picker.$emit('pick', new Date());
},
},
{
text: 'Yesterday',
// tslint:disable-next-line:no-any
onClick (picker: any) {
const date = new Date();
date.setTime(date.getTime() - 3600 * 1000 * 24);
picker.$emit('pick', date);
},
},
{
text: 'A week ago',
// tslint:disable-next-line:no-any
onClick (picker: any) {
const date = new Date();
date.setTime(date.getTime() - 3600 * 1000 * 24 * 7);
picker.$emit('pick', date);
},
},
],
},
};
},
watch: {
value () {
this.tempValue = this.displayValue as string;
},
},
computed: {
node (): INodeUi | null {
if (this.isCredential === true) {
return null;
}
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`;
}
}
return title;
},
displayValue (): string | number | boolean | null {
if (this.remoteParameterOptionsLoadingIssues !== null) {
return 'Error loading...';
} else if (this.remoteParameterOptionsLoading === true) {
// If it is loading options from server display
// 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...';
}
let returnValue;
if (this.isValueExpression === false) {
returnValue = this.value;
} else {
returnValue = this.expressionValueComputed;
}
if (returnValue !== undefined && this.parameter.type === 'string') {
const rows = this.getArgument('rows');
if (rows === undefined || rows === 1) {
returnValue = returnValue.toString().replace(/\n/, '|');
}
}
return returnValue;
},
displayOptionsComputed (): boolean {
if (this.isReadOnly === true) {
return false;
}
if (this.parameter.type === 'collection') {
return false;
}
if (this.displayOptions === true) {
return true;
}
return false;
},
expressionValueComputed (): NodeParameterValue | null {
if (this.isCredential === true || this.node === null) {
return null;
}
let computedValue: NodeParameterValue;
try {
computedValue = this.resolveExpression(this.value) as NodeParameterValue;
} catch (error) {
computedValue = `[ERROR: ${error.message}]`;
}
// Try to convert it into the corret type
if (this.parameter.type === 'number') {
computedValue = parseInt(computedValue as string, 10);
if (isNaN(computedValue)) {
return null;
}
}
return computedValue;
},
getStringInputType () {
if (this.getArgument('password') === true) {
return 'password';
}
const rows = this.getArgument('rows');
if (rows !== undefined && rows > 1) {
return 'textarea';
}
return 'text';
},
getIssues (): string[] {
if (this.isCredential === true || this.node === null) {
return [];
}
const newPath = this.shortPath.split('.');
newPath.pop();
const issues = NodeHelpers.getParameterIssues(this.parameter, this.node.parameters, newPath.join('.'));
if (this.parameter.type === 'options' && this.remoteParameterOptionsLoading === false && this.remoteParameterOptionsLoadingIssues === null) {
// Check if the value resolves to a valid option
// Currently it only displays an error in the node itself in
// case the value is not valid. The workflow can still be executed
// and the error is not displayed on the node in the workflow
const validOptions = this.parameterOptions!.map((options: INodePropertyOptions) => options.value);
if (this.displayValue === null || !validOptions.includes(this.displayValue as string)) {
if (issues.parameters === undefined) {
issues.parameters = {};
}
issues.parameters[this.parameter.name] = [`The value "${this.displayValue}" is not supported!`];
}
} else if (this.remoteParameterOptionsLoadingIssues !== null) {
if (issues.parameters === undefined) {
issues.parameters = {};
}
issues.parameters[this.parameter.name] = [`There was a problem loading the parameter options from server: "${this.remoteParameterOptionsLoadingIssues}"`];
}
if (issues !== undefined &&
issues.parameters !== undefined &&
issues.parameters[this.parameter.name] !== undefined) {
return issues.parameters[this.parameter.name];
}
return [];
},
isDefault (): boolean {
return this.parameter.default === this.value;
},
isValueExpression () {
if (typeof this.value === 'string' && this.value.charAt(0) === '=') {
return true;
}
return false;
},
parameterOptions (): INodePropertyOptions[] {
if (this.remoteMethod === undefined) {
// Options are already given
return this.parameter.options;
}
// Options get loaded from server
return this.remoteParameterOptions;
},
parameterInputClasses () {
const classes = [];
if (this.isValueExpression) {
classes.push('expression');
}
if (this.getIssues.length) {
classes.push('has-issues');
}
return classes;
},
parameterInputWrapperStyle () {
let deductWidth = 0;
const styles = {
width: '100%',
};
if (this.displayOptionsComputed === true) {
deductWidth += 25;
}
if (this.getIssues.length) {
deductWidth += 20;
}
if (deductWidth !== 0) {
styles.width = `calc(100% - ${deductWidth}px)`;
}
return styles;
},
remoteMethod (): string | undefined {
return this.getArgument('loadOptionsMethod') as string | undefined;
},
shortPath (): string {
const shortPath = this.path.split('.');
shortPath.shift();
return shortPath.join('.');
},
workflow (): Workflow {
return this.getWorkflow();
},
},
methods: {
async loadRemoteParameterOptions () {
if (this.node === null || this.remoteMethod === undefined) {
return;
}
this.remoteParameterOptionsLoadingIssues = null;
this.remoteParameterOptionsLoading = true;
this.remoteParameterOptions.length = 0;
try {
const options = await this.restApi().getNodeParameterOptions(this.node.type, this.remoteMethod, this.node.credentials);
this.remoteParameterOptions.push.apply(this.remoteParameterOptions, options);
} catch (error) {
this.remoteParameterOptionsLoadingIssues = error.message;
}
this.remoteParameterOptionsLoading = false;
},
closeExpressionEditDialog () {
this.expressionEditDialogVisible = false;
},
closeTextEditDialog () {
this.textEditDialogVisible = false;
},
getArgument (argumentName: string): string | number | boolean | undefined {
if (this.parameter.typeOptions === undefined) {
return undefined;
}
if (this.parameter.typeOptions[argumentName] === undefined) {
return undefined;
}
return this.parameter.typeOptions[argumentName];
},
expressionUpdated (value: string) {
this.valueChanged(value);
},
setFocus () {
if (this.isReadOnly === true) {
return;
}
if (this.isValueExpression) {
this.expressionEditDialogVisible = true;
return;
}
if (this.parameter.type === 'string' && this.getArgument('alwaysOpenEditWindow')) {
this.textEditDialogVisible = true;
return;
}
if (this.node !== null) {
// When an event like mouse-click removes the active node while
// editing is active it does not know where to save the value to.
// For that reason do we save the node-name here. We could probably
// also just do that once on load but if Vue decides for some reason to
// reuse the input it could have the wrong value so lets set it everytime
// just to be sure
this.nodeName = this.node.name;
}
// Set focus on field
setTimeout(() => {
(this.$refs.inputField as HTMLInputElement).focus();
});
},
valueChanged (value: string | number | boolean | Date | null) {
if (value instanceof Date) {
value = value.toISOString();
}
const parameterData = {
node: this.node !== null ? this.node.name : this.nodeName,
name: this.path,
value,
};
this.$emit('valueChanged', parameterData);
},
optionSelected (command: string) {
if (command === 'resetValue') {
this.valueChanged(this.parameter.default);
} else if (command === 'addExpression') {
this.valueChanged(`=${this.value}`);
this.expressionEditDialogVisible = true;
} else if (command === 'removeExpression') {
this.valueChanged(this.expressionValueComputed || null);
}
},
},
mounted () {
this.tempValue = this.displayValue as string;
if (this.node !== null) {
this.nodeName = this.node.name;
}
if (this.remoteMethod !== undefined && this.node !== null) {
// Make sure to load the parameter options
// directly and whenever the credentials change
this.$watch(() => this.node!.credentials, () => {
this.loadRemoteParameterOptions();
}, { deep: true, immediate: true });
}
},
});
</script>
<style scoped lang="scss">
.parameter-input {
display: inline-block;
}
.parameter-options {
width: 25px;
text-align: right;
float: right;
}
.parameter-issues {
width: 20px;
text-align: right;
float: right;
color: #ff8080;
font-size: 1.2em;
}
.color-input {
background-color: $--custom-input-background;
border-radius: 16px;
line-height: 2.2em;
.el-input {
width: 90px;
}
.color-picker {
float: left;
z-index: 10;
}
}
.el-select {
overflow: auto;
}
</style>
<style lang="scss">
.ql-editor {
padding: 6px;
line-height: 26px;
}
.expression-info {
display: none;
}
.expression {
.expression-info {
display: inline-block;
background-color: #441133;
color: #fff;
font-size: 0.7em;
line-height: 2.5em;
padding: 0 0.5em;
margin-left: 1em;
border-radius: 3px;
}
.el-switch__core {
border: 1px dashed $--custom-expression-text;
}
.el-input > .el-input__inner,
.el-select > .el-input__inner,
.el-textarea textarea,
.el-textarea textarea:active,
.el-textarea textarea:focus,
.el-textarea textarea:hover,
.el-input-number,
.color-input {
border: 1px dashed $--custom-expression-text;
color: $--custom-expression-text;
background-color: $--custom-expression-background;
}
.el-input-number input,
.color-input .el-input__inner {
background-color: $--custom-expression-background;
}
// Overwrite again for number and color inputs to not create
// a second border inside of the already existing one
.color-input .el-input > .el-input__inner,
.el-input-number .el-input > .el-input__inner {
border: none;
background-color: none;
}
}
.has-issues {
.el-textarea textarea,
.el-textarea textarea:active,
.el-textarea textarea:focus,
.el-textarea textarea:hover,
.el-input-number input,
.el-input-number input:active,
.el-input-number input:focus,
.el-input-number input:hover,
.el-input-number [role="button"],
.el-input-number [role="button"]:active,
.el-input-number [role="button"]:focus,
.el-input-number [role="button"]:hover,
.el-input input,
.el-input input:active,
.el-input input:focus,
.el-input input:hover {
border-width: 1px;
border-color: #ff8080;
border-style: solid;
}
}
.el-dropdown {
color: #999;
}
.option-headline {
font-weight: 600;
}
li:not(.selected) .option-description {
color: $--custom-font-very-light;
}
.option-description {
font-weight: 400;
white-space: normal;
max-width: 350px;
margin-top: -4px;
}
.edit-window-button {
display: none;
}
.parameter-input:hover .edit-window-button {
display: inline;
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<el-row class="parameter-wrapper">
<el-col :span="isMultiLineParameter ? 24 : 10" class="parameter-name" :class="{'multi-line': isMultiLineParameter}">
<span class="title" :title="parameter.displayName">{{parameter.displayName}}</span>:
<el-tooltip class="parameter-info" placement="top" v-if="parameter.description" effect="light">
<div slot="content" v-html="parameter.description"></div>
<font-awesome-icon icon="question-circle" />
</el-tooltip>
</el-col>
<el-col :span="isMultiLineParameter ? 24 : 14" class="parameter-value">
<parameter-input :parameter="parameter" :value="value" :displayOptions="displayOptions" :path="path" @valueChanged="valueChanged" />
</el-col>
</el-row>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IUpdateInformation,
} from '@/Interface';
import ParameterInput from '@/components/ParameterInput.vue';
export default Vue
.extend({
name: 'ParameterInputFull',
components: {
ParameterInput,
},
computed: {
isMultiLineParameter () {
const rows = this.getArgument('rows');
if (rows !== undefined && rows > 1) {
return true;
}
return false;
},
},
props: [
'displayOptions',
'parameter',
'path',
'value',
],
methods: {
getArgument (argumentName: string): string | number | boolean | undefined {
if (this.parameter.typeOptions === undefined) {
return undefined;
}
if (this.parameter.typeOptions[argumentName] === undefined) {
return undefined;
}
return this.parameter.typeOptions[argumentName];
},
valueChanged (parameterData: IUpdateInformation) {
this.$emit('valueChanged', parameterData);
},
},
});
</script>
<style lang="scss">
.parameter-wrapper {
line-height: 2.5em;
.option {
margin: 1em;
}
.parameter-info {
display: none;
}
.parameter-name {
&:hover {
.parameter-info {
display: inline;
}
}
&.multi-line {
line-height: 1.5em;
}
}
.title {
font-weight: 400;
}
}
</style>

View File

@@ -0,0 +1,228 @@
<template>
<div>
<div v-for="parameter in filteredParameters" :key="parameter.name">
<div
v-if="multipleValues(parameter) === true && parameter.type !== 'fixedCollection'"
class="parameter-item"
>
<multiple-parameter
:parameter="parameter"
:values="getParameterValue(nodeValues, parameter.name, path)"
:nodeValues="nodeValues"
:path="getPath(parameter.name)"
@valueChanged="valueChanged"
/>
</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}}:
<el-tooltip placement="top" class="parameter-info" v-if="parameter.description" effect="light">
<div slot="content" v-html="parameter.description"></div>
<font-awesome-icon icon="question-circle"/>
</el-tooltip>
</div>
<div>
<collection-parameter
v-if="parameter.type === 'collection'"
:parameter="parameter"
:values="getParameterValue(nodeValues, parameter.name, path)"
:nodeValues="nodeValues"
:path="getPath(parameter.name)"
@valueChanged="valueChanged"
/>
<fixed-collection-parameter
v-else-if="parameter.type === 'fixedCollection'"
:parameter="parameter"
:values="getParameterValue(nodeValues, parameter.name, path)"
:nodeValues="nodeValues"
:path="getPath(parameter.name)"
@valueChanged="valueChanged"
/>
</div>
</div>
<div v-else-if="displayNodeParameter(parameter)" class="parameter-item">
<div class="delete-option clickable" title="Delete" v-if="hideDelete !== true && !isReadOnly">
<font-awesome-icon
icon="trash"
class="reset-icon clickable"
title="Delete Parameter"
@click="deleteOption(parameter.name)"
/>
</div>
<parameter-input-full
:parameter="parameter"
:value="getParameterValue(nodeValues, parameter.name, path)"
:displayOptions="true"
:path="getPath(parameter.name)"
@valueChanged="valueChanged"
/>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
INodeProperties,
} from 'n8n-workflow';
import { IUpdateInformation } from '@/Interface';
import MultipleParameter from '@/components/MultipleParameter.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import ParameterInputFull from '@/components/ParameterInputFull.vue';
import { get } from 'lodash';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
nodeHelpers,
)
.extend({
name: 'ParameterInputList',
components: {
MultipleParameter,
ParameterInputFull,
},
props: [
'nodeValues', // INodeParameters
'parameters', // INodeProperties
'path', // string
'hideDelete', // boolean
],
computed: {
filteredParameters (): INodeProperties {
return this.parameters.filter((parameter: INodeProperties) => this.displayNodeParameter(parameter));
},
},
methods: {
multipleValues (parameter: INodeProperties): boolean {
if (this.getArgument('multipleValues', parameter) === true) {
return true;
}
return false;
},
getArgument (
argumentName: string,
parameter: INodeProperties
): string | number | boolean | undefined {
if (parameter.typeOptions === undefined) {
return undefined;
}
if (parameter.typeOptions[argumentName] === undefined) {
return undefined;
}
return parameter.typeOptions[argumentName];
},
getPath (parameterName: string): string {
return (this.path ? `${this.path}.` : '') + parameterName;
},
deleteOption (optionName: string): void {
const parameterData = {
name: this.getPath(optionName),
value: null,
};
// TODO: If there is only one option it should delete the whole one
this.$emit('valueChanged', parameterData);
},
displayNodeParameter (parameter: INodeProperties): boolean {
if (parameter.displayOptions === undefined) {
// If it is not defined no need to do a proper check
return true;
}
return this.displayParameter(this.nodeValues, parameter, this.path);
},
valueChanged (parameterData: IUpdateInformation): void {
this.$emit('valueChanged', parameterData);
},
},
beforeCreate: function () { // tslint:disable-line
// Because we have a circular dependency on CollectionParameter import it here
// to not break Vue.
this.$options!.components!.FixedCollectionParameter = require('./FixedCollectionParameter.vue').default;
this.$options!.components!.CollectionParameter = require('./CollectionParameter.vue').default;
},
});
</script>
<style lang="scss">
.delete-option {
display: none;
position: absolute;
z-index: 999;
color: #f56c6c;
&:hover {
color: #ff0000;
}
}
.multi-parameter {
position: relative;
margin: 0.5em 0;
padding: 0.5em 0;
>.parameter-name {
font-weight: 600;
border-bottom: 1px solid #999;
.delete-option {
top: 0;
}
}
}
.parameter-item {
position: relative;
.delete-option {
left: -0.9em;
top: 0.6em;
}
}
.parameter-item:hover > .delete-option,
.parameter-name:hover > .delete-option {
display: block;
}
.parameter-name {
&:hover {
.parameter-info {
display: inline;
}
}
.delete-option {
left: -0.9em;
}
.parameter-info {
display: none;
}
}
</style>

View File

@@ -0,0 +1,628 @@
<template>
<div class="run-data-view" v-loading="workflowRunning">
<BinaryDataDisplay :windowVisible="binaryDataDisplayVisible" :displayData="binaryDataDisplayData" @close="closeBinaryDataDisplay"/>
<el-button
v-if="node && !isReadOnly"
:disabled="workflowRunning"
@click.stop="runWorkflow(node.name)"
class="execute-node-button"
:title="`Executes node ${node.name} and all not already executed nodes before it.`"
>
<div class="run-icon-button">
<font-awesome-icon v-if="!workflowRunning" icon="play-circle"/>
<font-awesome-icon v-else icon="spinner" spin />
</div>
Execute Node
</el-button>
<div class="header">
<div class="title-text">
<strong>Results: {{ dataCount }}</strong>&nbsp;
<el-popover
v-if="runMetadata"
placement="right"
width="400"
trigger="hover"
>
<strong>Start Time:</strong> {{runMetadata.startTime}}<br/>
<strong>Execution Time:</strong> {{runMetadata.executionTime}} ms
<font-awesome-icon icon="info-circle" class="primary-color" slot="reference" />
</el-popover>
<span v-if="maxOutputIndex > 0">
| Output:
<select v-model="outputIndex" @click.stop>
<option v-for="option in (maxOutputIndex + 1)" :value="option -1" :key="option">
{{ getOutputName(option-1) }}
</option>
</select>
</span>
<span v-if="maxRunIndex > 0">
| Data of Execution:
<select v-model="runIndex" @click.stop>
<option v-for="option in (maxRunIndex + 1)" :value="option-1" :key="option">
{{ option }}
</option>
</select>
/{{maxRunIndex+1}}
</span>
</div>
<div v-if="node && workflowRunData !== null && workflowRunData.hasOwnProperty(node.name) && !workflowRunData[node.name][runIndex].error" class="title-data-display-selector" @click.stop>
<el-radio-group v-model="displayMode" size="mini">
<el-radio-button label="JSON"></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-group>
</div>
</div>
<div class="data-display-content">
<span v-if="node && workflowRunData !== null && workflowRunData.hasOwnProperty(node.name)">
<div v-if="workflowRunData[node.name][runIndex].error" class="error-display">
<div class="error-message">ERROR: {{workflowRunData[node.name][runIndex].error.message}}</div>
<pre><code>{{workflowRunData[node.name][runIndex].error.stack}}</code></pre>
</div>
<span v-else>
<div v-if="['JSON', 'Table'].includes(displayMode)">
<div v-if="jsonData.length === 0" class="no-data">
No text data found
</div>
<vue-json-pretty
v-else-if="displayMode === 'JSON'"
class="json-data"
:data="jsonData">
</vue-json-pretty>
<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.
</div>
<table v-else-if="tableData !== null">
<tr>
<th v-for="column in tableData.columns" :key="column">{{column}}</th>
</tr>
<tr v-for="(row, index1) in tableData.data" :key="index1">
<td v-for="(data, index2) in row" :key="index2">{{ [null, undefined].includes(data) ? '&nbsp;' : data }}</td>
</tr>
</table>
</div>
</div>
<div v-else-if="displayMode === 'Binary'">
<div v-if="binaryData.length === 0" class="no-data">
No binary data found
</div>
<div v-else>
<div v-for="(binaryDataEntry, index) in binaryData" :key="index">
<div class="binary-data-row-index">
<div class="binary-data-cell-index">
{{index + 1}}
</div>
</div>
<div class="binary-data-row">
<div class="binary-data-cell" v-for="(binaryData, key) in binaryDataEntry" :key="index + '_' + key">
<div class="binary-data-information">
<div class="binary-data-cell-name">
{{key}}
</div>
<div v-if="binaryData.fileName">
<div class="label">File Name: </div>
<div class="value">{{binaryData.fileName}}</div>
</div>
<div v-if="binaryData.fileExtension">
<div class="label">File Extension:</div>
<div class="value">{{binaryData.fileExtension}}</div>
</div>
<div v-if="binaryData.mimeType">
<div class="label">Mime Type: </div>
<div class="value">{{binaryData.mimeType}}</div>
</div>
<!-- <el-button @click="displayBinaryData(binaryData)"> -->
<div class="binary-data-show-data-button-wrapper">
<el-button size="mini" class="binary-data-show-data-button" @click="displayBinaryData(index, key)">
Show Binary Data
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</span>
</span>
<div v-else class="message">
<div>
<strong>No data</strong><br />
<br />
To display data execute the node first by pressing the execute button above.
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
// @ts-ignore
import VueJsonPretty from 'vue-json-pretty';
import {
GenericValue,
IBinaryData,
IBinaryKeyData,
IDataObject,
INodeExecutionData,
IRun,
IRunData,
IRunExecutionData,
ITaskData,
ITaskDataConnections,
} from 'n8n-workflow';
import {
IBinaryDisplayData,
IExecutionResponse,
INodeUi,
ITableData,
} from '@/Interface';
import BinaryDataDisplay from '@/components/BinaryDataDisplay.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { workflowRun } from '@/components/mixins/workflowRun';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
nodeHelpers,
workflowRun,
)
.extend({
name: 'RunData',
components: {
BinaryDataDisplay,
VueJsonPretty,
},
data () {
return {
binaryDataPreviewActive: false,
displayMode: 'Table',
runIndex: 0,
outputIndex: 0,
binaryDataDisplayVisible: false,
binaryDataDisplayData: null as IBinaryDisplayData | null,
};
},
computed: {
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
workflowExecution (): IExecutionResponse | null {
return this.$store.getters.getWorkflowExecution;
},
workflowRunData (): IRunData | null {
if (this.workflowExecution === null) {
return null;
}
const executionData: IRunExecutionData = this.workflowExecution.data;
return executionData.resultData.runData;
},
node (): INodeUi | null {
return this.$store.getters.activeNode;
},
runMetadata () {
if (!this.node || this.workflowExecution === null) {
return null;
}
const runData = this.workflowRunData;
if (runData === null || !runData.hasOwnProperty(this.node.name)) {
return null;
}
if (runData[this.node.name].length <= this.runIndex) {
return null;
}
const taskData: ITaskData = runData[this.node.name][this.runIndex];
return {
executionTime: taskData.executionTime,
startTime: new Date(taskData.startTime).toLocaleString(),
};
},
dataCount (): number {
if (this.node === null) {
return 0;
}
const runData: IRunData | null = this.workflowRunData;
if (runData === null || !runData.hasOwnProperty(this.node.name)) {
return 0;
}
if (runData[this.node.name].length <= this.runIndex) {
return 0;
}
if (runData[this.node.name][this.runIndex].hasOwnProperty('error')) {
return 1;
}
if (!runData[this.node.name][this.runIndex].hasOwnProperty('data') ||
runData[this.node.name][this.runIndex].data === undefined
) {
return 0;
}
const inputData = this.getMainInputData(runData[this.node.name][this.runIndex].data!, this.outputIndex);
return inputData.length;
},
maxOutputIndex (): number {
if (this.node === null) {
return 0;
}
const runData: IRunData | null = this.workflowRunData;
if (runData === null || !runData.hasOwnProperty(this.node.name)) {
return 0;
}
if (runData[this.node.name].length < this.runIndex) {
return 0;
}
if (runData[this.node.name][this.runIndex].data === undefined ||
runData[this.node.name][this.runIndex].data!.main === undefined
) {
return 0;
}
return runData[this.node.name][this.runIndex].data!.main.length - 1;
},
maxRunIndex (): number {
if (this.node === null) {
return 0;
}
const runData: IRunData | null = this.workflowRunData;
if (runData === null || !runData.hasOwnProperty(this.node.name)) {
return 0;
}
if (runData[this.node.name].length) {
return runData[this.node.name].length - 1;
}
return 0;
},
jsonData (): IDataObject[] {
const inputData = this.getNodeInputData(this.node, this.runIndex, this.outputIndex);
if (inputData.length === 0) {
return [];
}
return this.convertToJson(inputData);
},
tableData (): ITableData | undefined {
const inputData = this.getNodeInputData(this.node, this.runIndex, this.outputIndex);
if (inputData.length === 0) {
return undefined;
}
return this.convertToTable(inputData);
},
binaryData (): IBinaryKeyData[] {
if (this.node === null) {
return [];
}
return this.getBinaryData(this.workflowRunData, this.node.name, this.runIndex, this.outputIndex);
},
},
methods: {
getOutputName (outputIndex: number) {
if (this.node === null) {
return outputIndex + 1;
}
const nodeType = this.$store.getters.nodeType(this.node.type);
if (!nodeType.hasOwnProperty('outputNames') || nodeType.outputNames.length <= outputIndex) {
return outputIndex + 1;
}
return nodeType.outputNames[outputIndex];
},
closeBinaryDataDisplay () {
this.binaryDataDisplayVisible = false;
this.binaryDataDisplayData = null;
},
convertToJson (inputData: INodeExecutionData[]): IDataObject[] {
const returnData: IDataObject[] = [];
inputData.forEach((data) => {
if (!data.hasOwnProperty('json')) {
return;
}
returnData.push(data.json);
});
return returnData;
},
convertToTable (inputData: INodeExecutionData[]): ITableData | undefined {
const tableData: GenericValue[][] = [];
const tableColumns: string[] = [];
let leftEntryColumns: string[], entryRows: GenericValue[];
// Go over all entries
let entry: IDataObject;
inputData.forEach((data) => {
if (!data.hasOwnProperty('json')) {
return;
}
entry = data.json;
// Go over all keys of entry
entryRows = [];
leftEntryColumns = Object.keys(entry);
// Go over all the already existing column-keys
tableColumns.forEach((key) => {
if (entry.hasOwnProperty(key)) {
// Entry does have key so add its value
entryRows.push(entry[key]);
// Remove key so that we know that it got added
leftEntryColumns.splice(leftEntryColumns.indexOf(key), 1);
} else {
// Entry does not have key so add null
entryRows.push(null);
}
});
// Go over all the columns the entry has but did not exist yet
leftEntryColumns.forEach((key) => {
// Add the key for all runs in the future
tableColumns.push(key);
// Add the value
entryRows.push(entry[key]);
});
// Add the data of the entry
tableData.push(entryRows);
});
// Make sure that all entry-rows have the same length
tableData.forEach((entryRows) => {
if (tableColumns.length > entryRows.length) {
// Has to less entries so add the missing ones
entryRows.push.apply(entryRows, new Array(tableColumns.length - entryRows.length));
}
});
return {
columns: tableColumns,
data: tableData,
};
},
clearExecutionData () {
this.$store.commit('setWorkflowExecutionData', null);
this.updateNodesExecutionIssues();
},
// displayBinaryData (binaryData: IBinaryData) {
displayBinaryData (index: number, key: string) {
this.binaryDataDisplayVisible = true;
this.binaryDataDisplayData = {
node: this.node!.name,
runIndex: this.runIndex,
outputIndex: this.outputIndex,
index,
key,
};
},
},
watch: {
node (newNode, oldNode) {
// Reset the selected output index every time another node gest selected
this.outputIndex = 0;
if (this.displayMode === 'Binary' && this.binaryData.length === 0) {
this.displayMode = 'Table';
}
},
displayMode () {
this.closeBinaryDataDisplay();
},
maxRunIndex () {
this.runIndex = Math.min(this.runIndex, this.maxRunIndex);
},
},
mounted () {
},
});
</script>
<style lang="scss">
.run-data-view {
position: relative;
bottom: 0;
left: 0;
margin-left: 350px;
width: calc(100% - 350px);
height: 100%;
z-index: 100;
color: #555;
font-size: 14px;
background-color: #f9f9f9;
.data-display-content {
position: absolute;
bottom: 0;
top: 50px;
left: 0;
right: 0;
overflow-y: auto;
.binary-data-row {
display: inline-flex;
padding: 0.5em 1em;
.binary-data-cell {
display: inline-block;
width: 300px;
overflow: hidden;
background-color: #fff;
margin-right: 1em;
border-radius: 3px;
-webkit-box-shadow: 0px 0px 12px 0px rgba(0,0,0,0.05);
-moz-box-shadow: 0px 0px 12px 0px rgba(0,0,0,0.05);
box-shadow: 0px 0px 12px 0px rgba(0,0,0,0.05);
.binary-data-information {
margin: 1em;
.binary-data-cell-name {
color: $--color-primary;
font-weight: 600;
font-size: 1.2em;
padding-bottom: 0.5em;
margin-bottom: 0.5em;
border-bottom: 1px solid #ccc;
}
.binary-data-show-data-button-wrapper {
margin-top: 1.5em;
text-align: center;
width: 100%;
.binary-data-show-data-button {
width: 130px;
}
}
.label {
padding-top: 0.5em;
font-weight: bold;
}
.value {
white-space: initial;
word-wrap: break-word;
}
}
}
}
.binary-data-row-index {
display: block;
padding: 1em 1em 0.25em 1em;
.binary-data-cell-index {
display: inline-block;
width: 30px;
height: 30px;
line-height: 30px;
border-radius: 5px;
text-align: center;
padding: 0 0.1em;
background-color: $--custom-header-background;
font-weight: 600;
color: #fff;
}
}
.json-data {
overflow-x: hidden;
white-space: initial;
word-wrap: break-word;
}
.error-display,
.json-data,
.message,
.no-data {
margin: 10px;
}
.error-display {
.error-message {
color: #ff0000;
font-weight: bold;
}
}
table {
border-collapse: collapse;
text-align: left;
width: calc(100% - 1px);
border-left: 25px solid #00000000;
border-right: 25px solid #00000000;
th {
background-color: $--custom-table-background-main;
color: #fff;
padding: 12px;
}
td {
padding: 12px;
}
tr:nth-child(even) {
background: #fff;;
}
tr:nth-child(odd) {
background: $--custom-table-background-alternative;
}
}
}
.execute-node-button {
position: absolute;
top: 10px;
right: 10px;
height: 30px;
width: 130px;
padding: 7px;
border-radius: 13px;
color: $--color-primary;
border: 1px solid $--color-primary;
background-color: #fff;
}
.execute-node-button:hover {
transform: scale(1.05);
}
.run-icon-button {
display: inline-block;
width: 20px;
}
.header {
padding-top: 10px;
padding-left: 10px;
.title-text {
display: inline-block;
line-height: 30px;
}
.title-data-display-selector {
position: absolute;
left: calc(50% - 105px);
width: 210px;
display: inline-block;
line-height: 30px;
text-align: center;
.entry.active {
font-weight: bold;
}
}
}
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<div v-if="dialogVisible">
<el-dialog :visible="dialogVisible" append-to-body width="80%" :title="`Edit ${parameter.displayName}`" :before-close="closeDialog">
<div class="text-editor-wrapper">
<div class="editor-description">
{{parameter.displayName}}:
</div>
<div class="text-editor" @keydown.stop @keydown.esc="closeDialog()">
<el-input type="textarea" ref="inputField" :value="value" :placeholder="parameter.placeholder" @change="valueChanged" @keydown.stop="noOp" rows="15" />
</div>
</div>
</el-dialog>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
Workflow,
} from 'n8n-workflow';
export default Vue.extend({
name: 'TextEdit',
props: [
'dialogVisible',
'parameter',
'value',
],
data () {
return {
};
},
methods: {
valueChanged (value: string) {
this.$emit('valueChanged', value);
},
closeDialog () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
},
},
watch: {
dialogVisible () {
if (this.dialogVisible === true) {
Vue.nextTick(() => {
(this.$refs.inputField as HTMLInputElement).focus();
});
}
},
},
});
</script>
<style scoped>
.editor-description {
font-weight: bold;
padding: 0 0 0.5em 0.2em;;
}
</style>

View File

@@ -0,0 +1,590 @@
<template>
<div @keydown.stop class="variable-selector-wrapper">
<div class="input-wrapper">
<el-input placeholder="Variable filter..." v-model="variableFilter" ref="inputField" size="small" type="text"></el-input>
</div>
<div class="result-wrapper">
<variable-selector-item :item="option" v-for="option in currentResults" :key="option.key" :extendAll="extendAll" @itemSelected="forwardItemSelected"></variable-selector-item>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IContextObject,
IDataObject,
GenericValue,
IRun,
IRunData,
IRunExecutionData,
Workflow,
WorkflowDataProxy,
} from 'n8n-workflow';
import VariableSelectorItem from '@/components/VariableSelectorItem.vue';
import {
IExecutionResponse,
IVariableItemSelected,
IVariableSelectorOption,
} from '@/Interface';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(
workflowHelpers,
)
.extend({
name: 'VariableSelector',
components: {
VariableSelectorItem,
},
props: [
'path',
],
data () {
return {
variableFilter: '',
selectorOpenInputIndex: null as number | null,
};
},
computed: {
extendAll (): boolean {
if (this.variableFilter) {
return true;
}
return false;
},
currentResults (): IVariableSelectorOption[] {
return this.getFilterResults(this.variableFilter.toLowerCase(), 0);
},
workflow (): Workflow {
return this.getWorkflow();
},
},
methods: {
forwardItemSelected (eventData: IVariableItemSelected) {
this.$emit('itemSelected', eventData);
},
sortOptions (options: IVariableSelectorOption[] | null): IVariableSelectorOption[] | null {
if (options === null) {
return null;
}
return options.sort((a: IVariableSelectorOption, b: IVariableSelectorOption) => {
const aHasOptions = a.hasOwnProperty('options');
const bHasOptions = b.hasOwnProperty('options');
if (bHasOptions && !aHasOptions) {
// When b has options but a not list it first
return 1;
} else if (!bHasOptions && aHasOptions) {
// When a has options but b not list it first
return -1;
}
// Else simply sort alphabetically
if (a.name < b.name) { return -1; }
if (a.name > b.name) { return 1; }
return 0;
});
},
removeEmptyEntries (inputData: IVariableSelectorOption[] | IVariableSelectorOption | null): IVariableSelectorOption[] | IVariableSelectorOption | null {
if (Array.isArray(inputData)) {
const newItems: IVariableSelectorOption[] = [];
let tempItem: IVariableSelectorOption;
inputData.forEach((item) => {
tempItem = this.removeEmptyEntries(item) as IVariableSelectorOption;
if (tempItem !== null) {
newItems.push(tempItem);
}
});
return newItems;
}
if (inputData && inputData.options) {
const newOptions = this.removeEmptyEntries(inputData.options);
if (Array.isArray(newOptions) && newOptions.length) {
// Has still options left so return
inputData.options = this.sortOptions(newOptions);
return inputData;
}
// Has no options left so remove
return null;
} else {
// Is an item no category
return inputData;
}
},
// Normalizes the path so compare paths which have use dots or brakets
getPathNormalized (path: string | undefined): string {
if (path === undefined) {
return '';
}
const pathArray = path.split('.');
const finalArray = [];
let item: string;
for (const pathPart of pathArray) {
const pathParts = pathPart.match(/\[.*?\]/g);
if (pathParts === null) {
// Does not have any brakets so add as it is
finalArray.push(pathPart);
} else {
// Has brakets so clean up the items and add them
if (pathPart.charAt(0) !== '[') {
// Does not start with a braket so there is a part before
// we have to add
finalArray.push(pathPart.substr(0, pathPart.indexOf('[')));
}
for (item of pathParts) {
item = item.slice(1, -1);
if (['"', "'"].includes(item.charAt(0))) {
// Is a string
item = item.slice(1, -1);
finalArray.push(item);
} else {
// Is a number
finalArray.push(`[${item}]`);
}
}
}
}
return finalArray.join('|');
},
jsonDataToFilterOption (inputData: IDataObject | GenericValue | IDataObject[] | GenericValue[] | null, parentPath: string, propertyName: string, filterText?: string, propertyIndex?: number, displayName?: string, skipKey?: string): IVariableSelectorOption[] {
let fullpath = `${parentPath}["${propertyName}"]`;
if (propertyIndex !== undefined) {
fullpath += `[${propertyIndex}]`;
}
const returnData: IVariableSelectorOption[] = [];
if (inputData === null) {
return returnData;
} else if (Array.isArray(inputData)) {
let newPropertyName = propertyName;
if (propertyIndex !== undefined) {
newPropertyName += `[${propertyIndex}]`;
}
const arrayData: IVariableSelectorOption[] = [];
for (let i = 0; i < inputData.length; i++) {
arrayData.push.apply(arrayData, this.jsonDataToFilterOption(inputData[i], parentPath, newPropertyName, filterText, i, `[Item: ${i}]`, skipKey));
}
returnData.push(
{
name: displayName || propertyName,
options: arrayData,
key: fullpath,
allowParentSelect: true,
dataType: 'array',
} as IVariableSelectorOption
);
} else if (typeof inputData === 'object') {
const tempValue: IVariableSelectorOption[] = [];
for (const key of Object.keys(inputData)) {
tempValue.push.apply(tempValue, this.jsonDataToFilterOption((inputData as IDataObject)[key], fullpath, key, filterText, undefined, undefined, skipKey));
}
if (tempValue.length) {
returnData.push(
{
name: displayName || propertyName,
options: this.sortOptions(tempValue),
key: fullpath,
allowParentSelect: true,
dataType: 'object',
} as IVariableSelectorOption
);
}
} else {
if (filterText !== undefined && propertyName.toLowerCase().indexOf(filterText) === -1) {
return returnData;
}
// Skip is currently only needed for leafs so only check here
if (this.getPathNormalized(skipKey) !== this.getPathNormalized(fullpath)) {
returnData.push(
{
name: propertyName,
key: fullpath,
value: inputData,
} as IVariableSelectorOption
);
}
}
return returnData;
},
/**
* Returns the data the a node does output
*
* @param {IRunData} runData The data of the run to get the data of
* @param {string} nodeName The name of the node to get the data of
* @param {string} filterText Filter text for parameters
* @param {number} [itemIndex=0] The index of the item
* @param {number} [runIndex=0] The index of the run
* @param {string} [inputName='main'] The name of the input
* @param {number} [outputIndex=0] The index of the output
* @returns
* @memberof Workflow
*/
getNodeOutputData (runData: IRunData, nodeName: string, filterText: string, itemIndex = 0, runIndex = 0, inputName = 'main', outputIndex = 0): IVariableSelectorOption[] | null {
if (!runData.hasOwnProperty(nodeName)) {
// No data found for node
return null;
}
if (runData[nodeName].length <= runIndex) {
// No data for given runIndex
return null;
}
if (!runData[nodeName][runIndex].hasOwnProperty('data') ||
runData[nodeName][runIndex].data === null ||
runData[nodeName][runIndex].data === undefined) {
// Data property does not exist or is not set (even though it normally has to)
return null;
}
if (!runData[nodeName][runIndex].data!.hasOwnProperty(inputName)) {
// No data found for inputName
return null;
}
if (runData[nodeName][runIndex].data![inputName].length <= outputIndex) {
// No data found for output Index
return null;
}
// The data should be identical no matter to which node it gets so always select the first one
if (runData[nodeName][runIndex].data![inputName][outputIndex] === null ||
runData[nodeName][runIndex].data![inputName][outputIndex]!.length <= itemIndex) {
// No data found for node connection found
return null;
}
const outputData = runData[nodeName][runIndex].data![inputName][outputIndex]![itemIndex];
const returnData: IVariableSelectorOption[] = [];
// Get json data
if (outputData.hasOwnProperty('json')) {
const jsonDataOptions: IVariableSelectorOption[] = [];
for (const propertyName of Object.keys(outputData.json)) {
jsonDataOptions.push.apply(jsonDataOptions, this.jsonDataToFilterOption(outputData.json[propertyName], `$node["${nodeName}"].data`, propertyName, filterText));
}
if (jsonDataOptions.length) {
returnData.push(
{
name: 'JSON',
options: this.sortOptions(jsonDataOptions),
}
);
}
}
// Get binary data
if (outputData.hasOwnProperty('binary')) {
const binaryData = [];
let binaryPropertyData = [];
for (const dataPropertyName of Object.keys(outputData.binary!)) {
binaryPropertyData = [];
for (const propertyName in outputData.binary![dataPropertyName]) {
if (propertyName === 'data') {
continue;
}
if (filterText && propertyName.toLowerCase().indexOf(filterText) === -1) {
// If filter is set apply it
continue;
}
binaryPropertyData.push(
{
name: propertyName,
key: `$node["${nodeName}"].binary.${dataPropertyName}.${propertyName}`,
value: outputData.binary![dataPropertyName][propertyName],
}
);
}
if (binaryPropertyData.length) {
binaryData.push(
{
name: dataPropertyName,
key: `$node["${nodeName}"].binary.${dataPropertyName}`,
options: this.sortOptions(binaryPropertyData),
allowParentSelect: true,
}
);
}
}
if (binaryData.length) {
returnData.push(
{
name: 'Binary',
key: `$node["${nodeName}"].binary`,
options: this.sortOptions(binaryData),
allowParentSelect: true,
}
);
}
}
return returnData;
},
getNodeContext (workflow: Workflow, runExecutionData: IRunExecutionData | null, parentNode: string[], nodeName: string, filterText: string): IVariableSelectorOption[] | null {
const inputIndex = 0;
const itemIndex = 0;
const inputName = 'main';
const runIndex = 0;
const returnData: IVariableSelectorOption[] = [];
const connectionInputData = this.connectionInputData(parentNode, inputName, runIndex, inputIndex);
if (connectionInputData === null) {
return returnData;
}
const dataProxy = new WorkflowDataProxy(workflow, runExecutionData, runIndex, itemIndex, nodeName, connectionInputData);
const proxy = dataProxy.getDataProxy();
// @ts-ignore
const nodeContext = proxy.$node[nodeName].context as IContextObject;
for (const key of Object.keys(nodeContext)) {
if (filterText !== undefined && key.toLowerCase().indexOf(filterText) === -1) {
// If filter is set apply it
continue;
}
returnData.push({
name: key,
key: `$node["${nodeName}"].context["${key}"]`,
// @ts-ignore
value: nodeContext[key],
});
}
return returnData;
},
/**
* Returns all the node parameters with values
*
* @param {string} nodeName The name of the node to return data of
* @param {string} path The path to the node to pretend to key
* @param {string} [skipParameter] Parameter to skip
* @param {string} [filterText] Filter text for parameters
* @returns
* @memberof Workflow
*/
getNodeParameters (nodeName: string, path: string, skipParameter?: string, filterText?: string): IVariableSelectorOption[] | null {
const node = this.workflow.getNode(nodeName);
if (node === null) {
return null;
}
const returnParameters: IVariableSelectorOption[] = [];
for (const parameterName in node.parameters) {
if (parameterName === skipParameter) {
// Skip the parameter
continue;
}
if (filterText !== undefined && parameterName.toLowerCase().indexOf(filterText) === -1) {
// If filter is set apply it
continue;
}
returnParameters.push.apply(returnParameters, this.jsonDataToFilterOption(node.parameters[parameterName], path, parameterName, filterText, undefined, undefined, skipParameter));
}
return returnParameters;
},
getFilterResults (filterText: string, itemIndex: number): IVariableSelectorOption[] {
const inputName = 'main';
const activeNode = this.$store.getters.activeNode;
const executionData = this.$store.getters.getWorkflowExecution as IExecutionResponse | null;
let parentNode = this.workflow.getParentNodes(activeNode.name, inputName, 1);
let runData = this.$store.getters.getWorkflowRunData as IRunData | null;
if (runData === null) {
runData = {};
}
let returnData: IVariableSelectorOption[] | null = [];
// -----------------------------------------
// Add the parameters of the current node
// -----------------------------------------
// Add the parameters
const currentNodeData: IVariableSelectorOption[] = [];
let tempOptions: IVariableSelectorOption[];
if (executionData !== null) {
const runExecutionData: IRunExecutionData = executionData.data;
tempOptions = this.getNodeContext(this.workflow, runExecutionData, parentNode, activeNode.name, filterText) as IVariableSelectorOption[];
if (tempOptions.length) {
currentNodeData.push(
{
name: 'Context',
options: this.sortOptions(tempOptions),
} as IVariableSelectorOption,
);
}
}
let tempOutputData;
if (parentNode.length) {
// If the node has an input node add the input data
tempOutputData = this.getNodeOutputData(runData, parentNode[0], filterText, itemIndex) as IVariableSelectorOption[];
if (tempOutputData) {
currentNodeData.push(
{
name: 'Input Data',
options: this.sortOptions(tempOutputData),
}
);
}
}
const initialPath = '$parameter';
let skipParameter = this.path;
if (skipParameter.startsWith('parameters.')) {
skipParameter = initialPath + skipParameter.substring(10);
}
currentNodeData.push(
{
name: 'Parameters',
options: this.sortOptions(this.getNodeParameters(activeNode.name, initialPath, skipParameter, filterText) as IVariableSelectorOption[]),
}
);
returnData.push(
{
name: 'Current Node',
options: this.sortOptions(currentNodeData),
}
);
// Add the input data
// -----------------------------------------
// Add all the nodes and their data
// -----------------------------------------
const allNodesData: IVariableSelectorOption[] = [];
let nodeOptions: IVariableSelectorOption[];
const upstreamNodes = this.workflow.getParentNodes(activeNode.name, inputName);
for (const nodeName of Object.keys(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)
if (nodeName === activeNode.name) {
// Skip the current node as this one get added separately
continue;
}
nodeOptions = [
{
name: 'Parameters',
options: this.sortOptions(this.getNodeParameters(nodeName, `$node["${nodeName}"].parameter`, undefined, filterText)),
} as IVariableSelectorOption,
];
if (executionData !== null) {
const runExecutionData: IRunExecutionData = executionData.data;
parentNode = this.workflow.getParentNodes(nodeName, inputName, 1);
tempOptions = this.getNodeContext(this.workflow, runExecutionData, parentNode, nodeName, filterText) as IVariableSelectorOption[];
if (tempOptions.length) {
nodeOptions = [
{
name: 'Context',
options: this.sortOptions(tempOptions),
} as IVariableSelectorOption,
];
}
}
if (upstreamNodes.includes(nodeName)) {
// If the node is an upstream node add also the output data which can be referenced
tempOutputData = this.getNodeOutputData(runData, nodeName, filterText, itemIndex);
if (tempOutputData) {
nodeOptions.push(
{
name: 'Output Data',
options: this.sortOptions(tempOutputData),
} as IVariableSelectorOption
);
}
}
allNodesData.push(
{
name: nodeName,
options: this.sortOptions(nodeOptions),
}
);
}
returnData.push(
{
name: 'Nodes',
options: this.sortOptions(allNodesData),
}
);
// Remove empty entries and return
returnData = this.removeEmptyEntries(returnData) as IVariableSelectorOption[] | null;
if (returnData === null) {
return [];
}
return returnData;
},
},
});
</script>
<style scoped lang="scss">
.variable-selector-wrapper {
border-radius: 0 0 4px 4px;
width: 100%;
height: 100%;
position: relative;
}
.result-wrapper {
line-height: 1em;
height: 370px;
overflow-x: hidden;
overflow-y: auto;
margin: 0.5em 0;
width: 100%;
}
.result-item {
font-size: 0.7em;
}
</style>

View File

@@ -0,0 +1,153 @@
<template>
<div class="item">
<div v-if="item.options" class="options">
<div v-if="item.options.length" class="headline clickable" @click="extended=!extended">
<div class="options-toggle" v-if="extendAll !== true">
<font-awesome-icon v-if="extended" icon="angle-down" />
<font-awesome-icon v-else icon="angle-right" />
</div>
<div class="option-title" :title="item.key">
{{item.name}}
<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" />
</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>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
<div v-if="item.options && (extended === true || extendAll === true)">
<variable-selector-item v-for="option in item.options" :item="option" :key="option.key" :extendAll="extendAll" :allowParentSelect="option.allowParentSelect" class="sub-level" @itemSelected="forwardItemSelected"></variable-selector-item>
</div>
</div>
<div v-else class="value clickable" @click="selectItem(item)">
<div class="item-title" :title="item.key">
{{item.name}}:
<font-awesome-icon icon="dot-circle" title="Select Item" />
</div>
<div class="item-value">{{ item.value !== undefined?item.value:'--- EMPTY ---' }}</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IVariableSelectorOption,
IVariableItemSelected,
} from '@/Interface';
export default Vue.extend({
name: 'VariableSelectorItem',
props: [
'allowParentSelect',
'extendAll',
'item',
],
computed: {
itemAddOperations () {
const returnOptions = [
{
command: 'raw',
displayName: 'Raw value',
},
];
if (this.item.dataType === 'array') {
returnOptions.push({
command: 'arrayLength',
displayName: 'Length',
});
returnOptions.push({
command: 'arrayValues',
displayName: 'Values',
});
} else if (this.item.dataType === 'object') {
returnOptions.push({
command: 'objectKeys',
displayName: 'Keys',
});
returnOptions.push({
command: 'objectValues',
displayName: 'Values',
});
}
return returnOptions;
},
},
data () {
return {
extended: false,
};
},
methods: {
optionSelected (command: string, item: IVariableSelectorOption) {
// By default it is raw
let variable = item.key;
if (command === 'arrayValues') {
variable = `${item.key}.join(', ')`;
} else if (command === 'arrayLength') {
variable = `${item.key}.length`;
} else if (command === 'objectKeys') {
variable = `Object.keys(${item.key}).join(', ')`;
} else if (command === 'objectValues') {
variable = `Object.values(${item.key}).join(', ')`;
}
this.$emit('itemSelected', { variable });
},
selectItem (item: IVariableSelectorOption) {
this.$emit('itemSelected', { variable: item.key });
},
forwardItemSelected (eventData: IVariableItemSelected) {
this.$emit('itemSelected', eventData);
},
},
});
</script>
<style scoped lang="scss">
.option-title {
position: relative;
display: inline-block;
font-size: 0.9em;
font-weight: 600;
padding: 0.2em 1em 0.2em 0.4em;
}
.item-title {
font-weight: 600;
font-size: 0.8em;
white-space: nowrap;
overflow-x: hidden;
text-overflow: ellipsis;
}
.headline {
position: relative;
margin: 2px;
margin-top: 10px;
color: $--color-primary;
}
.options-toggle {
position: relative;
top: 1px;
margin: 0 3px 0 8px;
display: inline;
}
.value {
margin: 0.2em;
padding: 0.1em 0.3em;
}
.item-value {
font-size: 0.6em;
overflow-x: auto;
}
.sub-level {
padding-left: 20px;
}
</style>

View File

@@ -0,0 +1,179 @@
<template>
<div class="workflow-activator">
<el-switch
v-loading="loading"
element-loading-spinner="el-icon-loading"
:value="workflowActive"
@change="activeChanged"
:title="workflowActive?'Deactivate Workflow':'Activate Workflow'"
:disabled="disabled || loading"
:active-color="getActiveColor"
inactive-color="#8899AA">
</el-switch>
<div class="could-not-be-started" v-if="couldNotBeStarted">
<el-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>
<font-awesome-icon @click="displayActivationError" icon="exclamation-triangle" />
</el-tooltip>
</div>
</div>
</template>
<script lang="ts">
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import {
IWorkflowDataUpdate,
} from '../Interface';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
restApi,
showMessage,
workflowHelpers,
)
.extend(
{
name: 'WorkflowActivator',
props: [
'disabled',
'workflowActive',
'workflowId',
],
data () {
return {
loading: false,
};
},
computed: {
nodesIssuesExist (): boolean {
return this.$store.getters.nodesIssuesExist;
},
isWorkflowActive (): boolean {
const activeWorkflows = this.$store.getters.getActiveWorkflows;
return activeWorkflows.includes(this.workflowId);
},
couldNotBeStarted (): boolean {
return this.workflowActive === true && this.isWorkflowActive !== this.workflowActive;
},
getActiveColor (): string {
if (this.couldNotBeStarted === true) {
return '#ff4949';
}
return '#13ce66';
},
},
methods: {
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!',
type: 'error',
});
return;
}
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!',
type: 'error',
});
return;
}
// Set that the active state should be changed
let data: IWorkflowDataUpdate = {};
const activeWorkflowId = this.$store.getters.workflowId;
if (newActiveState === true && this.workflowId === activeWorkflowId) {
// If the currently active workflow gets activated save the whole
// workflow. If that would not happen then it could be quite confusing
// for people because it would activate a different version of the workflow
// than the one they can currently see.
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!');
if (importConfirm === false) {
return;
}
// Get the current workflow data that it gets saved together with the activation
data = await this.getWorkflowDataToSave();
}
data.active = newActiveState;
this.loading = true;
try {
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.loading = false;
return;
}
const currentWorkflowId = this.$store.getters.workflowId;
if (currentWorkflowId === this.workflowId) {
// If the status of the current workflow got changed
// commit it specifically
this.$store.commit('setActive', newActiveState);
}
if (newActiveState === true) {
this.$store.commit('setWorkflowActive', this.workflowId);
} else {
this.$store.commit('setWorkflowInactive', this.workflowId);
}
this.$emit('workflowActiveChanged', { id: this.workflowId, active: newActiveState });
this.loading = false;
},
async displayActivationError () {
let errorMessage: string;
try {
const errorData = await this.restApi().getActivationError(this.workflowId);
if (errorData === undefined) {
errorMessage = 'Sorry there was a problem. No error got found to display.';
} else {
errorMessage = `The following error occurred on workflow activation:<br /><i>${errorData.error.message}</i>`;
}
} catch (error) {
errorMessage = 'Sorry there was a problem requesting the error';
}
this.$showMessage({
title: 'Problem activating workflow',
message: errorMessage,
type: 'warning',
duration: 0,
});
},
},
}
);
</script>
<style scoped>
.workflow-activator {
display: inline-block;
}
.could-not-be-started {
display: inline-block;
color: #ff4949;
margin-left: 0.5em;
}
/deep/ .el-loading-spinner {
margin-top: -10px;
}
</style>

View File

@@ -0,0 +1,140 @@
<template>
<span>
<el-dialog :visible="dialogVisible" append-to-body width="80%" title="Open Workflow" :before-close="closeDialog" top="5vh">
<div class="text-very-light">
Select a workflow to open:
</div>
<div class="search-wrapper ignore-key-press">
<el-input placeholder="Workflow filter..." ref="inputFieldFilter" v-model="filterText">
<i slot="prefix" class="el-input__icon el-icon-search"></i>
</el-input>
</div>
<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>
<el-table-column property="createdAt" label="Created" class-name="clickable" width="225" sortable></el-table-column>
<el-table-column property="updatedAt" label="Updated" class-name="clickable" width="225" sortable></el-table-column>
<el-table-column label="Active" width="90">
<template slot-scope="scope">
<workflow-activator :workflow-active="scope.row.active" :workflow-id="scope.row.id" @workflowActiveChanged="workflowActiveChanged" />
</template>
</el-table-column>
</el-table>
</el-dialog>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { restApi } from '@/components/mixins/restApi';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import { IWorkflowShortResponse } from '@/Interface';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
restApi,
showMessage,
).extend({
name: 'WorkflowOpen',
props: [
'dialogVisible',
],
components: {
WorkflowActivator,
},
data () {
return {
filterText: '',
isDataLoading: false,
workflows: [] as IWorkflowShortResponse[],
};
},
computed: {
filteredWorkflows (): IWorkflowShortResponse[] {
return this.workflows.filter((workflow: IWorkflowShortResponse) => {
if (this.filterText === '' || workflow.name.toLowerCase().indexOf(this.filterText.toLowerCase()) !== -1) {
return true;
}
return false;
});
},
},
watch: {
dialogVisible (newValue, oldValue) {
if (newValue) {
this.filterText = '';
this.openDialog();
Vue.nextTick(() => {
// Make sure that users can directly type in the filter
(this.$refs.inputFieldFilter as HTMLInputElement).focus();
});
}
},
},
methods: {
closeDialog () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
},
openWorkflow (data: IWorkflowShortResponse, column: any) { // tslint:disable-line:no-any
if (column.label !== 'Active') {
this.$emit('openWorkflow', data.id);
}
},
openDialog () {
this.isDataLoading = true;
this.restApi().getWorkflows()
.then(
(data) => {
this.workflows = data;
this.workflows.forEach((workflowData: IWorkflowShortResponse) => {
workflowData.createdAt = this.convertToDisplayDate(workflowData.createdAt as number);
workflowData.updatedAt = this.convertToDisplayDate(workflowData.updatedAt as number);
});
this.isDataLoading = false;
}
)
.catch(
(error: Error) => {
this.$showError(error, 'Problem loading workflows', 'There was a problem loading the workflows:');
this.isDataLoading = false;
}
);
},
workflowActiveChanged (data: { id: string, active: boolean }) {
for (const workflow of this.workflows) {
if (workflow.id === data.id) {
workflow.active = data.active;
}
}
},
},
});
</script>
<style scoped lang="scss">
.search-wrapper {
position: absolute;
right: 20px;
top: 20px;
width: 200px;
}
.search-table {
margin-top: 2em;
}
</style>

View File

@@ -0,0 +1,283 @@
<template>
<span>
<el-dialog class="workflow-settings" :visible="dialogVisible" append-to-body width="50%" title="Workflow Settings" :before-close="closeDialog">
<div v-loading="isLoading">
<el-row>
<el-col :span="10" class="setting-name">
Error Workflow:
<el-tooltip class="setting-info" placement="top" effect="light">
<div slot="content" v-html="helpTexts.errorWorkflow"></div>
<font-awesome-icon icon="question-circle" />
</el-tooltip>
</el-col>
<el-col :span="14" class="ignore-key-press">
<el-select v-model="workflowSettings.errorWorkflow" placeholder="Select Workflow" size="small" filterable>
<el-option
v-for="item in workflows"
:key="item.id"
:label="item.name"
:value="item.id">
</el-option>
</el-select>
</el-col>
</el-row>
<el-row>
<el-col :span="10" class="setting-name">
Timezone:
<el-tooltip class="setting-info" placement="top" effect="light">
<div slot="content" v-html="helpTexts.timezone"></div>
<font-awesome-icon icon="question-circle" />
</el-tooltip>
</el-col>
<el-col :span="14" class="ignore-key-press">
<el-select v-model="workflowSettings.timezone" placeholder="Select Timezone" size="small" filterable>
<el-option
v-for="timezone of timezones"
:key="timezone.key"
:label="timezone.value"
:value="timezone.key">
</el-option>
</el-select>
</el-col>
</el-row>
<el-row>
<el-col :span="10" class="setting-name">
Save Manual Runs:
<el-tooltip class="setting-info" placement="top" effect="light">
<div slot="content" v-html="helpTexts.saveManualRuns"></div>
<font-awesome-icon icon="question-circle" />
</el-tooltip>
</el-col>
<el-col :span="14" class="ignore-key-press">
<el-select v-model="workflowSettings.saveManualRuns" placeholder="Select Option" size="small" filterable>
<el-option
v-for="option of saveManualOptions"
:key="option.key"
:label="option.value"
:value="option.key">
</el-option>
</el-select>
</el-col>
</el-row>
<div class="action-buttons">
<el-button type="success" @click="saveSettings">
Save
</el-button>
</div>
</div>
</el-dialog>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
import { restApi } from '@/components/mixins/restApi';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import {
IWorkflowShortResponse,
IWorkflowDataUpdate,
} from '@/Interface';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
restApi,
showMessage,
).extend({
name: 'WorkflowSettings',
props: [
'dialogVisible',
],
data () {
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.',
saveManualRuns: 'If data data of executions should be saved when started manually from the editor.',
},
defaultValues: {
timezone: 'America/New_York',
saveManualRuns: false,
},
saveManualOptions: [] as Array<{ key: string | boolean, value: string }>,
timezones: [] as Array<{ key: string, value: string }>,
workflowSettings: {},
workflows: [] as IWorkflowShortResponse[],
};
},
watch: {
dialogVisible (newValue, oldValue) {
if (newValue) {
this.openDialog();
}
},
},
methods: {
closeDialog () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
},
async loadSaveManualOptions () {
this.saveManualOptions.length = 0;
this.saveManualOptions.push({
key: 'DEFAULT',
value: 'Use default - ' + (this.defaultValues.saveManualRuns === true ? 'Yes' : 'No'),
});
this.saveManualOptions.push({
key: true,
value: 'Yes',
});
this.saveManualOptions.push({
key: false,
value: 'No',
});
},
async loadTimezones () {
if (this.timezones.length !== 0) {
// Data got already loaded
return;
}
const timezones = await this.restApi().getTimezones();
let defaultTimezoneValue = timezones[this.defaultValues.timezone] as string | undefined;
if (defaultTimezoneValue === undefined) {
defaultTimezoneValue = 'Default Timezone not valid!';
}
this.timezones.push({
key: 'DEFAULT',
value: `Default - ${defaultTimezoneValue}`,
});
for (const timezone of Object.keys(timezones)) {
this.timezones.push({
key: timezone,
value: timezones[timezone] as string,
});
}
},
async loadWorkflows () {
const workflows = await this.restApi().getWorkflows();
workflows.sort((a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
return -1;
}
if (a.name.toLowerCase() > b.name.toLowerCase()) {
return 1;
}
return 0;
});
// @ts-ignore
workflows.unshift({
id: undefined as unknown as string,
name: '- No Workflow -',
});
Vue.set(this, 'workflows', workflows);
},
async openDialog () {
const workflowId = this.$route.params.name;
if (this.$route.params.name === undefined) {
this.$showMessage({
title: 'No workflow active',
message: `No workflow active to display settings of.`,
type: 'error',
duration: 0,
});
this.closeDialog();
return;
}
this.defaultValues.saveManualRuns = this.$store.getters.saveManualRuns;
this.defaultValues.timezone = this.$store.getters.timezone;
this.isLoading = true;
const promises = [];
promises.push(this.loadWorkflows());
promises.push(this.loadSaveManualOptions());
promises.push(this.loadTimezones());
try {
await Promise.all(promises);
} catch (error) {
this.$showError(error, 'Problem loading settings', 'The following error occurred loading the data:');
}
const workflowSettings = JSON.parse(JSON.stringify(this.$store.getters.workflowSettings));
if (workflowSettings.timezone === undefined) {
workflowSettings.timezone = 'DEFAULT';
}
if (workflowSettings.saveManualRuns === undefined) {
workflowSettings.saveManualRuns = 'DEFAULT';
}
Vue.set(this, 'workflowSettings', workflowSettings);
this.isLoading = false;
},
async saveSettings () {
// Set that the active state should be changed
const data: IWorkflowDataUpdate = {
settings: this.workflowSettings,
};
this.isLoading = true;
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.isLoading = false;
return;
}
this.$store.commit('setWorkflowSettings', this.workflowSettings);
this.isLoading = false;
this.$showMessage({
title: 'Settings saved',
message: 'The workflow settings got saved!',
type: 'success',
});
this.closeDialog();
},
},
});
</script>
<style scoped lang="scss">
.workflow-settings {
.el-row {
padding: 0.25em 0;
}
}
.action-buttons {
margin-top: 1em;
text-align: right;
}
.setting-info {
display: none;
}
.setting-name {
line-height: 32px;
}
.setting-name:hover {
.setting-info {
display: inline;
}
}
</style>

View File

@@ -0,0 +1,201 @@
/**
* Captures any pasted data and sends it to method "receivedCopyPasteData" which has to be
* defined on the component which uses this mixin
*/
import Vue from 'vue';
// export const copyPaste = {
export const copyPaste = Vue.extend({
data () {
return {
copyPasteElementsGotCreated: false,
};
},
mounted () {
if (this.copyPasteElementsGotCreated === true) {
return;
}
this.copyPasteElementsGotCreated = true;
// Define the style of the html elements that get created to make
// sure that they are not visible
const style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = `
.hidden-copy-paste {
position: absolute;
bottom: 0;
left: 0;
width: 10px;
height: 10px;
display: block;
font-size: 1px;
z-index: -1;
color: transparent;
background: transparent;
overflow: hidden;
border: none;
padding: 0;
resize: none;
outline: none;
-webkit-user-select: text;
user-select: text;
}
`;
document.getElementsByTagName('head')[0].appendChild(style);
// Code is mainly from
// https://www.lucidchart.com/techblog/2014/12/02/definitive-guide-copying-pasting-javascript/
const isSafari = navigator.appVersion.search('Safari') !== -1 && navigator.appVersion.search('Chrome') === -1 && navigator.appVersion.search('CrMo') === -1 && navigator.appVersion.search('CriOS') === -1;
const isIe = (navigator.userAgent.toLowerCase().indexOf('msie') !== -1 || navigator.userAgent.toLowerCase().indexOf('trident') !== -1);
const hiddenInput = document.createElement('input');
hiddenInput.setAttribute('type', 'text');
hiddenInput.setAttribute('id', 'hidden-input-copy-paste');
hiddenInput.setAttribute('class', 'hidden-copy-paste');
document.body.append(hiddenInput);
let ieClipboardDiv: HTMLDivElement | null = null;
if (isIe) {
ieClipboardDiv = document.createElement('div');
ieClipboardDiv.setAttribute('id', 'hidden-ie-clipboard-copy-paste');
ieClipboardDiv.setAttribute('class', 'hidden-copy-paste');
ieClipboardDiv.setAttribute('contenteditable', 'true');
document.body.append(ieClipboardDiv);
document.addEventListener('beforepaste', () => {
// @ts-ignore
if (hiddenInput.is(':focus')) {
this.focusIeClipboardDiv(ieClipboardDiv as HTMLDivElement);
}
}, true);
}
let userInput = '';
const hiddenInputListener = (text: string) => { };
hiddenInput.addEventListener('input', (e) => {
const value = hiddenInput.value;
userInput += value;
hiddenInputListener(userInput);
// There is a bug (sometimes) with Safari and the input area can't be updated during
// the input event, so we update the input area after the event is done being processed
if (isSafari) {
hiddenInput.focus();
setTimeout(() => { this.focusHiddenArea(hiddenInput); }, 0);
} else {
this.focusHiddenArea(hiddenInput);
}
});
// Set clipboard event listeners on the document.
['paste'].forEach((event) => {
document.addEventListener(event, (e) => {
// Check if the event got emitted from a message box or from something
// else which should ignore the copy/paste
// @ts-ignore
const path = e.path || (e.composedPath && e.composedPath());
for (let index = 0; index < path.length; index++) {
if (path[index].className && typeof path[index].className === 'string' && (
path[index].className.includes('el-message-box') || path[index].className.includes('ignore-key-press')
)) {
return;
}
}
if (ieClipboardDiv !== null) {
this.ieClipboardEvent(event, ieClipboardDiv);
} else {
this.standardClipboardEvent(event, e as ClipboardEvent);
// @ts-ignore
if (!document.activeElement || (document.activeElement && ['textarea', 'text', 'email', 'password'].indexOf(document.activeElement.type) === -1)) {
// That it still allows to paste into text, email, password & textarea-fiels we
// check if we can identify the active element and if so only
// run it if something else is selected.
this.focusHiddenArea(hiddenInput);
e.preventDefault();
}
}
});
});
},
methods: {
receivedCopyPasteData (plainTextData: string, event?: ClipboardEvent): void {
// THIS HAS TO BE DEFINED IN COMPONENT!
},
// For every browser except IE, we can easily get and set data on the clipboard
standardClipboardEvent (clipboardEventName: string, event: ClipboardEvent) {
const clipboardData = event.clipboardData;
if (clipboardData !== null && clipboardEventName === 'paste') {
const clipboardText = clipboardData.getData('text/plain');
this.receivedCopyPasteData(clipboardText, event);
}
},
// For IE, we can get/set Text or URL just as we normally would
ieClipboardEvent (clipboardEventName: string, ieClipboardDiv: HTMLDivElement) {
// @ts-ignore
const clipboardData = window.clipboardData;
if (clipboardEventName === 'paste') {
const clipboardText = clipboardData.getData('Text');
// @ts-ignore
ieClipboardDiv.empty();
this.receivedCopyPasteData(clipboardText);
}
},
// Focuses an element to be ready for copy/paste (used exclusively for IE)
focusIeClipboardDiv (ieClipboardDiv: HTMLDivElement) {
ieClipboardDiv.focus();
const range = document.createRange();
// @ts-ignore
range.selectNodeContents((ieClipboardDiv.get(0)));
const selection = window.getSelection();
if (selection !== null) {
selection.removeAllRanges();
selection.addRange(range);
}
},
focusHiddenArea (hiddenInput: HTMLInputElement) {
// In order to ensure that the browser will fire clipboard events, we always need to have something selected
hiddenInput.value = ' ';
hiddenInput.focus();
hiddenInput.select();
},
/**
* Copies given data to clipboard
*/
copyToClipboard (value: string): void {
// FROM: https://hackernoon.com/copying-text-to-clipboard-with-javascript-df4d4988697f
const element = document.createElement('textarea'); // Create a <textarea> element
element.value = value; // Set its value to the string that you want copied
element.setAttribute('readonly', ''); // Make it readonly to be tamper-proof
element.style.position = 'absolute';
element.style.left = '-9999px'; // Move outside the screen to make it invisible
document.body.appendChild(element); // Append the <textarea> element to the HTML document
const selection = document.getSelection();
if (selection === null) {
return;
}
const selected = selection.rangeCount > 0 // Check if there is any content selected previously
? selection.getRangeAt(0) // Store selection if found
: false; // Mark as false to know no selection existed before
element.select(); // Select the <textarea> content
document.execCommand('copy'); // Copy - only works as a result of a user action (e.g. click events)
document.body.removeChild(element); // Remove the <textarea> element
if (selected) {
// If a selection existed before copying
selection.removeAllRanges(); // Unselect everything on the HTML document
selection.addRange(selected); // Restore the original selection
}
},
},
});

View File

@@ -0,0 +1,77 @@
import dateformat from 'dateformat';
import { showMessage } from '@/components/mixins/showMessage';
import { MessageType } from '@/Interface';
import mixins from 'vue-typed-mixins';
export const genericHelpers = mixins(showMessage).extend({
data () {
return {
loadingService: null as any | null, // tslint:disable-line:no-any
};
},
computed: {
isReadOnly (): boolean {
if (['NodeViewExisting', 'NodeViewNew'].includes(this.$route.name as string)) {
return false;
}
return true;
},
},
methods: {
convertToDisplayDate (epochTime: number) {
return dateformat(epochTime, 'yyyy-mm-dd HH:MM:ss');
},
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.`,
type: 'error',
duration: 0,
});
return false;
}
return true;
},
startLoading () {
if (this.loadingService !== null) {
return;
}
this.loadingService = this.$loading(
{
lock: true,
text: 'Loading',
spinner: 'el-icon-loading',
background: 'rgba(255, 255, 255, 0.8)',
}
);
},
stopLoading () {
if (this.loadingService !== null) {
this.loadingService.close();
this.loadingService = null;
}
},
async confirmMessage (message: string, headline: string, type = 'warning' as MessageType, confirmButtonText = 'OK', cancelButtonText = 'Cancel'): Promise<boolean> {
try {
await this.$confirm(message, headline, {
confirmButtonText,
cancelButtonText,
type,
dangerouslyUseHTMLString: true,
});
return true;
} catch (e) {
return false;
}
},
},
});

View File

@@ -0,0 +1,162 @@
import { INodeUi } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { nodeIndex } from '@/components/mixins/nodeIndex';
export const mouseSelect = mixins(nodeIndex).extend({
data () {
return {
selectActive: false,
selectBox: document.createElement('span'),
};
},
mounted () {
this.createSelectBox();
},
methods: {
createSelectBox () {
this.selectBox.id = 'select-box';
this.selectBox.style.margin = '0px auto';
this.selectBox.style.border = '2px dotted #FF0000';
this.selectBox.style.position = 'fixed';
this.selectBox.style.zIndex = '100';
this.selectBox.style.visibility = 'hidden';
this.selectBox.addEventListener('mouseup', this.mouseUpMouseSelect);
// document.body.appendChild(this.selectBox);
this.$el.appendChild(this.selectBox);
},
showSelectBox (event: MouseEvent) {
// @ts-ignore
this.selectBox.x = event.pageX;
// @ts-ignore
this.selectBox.y = event.pageY;
this.selectBox.style.left = event.pageX + 'px';
this.selectBox.style.top = event.pageY + 'px';
this.selectBox.style.visibility = 'visible';
this.selectActive = true;
},
updateSelectBox (event: MouseEvent) {
const selectionBox = this.getSelectionBox(event);
this.selectBox.style.left = selectionBox.x + 'px';
this.selectBox.style.top = selectionBox.y + 'px';
this.selectBox.style.width = selectionBox.width + 'px';
this.selectBox.style.height = selectionBox.height + 'px';
},
hideSelectBox () {
this.selectBox.style.visibility = 'hidden';
// @ts-ignore
this.selectBox.x = 0;
// @ts-ignore
this.selectBox.y = 0;
this.selectBox.style.left = '0px';
this.selectBox.style.top = '0px';
this.selectBox.style.width = '0px';
this.selectBox.style.height = '0px';
this.selectActive = false;
},
getSelectionBox (event: MouseEvent) {
return {
// @ts-ignore
x: Math.min(event.pageX, this.selectBox.x),
// @ts-ignore
y: Math.min(event.pageY, this.selectBox.y),
// @ts-ignore
width: Math.abs(event.pageX - this.selectBox.x),
// @ts-ignore
height: Math.abs(event.pageY - this.selectBox.y),
};
},
getNodesInSelection (event: MouseEvent): INodeUi[] {
const returnNodes: INodeUi[] = [];
const selectionBox = this.getSelectionBox(event);
const offsetPosition = this.$store.getters.getNodeViewOffsetPosition;
// Consider the offset of the workflow when it got moved
selectionBox.x -= offsetPosition[0];
selectionBox.y -= offsetPosition[1];
// Go through all nodes and check if they are selected
this.$store.getters.allNodes.forEach((node: INodeUi) => {
// TODO: Currently always uses the top left corner for checking. Should probably use the center instead
if (node.position[0] < selectionBox.x || node.position[0] > (selectionBox.x + selectionBox.width)) {
return;
}
if (node.position[1] < selectionBox.y || node.position[1] > (selectionBox.y + selectionBox.height)) {
return;
}
returnNodes.push(node);
});
return returnNodes;
},
mouseDownMouseSelect (e: MouseEvent) {
if (e.ctrlKey === true) {
// We only care about it when the ctrl key is not pressed at the same time.
// So we exit when it is pressed.
return;
}
if (this.$store.getters.isActionActive('dragActive')) {
// If a node does currently get dragged we do not activate the selection
return;
}
this.showSelectBox(e);
// @ts-ignore // Leave like this. Do not add a anonymous function because then remove would not work anymore
this.$el.addEventListener('mousemove', this.mouseMoveSelect);
},
mouseUpMouseSelect (e: MouseEvent) {
if (this.selectActive === false) {
// If it is not active return direcly.
// Else normal node dragging will not work.
return;
}
// @ts-ignore
this.$el.removeEventListener('mousemove', this.mouseMoveSelect);
// Deselect all nodes
this.deselectAllNodes();
// Select the nodes which are in the selection box
const selectedNodes = this.getNodesInSelection(e);
selectedNodes.forEach((node) => {
this.nodeSelected(node);
});
this.hideSelectBox();
},
mouseMoveSelect (e: MouseEvent) {
if (e.buttons === 0) {
// Mouse button is not pressed anymore so stop selection mode
// Happens normally when mouse leave the view pressed and then
// comes back unpressed.
this.mouseUpMouseSelect(e);
return;
}
this.updateSelectBox(e);
},
nodeSelected (node: INodeUi) {
this.$store.commit('addSelectedNode', node);
const nodeElement = `node-${this.getNodeIndex(node.name)}`;
// @ts-ignore
this.instance.addToDragSelection(nodeElement);
},
deselectAllNodes () {
// @ts-ignore
this.instance.clearDragSelection();
this.$store.commit('resetSelectedNodes');
this.$store.commit('setLastSelectedNode', null);
this.$store.commit('setActiveNode', null);
},
},
});

View File

@@ -0,0 +1,72 @@
import mixins from 'vue-typed-mixins';
import { nodeIndex } from '@/components/mixins/nodeIndex';
export const moveNodeWorkflow = mixins(nodeIndex).extend({
data () {
return {
moveLastPosition: [0, 0],
};
},
mounted () {
},
methods: {
moveWorkflow (e: MouseEvent) {
const offsetPosition = this.$store.getters.getNodeViewOffsetPosition;
const nodeViewOffsetPositionX = offsetPosition[0] + (e.pageX - this.moveLastPosition[0]);
const nodeViewOffsetPositionY = offsetPosition[1] + (e.pageY - this.moveLastPosition[1]);
this.$store.commit('setNodeViewOffsetPosition', [nodeViewOffsetPositionX, nodeViewOffsetPositionY]);
// Update the last position
this.moveLastPosition[0] = e.pageX;
this.moveLastPosition[1] = e.pageY;
},
mouseDownMoveWorkflow (e: MouseEvent) {
if (e.ctrlKey === false) {
// We only care about it when the ctrl key is pressed at the same time.
// So we exit when it is not pressed.
return;
}
if (this.$store.getters.isActionActive('dragActive')) {
// If a node does currently get dragged we do not activate the selection
return;
}
this.$store.commit('setNodeViewMoveInProgress', true);
this.moveLastPosition[0] = e.pageX;
this.moveLastPosition[1] = e.pageY;
// @ts-ignore
this.$el.addEventListener('mousemove', this.mouseMoveNodeWorkflow);
},
mouseUpMoveWorkflow (e: MouseEvent) {
if (this.$store.getters.isNodeViewMoveInProgress === false) {
// If it is not active return direcly.
// Else normal node dragging will not work.
return;
}
// @ts-ignore
this.$el.removeEventListener('mousemove', this.mouseMoveNodeWorkflow);
this.$store.commit('setNodeViewMoveInProgress', false);
// Nothing else to do. Simply leave the node view at the current offset
},
mouseMoveNodeWorkflow (e: MouseEvent) {
if (e.buttons === 0) {
// Mouse button is not pressed anymore so stop selection mode
// Happens normally when mouse leave the view pressed and then
// comes back unpressed.
// @ts-ignore
this.mouseUp(e);
return;
}
this.moveWorkflow(e);
},
},
});

View File

@@ -0,0 +1,293 @@
import { IConnectionsUi, IEndpointOptions, INodeUi, XYPositon } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { nodeIndex } from '@/components/mixins/nodeIndex';
import { NODE_NAME_PREFIX } from '@/constants';
export const nodeBase = mixins(nodeIndex).extend({
mounted () {
// Initialize the node
if (this.data !== null) {
this.__addNode(this.data);
}
},
data () {
return {
};
},
computed: {
data (): INodeUi {
return this.$store.getters.nodeByName(this.name);
},
hasIssues (): boolean {
if (this.data.issues !== undefined && Object.keys(this.data.issues).length) {
return true;
}
return false;
},
isReadOnly (): boolean {
if (['NodeViewExisting', 'NodeViewNew'].includes(this.$route.name as string)) {
return false;
}
return true;
},
nodeName (): string {
return NODE_NAME_PREFIX + this.nodeIndex;
},
nodeIndex (): string {
return this.$store.getters.getNodeIndex(this.data.name).toString();
},
nodeStyle (): object {
const returnStyles: {
[key: string]: string;
} = {
left: this.data.position[0] + 'px',
top: this.data.position[1] + 'px',
'border-color': this.data.color as string,
};
return returnStyles;
},
},
props: [
'name',
'nodeId',
'instance',
],
methods: {
__addNode (node: INodeUi) {
// TODO: Later move the node-connection definitions to a special file
const nodeConnectors: IConnectionsUi = {
main: {
input: {
uuid: '-top',
maxConnections: -1,
endpoint: 'Rectangle',
endpointStyle: { width: 24, height: 12, fill: '#555', stroke: '#555', strokeWidth: 0 },
dragAllowedWhenFull: true,
},
output: {
uuid: '-bottom',
maxConnections: -1,
endpoint: 'Dot',
endpointStyle: { radius: 9, fill: '#555', outlineStroke: 'none' },
dragAllowedWhenFull: true,
},
},
};
let nodeTypeData = this.$store.getters.nodeType(node.type);
if (!nodeTypeData) {
// If node type is not know use by default the base.noOp data to display it
nodeTypeData = this.$store.getters.nodeType('n8n-nodes-base.noOp');
}
const anchorPositions: {
[key: string]: {
[key: number]: string[] | number[][];
}
} = {
input: {
1: [
'Top',
],
2: [
[0.3, 0, 0, -1],
[0.7, 0, 0, -1],
],
3: [
[0.25, 0, 0, -1],
[0.5, 0, 0, -1],
[0.75, 0, 0, -1],
],
},
output: {
1: [
'Bottom',
],
2: [
[0.3, 1, 0, 1],
[0.7, 1, 0, 1],
],
3: [
[0.25, 1, 0, 1],
[0.5, 1, 0, 1],
[0.75, 1, 0, 1],
],
},
};
// Add Inputs
let index, inputData, anchorPosition;
let newEndpointData: IEndpointOptions;
let indexData: {
[key: string]: number;
} = {};
nodeTypeData.inputs.forEach((inputName: string) => {
// @ts-ignore
inputData = nodeConnectors[inputName].input;
// Increment the index for inputs with current name
if (indexData.hasOwnProperty(inputName)) {
indexData[inputName]++;
} else {
indexData[inputName] = 0;
}
index = indexData[inputName];
// Get the position of the anchor depending on how many it has
anchorPosition = anchorPositions.input[nodeTypeData.inputs.length][index];
newEndpointData = {
uuid: `${this.nodeIndex}` + inputData.uuid + index,
anchor: anchorPosition,
maxConnections: inputData.maxConnections,
endpoint: inputData.endpoint,
endpointStyle: inputData.endpointStyle,
isSource: false,
isTarget: true,
parameters: {
nodeIndex: this.nodeIndex,
type: inputName,
index,
},
dragAllowedWhenFull: inputData.dragAllowedWhenFull,
dropOptions: {
tolerance: 'touch',
hoverClass: 'dropHover',
},
};
this.instance.addEndpoint(this.nodeName, newEndpointData);
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);
}
});
// Add Outputs
indexData = {};
nodeTypeData.outputs.forEach((inputName: string) => {
inputData = nodeConnectors[inputName].output;
// Increment the index for outputs with current name
if (indexData.hasOwnProperty(inputName)) {
indexData[inputName]++;
} else {
indexData[inputName] = 0;
}
index = indexData[inputName];
// Get the position of the anchor depending on how many it has
anchorPosition = anchorPositions.output[nodeTypeData.outputs.length][index];
newEndpointData = {
uuid: `${this.nodeIndex}` + inputData.uuid + index,
anchor: anchorPosition,
maxConnections: inputData.maxConnections,
endpoint: inputData.endpoint,
endpointStyle: inputData.endpointStyle,
isSource: true,
isTarget: false,
parameters: {
nodeIndex: this.nodeIndex,
type: inputName,
index,
},
dragAllowedWhenFull: inputData.dragAllowedWhenFull,
dragProxy: ['Rectangle', { width: 1, height: 1, strokeWidth: 0 }],
};
if (nodeTypeData.outputNames) {
// Apply output names if they got set
newEndpointData.overlays = [
['Label',
{
location: [0.5, 1.5],
label: nodeTypeData.outputNames[index],
cssClass: 'node-endpoint-label',
visible: true,
},
],
];
}
this.instance.addEndpoint(this.nodeName, newEndpointData);
});
// Make nodes draggable
this.instance.draggable(this.nodeName, {
grid: [10, 10],
start: (params: { e: MouseEvent }) => {
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
// undefined. So check if the currently dragged node is selected and if not clear
// the drag-selection.
this.instance.clearDragSelection();
this.$store.commit('resetSelectedNodes');
}
this.$store.commit('addActiveAction', 'dragActive');
},
stop: (params: { e: MouseEvent}) => {
if (this.$store.getters.isActionActive('dragActive')) {
const moveNodes = this.$store.getters.getSelectedNodes.slice();
const selectedNodeNames = moveNodes.map((node: INodeUi) => node.name);
if (!selectedNodeNames.includes(this.data.name)) {
// If the current node is not in selected add it to the nodes which
// got moved manually
moveNodes.push(this.data);
}
// This does for some reason just get called once for the node that got clicked
// 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;
moveNodes.forEach((node: INodeUi) => {
const nodeElement = `node-${this.getNodeIndex(node.name)}`;
const element = document.getElementById(nodeElement);
if (element === null) {
return;
}
newNodePositon = [
parseInt(element.style.left!.slice(0, -2), 10),
parseInt(element.style.top!.slice(0, -2), 10),
];
const updateInformation = {
name: node.name,
properties: {
// @ts-ignore, draggable does not have definitions
position: newNodePositon,
},
};
this.$store.commit('updateNodeProperties', updateInformation);
});
this.$store.commit('removeActiveAction', 'dragActive');
}
},
filter: '.action-button',
});
},
mouseLeftClick (e: MouseEvent) {
if (this.$store.getters.isActionActive('dragActive')) {
this.$store.commit('removeActiveAction', 'dragActive');
} else {
if (!e.ctrlKey) {
this.$emit('deselectAllNodes');
}
this.$emit('nodeSelected', this.name);
}
},
},
});

View File

@@ -0,0 +1,251 @@
import {
IBinaryKeyData,
ICredentialType,
INodeCredentialDescription,
NodeHelpers,
INodeParameters,
INodeExecutionData,
INodeIssues,
INodeIssueObjectProperty,
INodeProperties,
INodeTypeDescription,
IRunData,
IRunExecutionData,
ITaskDataConnections,
} from 'n8n-workflow';
import {
ICredentialsResponse,
INodeUi,
} from '../../Interface';
import { restApi } from '@/components/mixins/restApi';
import { get } from 'lodash';
import mixins from 'vue-typed-mixins';
export const nodeHelpers = mixins(
restApi,
)
.extend({
methods: {
// Returns the parameter value
getParameterValue (nodeValues: INodeParameters, parameterName: string, path: string) {
return get(
nodeValues,
path ? path + '.' + parameterName : parameterName,
);
},
// Returns if the given parameter should be displayed or not
displayParameter (nodeValues: INodeParameters, parameter: INodeProperties | INodeCredentialDescription, path: string) {
return NodeHelpers.displayParameterPath(nodeValues, parameter, path);
},
// Returns all the issues of the node
getNodeIssues (nodeType: INodeTypeDescription | null, node: INodeUi, ignoreIssues?: string[]): INodeIssues | null {
let nodeIssues: INodeIssues | null = null;
ignoreIssues = ignoreIssues || [];
if (nodeType === null) {
// Node type is not known
if (!ignoreIssues.includes('typeUnknown')) {
nodeIssues = {
typeUnknown: true,
};
}
} else {
// Node type is known
// Add potential parameter issues
if (!ignoreIssues.includes('parameters')) {
nodeIssues = NodeHelpers.getNodeParametersIssues(nodeType.properties, node);
}
if (!ignoreIssues.includes('credentials')) {
// Add potential credential issues
const nodeCredentialIssues = this.getNodeCredentialIssues(node, nodeType);
if (nodeIssues === null) {
nodeIssues = nodeCredentialIssues;
} else {
NodeHelpers.mergeIssues(nodeIssues, nodeCredentialIssues);
}
}
}
if (this.hasNodeExecutionIssues(node) === true && !ignoreIssues.includes('execution')) {
if (nodeIssues === null) {
nodeIssues = {};
}
nodeIssues.execution = true;
}
return nodeIssues;
},
// Set the status on all the nodes which produced an error so that it can be
// displayed in the node-view
hasNodeExecutionIssues (node: INodeUi): boolean {
const workflowResultData: IRunData = this.$store.getters.getWorkflowRunData;
if (workflowResultData === null || !workflowResultData.hasOwnProperty(node.name)) {
return false;
}
for (const taskData of workflowResultData[node.name]) {
if (taskData.error !== undefined) {
return true;
}
}
return false;
},
// Updates the execution issues.
updateNodesExecutionIssues () {
const nodes = this.$store.getters.allNodes;
for (const node of nodes) {
this.$store.commit('setNodeIssue', {
node: node.name,
type: 'execution',
value: this.hasNodeExecutionIssues(node) ? true : null,
});
}
},
// Returns all the credential-issues of the node
getNodeCredentialIssues (node: INodeUi, nodeType?: INodeTypeDescription): INodeIssues | null {
if (nodeType === undefined) {
nodeType = this.$store.getters.nodeType(node.type);
}
if (nodeType === null || nodeType!.credentials === undefined) {
// Node does not need any credentials or nodeType could not be found
return null;
}
const foundIssues: INodeIssueObjectProperty = {};
let userCredentials: ICredentialsResponse[] | null;
let credentialType: ICredentialType | null;
let credentialDisplayName: string;
let selectedCredentials: string;
for (const credentialTypeDescription of nodeType!.credentials) {
// Check if credentials should be displayed else ignore
if (this.displayParameter(node.parameters, credentialTypeDescription, '') !== true) {
continue;
}
// Get the display name of the credential type
credentialType = this.$store.getters.credentialType(credentialTypeDescription.name);
if (credentialType === null) {
credentialDisplayName = credentialTypeDescription.name;
} else {
credentialDisplayName = credentialType.displayName;
}
if (node.credentials === undefined || node.credentials[credentialTypeDescription.name] === undefined) {
// Credentials are not set
if (credentialTypeDescription.required === true) {
foundIssues[credentialTypeDescription.name] = [`Credentials for "${credentialDisplayName}" are not set.`];
}
} else {
// If they are set check if the value is valid
selectedCredentials = node.credentials[credentialTypeDescription.name];
userCredentials = this.$store.getters.credentialsByType(credentialTypeDescription.name);
if (userCredentials === null) {
userCredentials = [];
}
if (userCredentials.find((credentialData) => credentialData.name === selectedCredentials) === undefined) {
foundIssues[credentialTypeDescription.name] = [`Credentials with name "${selectedCredentials}" do not exist for "${credentialDisplayName}".`];
}
}
}
// TODO: Could later check also if the node has access to the credentials
if (Object.keys(foundIssues).length === 0) {
return null;
}
return {
credentials: foundIssues,
};
},
// Updates the node credential issues
updateNodesCredentialsIssues () {
const nodes = this.$store.getters.allNodes;
let issues: INodeIssues | null;
for (const node of nodes) {
issues = this.getNodeCredentialIssues(node);
this.$store.commit('setNodeIssue', {
node: node.name,
type: 'credentials',
value: issues === null ? null : issues.credentials,
});
}
},
getNodeInputData (node: INodeUi | null, runIndex = 0, outputIndex = 0): INodeExecutionData[] {
if (node === null) {
return [];
}
if (this.$store.getters.getWorkflowExecution === null) {
return [];
}
const executionData: IRunExecutionData = this.$store.getters.getWorkflowExecution.data;
const runData = executionData.resultData.runData;
if (runData === null || runData[node.name] === undefined ||
!runData[node.name][runIndex].data ||
runData[node.name][runIndex].data === undefined
) {
return [];
}
return this.getMainInputData(runData[node.name][runIndex].data!, outputIndex);
},
// Returns the data of the main input
getMainInputData (connectionsData: ITaskDataConnections, outputIndex: number): INodeExecutionData[] {
if (!connectionsData || !connectionsData.hasOwnProperty('main') || connectionsData.main === undefined || connectionsData.main.length < outputIndex) {
return [];
}
return connectionsData.main[outputIndex] as INodeExecutionData[];
},
// Returns all the binary data of all the entries
getBinaryData (workflowRunData: IRunData | null, node: string | null, runIndex: number, outputIndex: number): IBinaryKeyData[] {
if (node === null) {
return [];
}
const runData: IRunData | null = workflowRunData;
if (runData === null || !runData[node] || !runData[node][runIndex] ||
!runData[node][runIndex].data
) {
return [];
}
const inputData = this.getMainInputData(runData[node][runIndex].data!, outputIndex);
const returnData: IBinaryKeyData[] = [];
for (let i = 0; i < inputData.length; i++) {
if (inputData[i].hasOwnProperty('binary') && inputData[i].binary !== undefined) {
returnData.push(inputData[i].binary!);
}
}
return returnData;
},
},
});

View File

@@ -0,0 +1,18 @@
import Vue from 'vue';
export const nodeIndex = Vue.extend({
methods: {
getNodeIndex (nodeName: string): string {
let uniqueId = this.$store.getters.getNodeIndex(nodeName);
if (uniqueId === -1) {
this.$store.commit('addToNodeIndex', nodeName);
uniqueId = this.$store.getters.getNodeIndex(nodeName);
}
// We return as string as draggable and jsplumb seems to make problems
// when numbers are given
return uniqueId.toString();
},
},
});

View File

@@ -0,0 +1,178 @@
import {
IPushData,
IPushDataExecutionFinished,
IPushDataNodeExecuteAfter,
IPushDataNodeExecuteBefore,
IPushDataTestWebhook,
} from '../../Interface';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import mixins from 'vue-typed-mixins';
export const pushConnection = mixins(
nodeHelpers,
showMessage,
)
.extend({
data () {
return {
eventSource: null as EventSource | null,
reconnectTimeout: null as NodeJS.Timeout | null,
};
},
computed: {
sessionId (): string {
return this.$store.getters.sessionId;
},
},
methods: {
pushAutomaticReconnect (): void {
if (this.reconnectTimeout !== null) {
return;
}
this.reconnectTimeout = setTimeout(() => {
this.pushConnect();
}, 3000);
},
/**
* Connect to server to receive data via EventSource
*/
pushConnect (): void {
// Make sure existing event-source instances get
// always removed that we do not end up with multiple ones
this.pushDisconnect();
const connectionUrl = `${this.$store.getters.getRestUrl}/push?sessionId=${this.sessionId}`;
this.eventSource = new EventSource(connectionUrl);
this.eventSource.addEventListener('message', this.pushMessageReceived, false);
this.eventSource.addEventListener('open', () => {
this.$store.commit('setPushConnectionActive', true);
if (this.reconnectTimeout !== null) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
}, false);
this.eventSource.addEventListener('error', () => {
this.pushDisconnect();
if (this.reconnectTimeout !== null) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
this.$store.commit('setPushConnectionActive', false);
this.pushAutomaticReconnect();
}, false);
},
/**
* Close connection to server
*/
pushDisconnect (): void {
if (this.eventSource !== null) {
this.eventSource.close();
this.eventSource = null;
this.$store.commit('setPushConnectionActive', false);
}
},
/**
* Process a newly received message
*
* @param {Event} event The event data with the message data
* @returns {void}
*/
pushMessageReceived (event: Event): void {
let receivedData: IPushData;
try {
// @ts-ignore
receivedData = JSON.parse(event.data);
} catch (error) {
console.error('The received push data is not valid JSON.');
return;
}
if (['executionFinished', 'nodeExecuteAfter', 'nodeExecuteBefore'].includes(receivedData.type)) {
if (this.$store.getters.isActionActive('workflowRunning') === false) {
// No workflow is running so ignore the messages
return;
}
// Deactivated for now because sometimes the push messages arrive
// before the execution id gets received
// const pushData = receivedData.data as IPushDataNodeExecuteBefore;
// if (this.$store.getters.activeExecutionId !== pushData.executionId) {
// // The data is not for the currently active execution so ignore it.
// // Should normally not happen but who knows...
// return;
// }
}
if (receivedData.type === 'executionFinished') {
// The workflow finished executing
const pushData = receivedData.data as IPushDataExecutionFinished;
const runDataExecuted = pushData.data;
if (runDataExecuted.finished !== true) {
// There was a problem with executing the workflow
this.$showMessage({
title: 'Problem executing workflow',
message: 'There was a problem executing the workflow!',
type: 'error',
});
} else {
// Workflow did execute without a problem
this.$showMessage({
title: 'Workflow got executed',
message: 'Workflow did get executed successfully!',
type: 'success',
});
}
// It does not push the runData as it got already pushed with each
// node that did finish. For that reason copy in here the data
// which we already have.
runDataExecuted.data.resultData.runData = this.$store.getters.getWorkflowRunData;
this.$store.commit('setExecutingNode', null);
this.$store.commit('setWorkflowExecutionData', runDataExecuted);
this.$store.commit('removeActiveAction', 'workflowRunning');
// Set the node execution issues on all the nodes which produced an error so that
// it can be displayed in the node-view
this.updateNodesExecutionIssues();
} else if (receivedData.type === 'nodeExecuteAfter') {
// A node finished to execute. Add its data
const pushData = receivedData.data as IPushDataNodeExecuteAfter;
this.$store.commit('addNodeExecutionData', pushData);
} else if (receivedData.type === 'nodeExecuteBefore') {
// A node started to be executed. Set it as executing.
const pushData = receivedData.data as IPushDataNodeExecuteBefore;
this.$store.commit('setExecutingNode', pushData.nodeName);
} else if (receivedData.type === 'testWebhookDeleted') {
// A test-webhook got deleted
const pushData = receivedData.data as IPushDataTestWebhook;
if (pushData.workflowId === this.$store.getters.workflowId) {
this.$store.commit('setExecutionWaitingForWebhook', false);
this.$store.commit('removeActiveAction', 'workflowRunning');
}
} else if (receivedData.type === 'testWebhookReceived') {
// A test-webhook did get called
const pushData = receivedData.data as IPushDataTestWebhook;
if (pushData.workflowId === this.$store.getters.workflowId) {
this.$store.commit('setExecutionWaitingForWebhook', false);
}
}
},
},
});

View File

@@ -0,0 +1,292 @@
import Vue from 'vue';
import { parse } from 'flatted';
import axios, { AxiosRequestConfig } from 'axios';
import {
IActivationError,
ICredentialsDecryptedResponse,
ICredentialsResponse,
IExecutionsCurrentSummaryExtended,
IExecutionDeleteFilter,
IExecutionPushResponse,
IExecutionResponse,
IExecutionFlattedResponse,
IExecutionsListResponse,
IExecutionsStopData,
IN8nUISettings,
IStartRunData,
IWorkflowDb,
IWorkflowShortResponse,
IRestApi,
IWorkflowData,
IWorkflowDataUpdate,
} from '@/Interface';
import {
ICredentialsDecrypted,
ICredentialType,
IDataObject,
INodeCredentials,
INodePropertyOptions,
INodeTypeDescription,
} from 'n8n-workflow';
/**
* Unflattens the Execution data.
*
* @export
* @param {IExecutionFlattedResponse} fullExecutionData The data to unflatten
* @returns {IExecutionResponse}
*/
function unflattenExecutionData (fullExecutionData: IExecutionFlattedResponse): IExecutionResponse {
// Unflatten the data
const returnData: IExecutionResponse = {
...fullExecutionData,
workflowData: fullExecutionData.workflowData as IWorkflowDb,
data: parse(fullExecutionData.data),
};
returnData.finished = returnData.finished ? returnData.finished : false;
if (fullExecutionData.id) {
returnData.id = fullExecutionData.id;
}
return returnData;
}
export class ReponseError extends Error {
// The HTTP status code of response
httpStatusCode?: number;
// The error code in the resonse
errorCode?: number;
// The stack trace of the server
serverStackTrace?: string;
/**
* Creates an instance of ReponseError.
* @param {string} message The error message
* @param {number} [errorCode] The error code which can be used by frontend to identify the actual error
* @param {number} [httpStatusCode] The HTTP status code the response should have
* @param {string} [stack] The stack trace
* @memberof ReponseError
*/
constructor (message: string, errorCode?: number, httpStatusCode?: number, stack?: string) {
super(message);
this.name = 'ReponseError';
if (errorCode) {
this.errorCode = errorCode;
}
if (httpStatusCode) {
this.httpStatusCode = httpStatusCode;
}
if (stack) {
this.serverStackTrace = stack;
}
}
}
export const restApi = Vue.extend({
methods: {
restApi (): IRestApi {
const self = this;
return {
async makeRestApiRequest (method: string, endpoint: string, data?: IDataObject): Promise<any> { // tslint:disable-line:no-any
try {
const options: AxiosRequestConfig = {
method,
url: endpoint,
baseURL: self.$store.getters.getRestUrl,
headers: {
sessionid: self.$store.getters.sessionId,
},
};
if (['PATCH', 'POST', 'PUT'].includes(method)) {
options.data = data;
} else {
options.params = data;
}
const response = await axios.request(options);
return response.data.data;
} catch (error) {
if (error.message === 'Network Error') {
throw new ReponseError('API-Server can not be reached. It is probably down.');
}
const errorResponseData = error.response.data;
if (errorResponseData !== undefined && errorResponseData.message !== undefined) {
throw new ReponseError(errorResponseData.message, errorResponseData.code, error.response.status, errorResponseData.stack);
}
throw error;
}
},
getActiveWorkflows: (): Promise<string[]> => {
return self.restApi().makeRestApiRequest('GET', `/active`);
},
getActivationError: (id: string): Promise<IActivationError | undefined> => {
return self.restApi().makeRestApiRequest('GET', `/active/error/${id}`);
},
getCurrentExecutions: (filter: object): Promise<IExecutionsCurrentSummaryExtended[]> => {
let sendData = {};
if (filter) {
sendData = {
filter,
};
}
return self.restApi().makeRestApiRequest('GET', `/executions-current`, sendData);
},
stopCurrentExecution: (executionId: string): Promise<IExecutionsStopData> => {
return self.restApi().makeRestApiRequest('POST', `/executions-current/${executionId}/stop`);
},
getSettings: (): Promise<IN8nUISettings> => {
return self.restApi().makeRestApiRequest('GET', `/settings`);
},
// Returns all node-types
getNodeTypes: (): Promise<INodeTypeDescription[]> => {
return self.restApi().makeRestApiRequest('GET', `/node-types`);
},
// Returns all the parameter options from the server
getNodeParameterOptions: (nodeType: string, methodName: string, credentials?: INodeCredentials): Promise<INodePropertyOptions[]> => {
const sendData = {
nodeType,
methodName,
credentials,
};
return self.restApi().makeRestApiRequest('GET', '/node-parameter-options', sendData);
},
// Removes a test webhook
removeTestWebhook: (workflowId: string): Promise<boolean> => {
return self.restApi().makeRestApiRequest('DELETE', `/test-webhook/${workflowId}`);
},
// Execute a workflow
runWorkflow: async (startRunData: IStartRunData): Promise<IExecutionPushResponse> => {
return self.restApi().makeRestApiRequest('POST', `/workflows/run`, startRunData);
},
// Creates new credentials
createNewWorkflow: (sendData: IWorkflowData): Promise<IWorkflowDb> => {
return self.restApi().makeRestApiRequest('POST', `/workflows`, sendData);
},
// Updates an existing workflow
updateWorkflow: (id: string, data: IWorkflowDataUpdate): Promise<IWorkflowDb> => {
return self.restApi().makeRestApiRequest('PATCH', `/workflows/${id}`, data);
},
// Deletes a workflow
deleteWorkflow: (name: string): Promise<void> => {
return self.restApi().makeRestApiRequest('DELETE', `/workflows/${name}`);
},
// Returns the workflow with the given name
getWorkflow: (id: string): Promise<IWorkflowDb> => {
return self.restApi().makeRestApiRequest('GET', `/workflows/${id}`);
},
// Returns all saved workflows
getWorkflows: (filter?: object): Promise<IWorkflowShortResponse[]> => {
let sendData;
if (filter) {
sendData = {
filter,
};
}
return self.restApi().makeRestApiRequest('GET', `/workflows`, sendData);
},
// Returns a workflow from a given URL
getWorkflowFromUrl: (url: string): Promise<IWorkflowDb> => {
return self.restApi().makeRestApiRequest('GET', `/workflows/from-url`, { url });
},
// Creates a new workflow
createNewCredentials: (sendData: ICredentialsDecrypted): Promise<ICredentialsResponse> => {
return self.restApi().makeRestApiRequest('POST', `/credentials`, sendData);
},
// Deletes a credentials
deleteCredentials: (id: string): Promise<void> => {
return self.restApi().makeRestApiRequest('DELETE', `/credentials/${id}`);
},
// Updates existing credentials
updateCredentials: (id: string, data: ICredentialsDecrypted): Promise<ICredentialsResponse> => {
return self.restApi().makeRestApiRequest('PATCH', `/credentials/${id}`, data);
},
// Returns the credentials with the given id
getCredentials: (id: string, includeData?: boolean): Promise<ICredentialsDecryptedResponse | ICredentialsResponse | undefined> => {
let sendData;
if (includeData) {
sendData = {
includeData,
};
}
return self.restApi().makeRestApiRequest('GET', `/credentials/${id}`, sendData);
},
// Returns all saved credentials
getAllCredentials: (filter?: object): Promise<ICredentialsResponse[]> => {
let sendData;
if (filter) {
sendData = {
filter,
};
}
return self.restApi().makeRestApiRequest('GET', `/credentials`, sendData);
},
// Returns all credential types
getCredentialTypes: (): Promise<ICredentialType[]> => {
return self.restApi().makeRestApiRequest('GET', `/credential-types`);
},
// Returns the execution with the given name
getExecution: async (id: string): Promise<IExecutionResponse> => {
const response = await self.restApi().makeRestApiRequest('GET', `/executions/${id}`);
return unflattenExecutionData(response);
},
// Deletes executions
deleteExecutions: (sendData: IExecutionDeleteFilter): Promise<void> => {
return self.restApi().makeRestApiRequest('POST', `/executions/delete`, sendData);
},
// Returns the execution with the given name
retryExecution: (id: string): Promise<IExecutionResponse> => {
return self.restApi().makeRestApiRequest('POST', `/executions/${id}/retry`);
},
// Returns all saved executions
// TODO: For sure needs some kind of default filter like last day, with max 10 results, ...
getPastExecutions: (filter: object, limit: number, lastStartedAt?: number): Promise<IExecutionsListResponse> => {
let sendData = {};
if (filter) {
sendData = {
filter,
lastStartedAt,
limit,
};
}
return self.restApi().makeRestApiRequest('GET', `/executions`, sendData);
},
// Returns all the available timezones
getTimezones: (): Promise<IDataObject> => {
return self.restApi().makeRestApiRequest('GET', `/options/timezones`);
},
};
},
},
});

View File

@@ -0,0 +1,26 @@
import Vue from 'vue';
import { Notification } from 'element-ui';
import { ElNotificationOptions } from 'element-ui/types/notification';
// export const showMessage = {
export const showMessage = Vue.extend({
methods: {
$showMessage (messageData: ElNotificationOptions) {
messageData.dangerouslyUseHTMLString = true;
if (messageData.position === undefined) {
messageData.position = 'bottom-right';
}
return Notification(messageData);
},
$showError (error: Error, title: string, message: string) {
this.$showMessage({
title,
message: `${message}<br /><i>${error.message}</i>`,
type: 'error',
duration: 0,
});
},
},
});

View File

@@ -0,0 +1,432 @@
import { PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants';
import {
INode,
INodeExecutionData,
INodeIssues,
INodeType,
INodeTypeDescription,
IRunData,
IRunExecutionData,
IWorfklowIssues,
INodeCredentials,
Workflow,
NodeHelpers,
} from 'n8n-workflow';
import {
IExecutionResponse,
INodeTypesMaxCount,
INodeUi,
IWorkflowData,
} from '../../Interface';
import { restApi } from '@/components/mixins/restApi';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import mixins from 'vue-typed-mixins';
export const workflowHelpers = mixins(
nodeHelpers,
restApi,
showMessage,
)
.extend({
methods: {
// Returns connectionInputData to be able to execute an expression.
connectionInputData (parentNode: string[], inputName: string, runIndex: number, inputIndex: number): INodeExecutionData[] | null {
let connectionInputData = null;
if (parentNode.length) {
// Add the input data to be able to also resolve the short expression format
// which does not use the node name
const parentNodeName = parentNode[0];
const workflowRunData = this.$store.getters.getWorkflowRunData as IRunData | null;
if (workflowRunData === null) {
return null;
}
if (!workflowRunData[parentNodeName] ||
workflowRunData[parentNodeName].length <= runIndex ||
!workflowRunData[parentNodeName][runIndex].hasOwnProperty('data') ||
workflowRunData[parentNodeName][runIndex].data === undefined ||
!workflowRunData[parentNodeName][runIndex].data!.hasOwnProperty(inputName) ||
workflowRunData[parentNodeName][runIndex].data![inputName].length <= inputIndex
) {
connectionInputData = [];
} else {
connectionInputData = workflowRunData[parentNodeName][runIndex].data![inputName][inputIndex];
}
}
return connectionInputData;
},
// Returns a shallow copy of the nodes which means that all the data on the lower
// levels still only gets referenced but the top level object is a different one.
// This has the advantage that it is very fast and does not cause problems with vuex
// when the workflow replaces the node-parameters.
getNodes (): INodeUi[] {
const nodes = this.$store.getters.allNodes;
const returnNodes: INodeUi[] = [];
for (const node of nodes) {
returnNodes.push(Object.assign({}, node));
}
return returnNodes;
},
// Returns data about nodeTypes which ahve a "maxNodes" limit set.
// For each such type does it return how high the limit is, how many
// already exist and the name of this nodes.
getNodeTypesMaxCount (): INodeTypesMaxCount {
const nodes = this.$store.getters.allNodes;
const returnData: INodeTypesMaxCount = {};
const nodeTypes = this.$store.getters.allNodeTypes;
for (const nodeType of nodeTypes) {
if (nodeType.maxNodes !== undefined) {
returnData[nodeType.name] = {
exist: 0,
max: nodeType.maxNodes,
nodeNames: [],
};
}
}
for (const node of nodes) {
if (returnData[node.type] !== undefined) {
returnData[node.type].exist += 1;
returnData[node.type].nodeNames.push(node.name);
}
}
return returnData;
},
// Returns how many nodes of the given type currently exist
getNodeTypeCount (nodeType: string): number {
const nodes = this.$store.getters.allNodes;
let count = 0;
for (const node of nodes) {
if (node.type === nodeType) {
count++;
}
}
return count;
},
// Checks if everything in the workflow is complete and ready to be executed
checkReadyForExecution (workflow: Workflow) {
let node: INode;
let nodeType: INodeType | undefined;
let nodeIssues: INodeIssues | null = null;
const workflowIssues: IWorfklowIssues = {};
for (const nodeName of Object.keys(workflow.nodes)) {
nodeIssues = null;
node = workflow.nodes[nodeName];
if (node.disabled === true) {
continue;
}
nodeType = workflow.nodeTypes.getByName(node.type);
if (nodeType === undefined) {
// Node type is not known
nodeIssues = {
typeUnknown: true,
};
} else {
nodeIssues = this.getNodeIssues(nodeType.description, node, ['execution']);
}
if (nodeIssues !== null) {
workflowIssues[node.name] = nodeIssues;
}
}
if (Object.keys(workflowIssues).length === 0) {
return null;
}
return workflowIssues;
},
// Returns a workflow instance.
getWorkflow (copyData?: boolean): Workflow {
const nodes = this.getNodes();
const connections = this.$store.getters.allConnections;
const nodeTypes = {
init: async () => { },
getAll: () => {
// Does not get used in Workflow so no need to return it
return [];
},
getByName: (nodeType: string) => {
const nodeTypeDescription = this.$store.getters.nodeType(nodeType);
if (nodeTypeDescription === null) {
return undefined;
}
return {
description: nodeTypeDescription,
};
},
};
let workflowId = this.$store.getters.workflowId;
if (workflowId !== PLACEHOLDER_EMPTY_WORKFLOW_ID) {
workflowId = undefined;
}
if (copyData === true) {
return new Workflow(workflowId, JSON.parse(JSON.stringify(nodes)), JSON.parse(JSON.stringify(connections)), false, nodeTypes);
} else {
return new Workflow(workflowId, nodes, connections, false, nodeTypes);
}
},
// Returns the currently loaded workflow as JSON.
getWorkflowDataToSave (): Promise<IWorkflowData> {
const workflowNodes = this.$store.getters.allNodes;
const workflowConnections = this.$store.getters.allConnections;
let nodeData;
const nodes = [];
for (let nodeIndex = 0; nodeIndex < workflowNodes.length; nodeIndex++) {
try {
// @ts-ignore
nodeData = this.getNodeDataToSave(workflowNodes[nodeIndex]);
} catch (e) {
return Promise.reject(e);
}
nodes.push(nodeData);
}
const data: IWorkflowData = {
name: this.$store.getters.workflowName,
nodes,
connections: workflowConnections,
active: this.$store.getters.isActive,
settings: this.$store.getters.workflowSettings,
};
const workflowId = this.$store.getters.workflowId;
if (workflowId !== PLACEHOLDER_EMPTY_WORKFLOW_ID) {
data.id = workflowId;
}
return Promise.resolve(data);
},
// Returns all node-types
getNodeDataToSave (node: INodeUi): INodeUi {
const skipKeys = [
'color',
'continueOnFail',
'credentials',
'disabled',
'issues',
'notes',
'parameters',
'status',
];
// @ts-ignore
const nodeData: INodeUi = {
parameters: {},
};
for (const key in node) {
if (key.charAt(0) !== '_' && skipKeys.indexOf(key) === -1) {
// @ts-ignore
nodeData[key] = node[key];
}
}
// 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) as INodeTypeDescription;
if (nodeType !== null) {
// Node-Type is known so we can save the parameters correctly
const nodeParameters = NodeHelpers.getNodeParameters(nodeType.properties, node.parameters, false, false);
nodeData.parameters = nodeParameters !== null ? nodeParameters : {};
// Add the node credentials if there are some set and if they should be displayed
if (node.credentials !== undefined && nodeType.credentials !== undefined) {
const saveCredenetials: INodeCredentials = {};
for (const nodeCredentialTypeName of Object.keys(node.credentials)) {
const credentialTypeDescription = nodeType.credentials
.find((credentialTypeDescription) => credentialTypeDescription.name === nodeCredentialTypeName);
if (credentialTypeDescription === undefined) {
// Credential type is not know so do not save
continue;
}
if (this.displayParameter(node.parameters, credentialTypeDescription, '') === false) {
// Credential should not be displayed so do also not save
continue;
}
saveCredenetials[nodeCredentialTypeName] = node.credentials[nodeCredentialTypeName];
}
// Set credential property only if it has content
if (Object.keys(saveCredenetials).length !== 0) {
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;
nodeData.parameters = node.parameters;
if (nodeData.color !== undefined) {
nodeData.color = node.color;
}
}
// Save the disabled property and continueOnFail only when is set
if (node.disabled === true) {
nodeData.disabled = true;
}
if (node.continueOnFail === true) {
nodeData.continueOnFail = true;
}
// Save the notes only if when they contain data
if (![undefined, ''].includes(node.notes)) {
nodeData.notes = node.notes;
}
return nodeData;
},
// Executes the given expression and returns its value
resolveExpression (expression: string) {
const inputIndex = 0;
const itemIndex = 0;
const runIndex = 0;
const inputName = 'main';
const activeNode = this.$store.getters.activeNode;
const workflow = this.getWorkflow();
const parentNode = workflow.getParentNodes(activeNode.name, inputName, 1);
const executionData = this.$store.getters.getWorkflowExecution as IExecutionResponse | null;
let connectionInputData = this.connectionInputData(parentNode, inputName, runIndex, inputIndex);
let runExecutionData: IRunExecutionData;
if (executionData === null) {
runExecutionData = {
resultData: {
runData: {},
},
};
} else {
runExecutionData = executionData.data;
}
if (connectionInputData === null) {
connectionInputData = [];
}
return workflow.getParameterValue(expression, runExecutionData, runIndex, itemIndex, activeNode.name, connectionInputData, true);
},
// Saves the currently loaded workflow to the database.
async saveCurrentWorkflow (withNewName = false) {
const currentWorkflow = this.$route.params.name;
let workflowName: string | null | undefined = '';
if (currentWorkflow === undefined || withNewName === true) {
// Currently no workflow name is set to get it from user
workflowName = await this.$prompt(
'Enter workflow name',
'Name',
{
confirmButtonText: 'Save',
cancelButtonText: 'Cancel',
}
)
.then((data) => {
// @ts-ignore
return data.value;
})
.catch(() => {
// User did cancel
return undefined;
});
if (workflowName === undefined) {
// User did cancel
return;
} else if (['', null].includes(workflowName)) {
// User did not enter a name
this.$showMessage({
title: 'Name missing',
message: `No name for the workflow got entered and could so not be saved!`,
type: 'error',
});
return;
}
}
try {
this.$store.commit('addActiveAction', 'workflowSaving');
let workflowData: IWorkflowData = await this.getWorkflowDataToSave();
if (currentWorkflow === undefined || withNewName === true) {
// Workflow is new or is supposed to get saved under a new name
// so create a new etnry in database
workflowData.name = workflowName as string;
workflowData = await this.restApi().createNewWorkflow(workflowData);
this.$store.commit('setWorkflowName', workflowData.name);
this.$store.commit('setWorkflowId', workflowData.id);
} else {
// Workflow exists already so update it
await this.restApi().updateWorkflow(currentWorkflow, workflowData);
}
this.$router.push({
name: 'NodeViewExisting',
params: { name: workflowData.id as string, action: 'workflowSave' },
});
this.$store.commit('removeActiveAction', 'workflowSaving');
this.$showMessage({
title: 'Workflow saved',
message: `The workflow "${workflowData.name}" got saved!`,
type: 'success',
});
} catch (e) {
this.$store.commit('removeActiveAction', 'workflowSaving');
this.$showMessage({
title: 'Problem saving workflow',
message: `There was a problem saving the workflow: "${e.message}"`,
type: 'error',
});
}
},
},
});

View File

@@ -0,0 +1,175 @@
import {
IExecutionPushResponse,
IExecutionResponse,
IStartRunData,
} from '@/Interface';
import {
IRunData,
IRunExecutionData,
NodeHelpers,
} from 'n8n-workflow';
import { restApi } from '@/components/mixins/restApi';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
export const workflowRun = mixins(
restApi,
workflowHelpers,
).extend({
methods: {
// Starts to executes a workflow on server.
async runWorkflowApi (runData: IStartRunData): Promise<IExecutionPushResponse> {
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.');
}
this.$store.commit('addActiveAction', 'workflowRunning');
let response: IExecutionPushResponse;
try {
response = await this.restApi().runWorkflow(runData);
} catch (error) {
this.$store.commit('removeActiveAction', 'workflowRunning');
throw error;
}
if (response.executionId !== undefined) {
this.$store.commit('setActiveExecutionId', response.executionId);
}
if (response.waitingForWebhook === true) {
this.$store.commit('setExecutionWaitingForWebhook', true);
}
return response;
},
async runWorkflow (nodeName: string): Promise<IExecutionPushResponse | undefined> {
if (this.$store.getters.isActionActive('workflowRunning') === true) {
return;
}
const workflow = this.getWorkflow();
try {
// Check first if the workflow has any issues before execute it
const issuesExist = this.$store.getters.nodesIssuesExist;
if (issuesExist === true) {
// If issues exist get all of the issues of all nodes
const workflowIssues = this.checkReadyForExecution(workflow);
if (workflowIssues !== null) {
const errorMessages = [];
let nodeIssues: string[];
for (const nodeName of Object.keys(workflowIssues)) {
nodeIssues = NodeHelpers.nodeIssuesToString(workflowIssues[nodeName]);
for (const nodeIssue of nodeIssues) {
errorMessages.push(`${nodeName}: ${nodeIssue}`);
}
}
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;- '),
type: 'error',
duration: 0,
});
return;
}
}
// Get the direct parents of the node
const directParentNodes = workflow.getParentNodes(nodeName, 'main', 1);
const runData = this.$store.getters.getWorkflowRunData;
let newRunData: IRunData | undefined;
const startNodes: string[] = [];
if (runData !== null && Object.keys(runData).length !== 0) {
newRunData = {};
// Go over the direct parents of the node
for (const directParentNode of directParentNodes) {
// Go over the parents of that node so that we can get a start
// node for each of the branches
const parentNodes = workflow.getParentNodes(directParentNode, 'main');
// Add also the direct parent to be checked
parentNodes.push(directParentNode);
for (const parentNode of parentNodes) {
if (runData[parentNode] === undefined || runData[parentNode].length === 0) {
// When we hit a node which has no data we stop and set it
// as a start node the execution from and then go on with other
// direct input nodes
startNodes.push(parentNode);
break;
}
newRunData[parentNode] = runData[parentNode].slice(0, 1);
}
}
if (Object.keys(newRunData).length === 0) {
// If there is no data for any of the parent nodes make sure
// that run data is empty that it runs regularly
newRunData = undefined;
}
}
if (startNodes.length === 0) {
startNodes.push(nodeName);
}
const workflowData = await this.getWorkflowDataToSave();
const startRunData: IStartRunData = {
workflowData,
runData: newRunData,
startNodes,
};
if (nodeName) {
startRunData.destinationNode = nodeName;
}
// Init the execution data to represent the start of the execution
// that data which gets reused is already set and data of newly executed
// nodes can be added as it gets pushed in
const executionData: IExecutionResponse = {
id: '__IN_PROGRESS__',
finished: false,
mode: 'manual',
startedAt: new Date().getTime(),
stoppedAt: 0,
workflowId: workflow.id,
data: {
resultData: {
runData: newRunData || {},
startNodes,
workflowData,
},
} as IRunExecutionData,
workflowData: {
id: this.$store.getters.workflowId,
name: workflowData.name!,
active: workflowData.active!,
createdAt: 0,
updatedAt: 0,
...workflowData,
},
};
this.$store.commit('setWorkflowExecutionData', executionData);
return await this.runWorkflowApi(startRunData);
} catch (error) {
this.$showError(error, 'Problem running workflow', 'There was a problem running the workflow:');
return undefined;
}
},
},
});