Initial commit to release
This commit is contained in:
135
packages/editor-ui/src/components/BinaryDataDisplay.vue
Normal file
135
packages/editor-ui/src/components/BinaryDataDisplay.vue
Normal 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>
|
||||
193
packages/editor-ui/src/components/CollectionParameter.vue
Normal file
193
packages/editor-ui/src/components/CollectionParameter.vue
Normal 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>
|
||||
217
packages/editor-ui/src/components/CredentialsEdit.vue
Normal file
217
packages/editor-ui/src/components/CredentialsEdit.vue
Normal 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>
|
||||
270
packages/editor-ui/src/components/CredentialsInput.vue
Normal file
270
packages/editor-ui/src/components/CredentialsInput.vue
Normal 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>
|
||||
184
packages/editor-ui/src/components/CredentialsList.vue
Normal file
184
packages/editor-ui/src/components/CredentialsList.vue
Normal 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>
|
||||
103
packages/editor-ui/src/components/DataDisplay.vue
Normal file
103
packages/editor-ui/src/components/DataDisplay.vue
Normal 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>
|
||||
121
packages/editor-ui/src/components/DisplayWithChange.vue
Normal file
121
packages/editor-ui/src/components/DisplayWithChange.vue
Normal 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>
|
||||
624
packages/editor-ui/src/components/ExecutionsList.vue
Normal file
624
packages/editor-ui/src/components/ExecutionsList.vue
Normal 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">
|
||||
</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>
|
||||
147
packages/editor-ui/src/components/ExpressionEdit.vue
Normal file
147
packages/editor-ui/src/components/ExpressionEdit.vue
Normal 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>
|
||||
360
packages/editor-ui/src/components/ExpressionInput.vue
Normal file
360
packages/editor-ui/src/components/ExpressionInput.vue
Normal 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>
|
||||
235
packages/editor-ui/src/components/FixedCollectionParameter.vue
Normal file
235
packages/editor-ui/src/components/FixedCollectionParameter.vue
Normal 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>
|
||||
314
packages/editor-ui/src/components/MainHeader.vue
Normal file
314
packages/editor-ui/src/components/MainHeader.vue
Normal 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>
|
||||
<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" />
|
||||
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>
|
||||
459
packages/editor-ui/src/components/MainSidebar.vue
Normal file
459
packages/editor-ui/src/components/MainSidebar.vue
Normal 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"/>
|
||||
<span slot="title" class="item-title-root">Workflows</span>
|
||||
</template>
|
||||
|
||||
<el-menu-item index="workflow-new">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="file"/>
|
||||
<span slot="title" class="item-title">New</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="workflow-open">
|
||||
<template slot="title">
|
||||
<font-awesome-icon icon="folder-open"/>
|
||||
<span slot="title" class="item-title">Open</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="workflow-save" :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"/>
|
||||
<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"/>
|
||||
<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>
|
||||
171
packages/editor-ui/src/components/MultipleParameter.vue
Normal file
171
packages/editor-ui/src/components/MultipleParameter.vue
Normal 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>
|
||||
406
packages/editor-ui/src/components/Node.vue
Normal file
406
packages/editor-ui/src/components/Node.vue
Normal 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 /> - ' + nodeIssues.join('<br /> - ');
|
||||
},
|
||||
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>
|
||||
79
packages/editor-ui/src/components/NodeCreateItem.vue
Normal file
79
packages/editor-ui/src/components/NodeCreateItem.vue
Normal 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>
|
||||
160
packages/editor-ui/src/components/NodeCreateList.vue
Normal file
160
packages/editor-ui/src/components/NodeCreateList.vue
Normal 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>
|
||||
104
packages/editor-ui/src/components/NodeCreator.vue
Normal file
104
packages/editor-ui/src/components/NodeCreator.vue
Normal 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>
|
||||
316
packages/editor-ui/src/components/NodeCredentials.vue
Normal file
316
packages/editor-ui/src/components/NodeCredentials.vue
Normal 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 /> - ' + getIssues(credentialTypeDescription.name).join('<br /> - ')"></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>
|
||||
80
packages/editor-ui/src/components/NodeIcon.vue
Normal file
80
packages/editor-ui/src/components/NodeIcon.vue
Normal 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>
|
||||
540
packages/editor-ui/src/components/NodeSettings.vue
Normal file
540
packages/editor-ui/src/components/NodeSettings.vue
Normal 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>
|
||||
222
packages/editor-ui/src/components/NodeWebhooks.vue
Normal file
222
packages/editor-ui/src/components/NodeWebhooks.vue
Normal 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>
|
||||
39
packages/editor-ui/src/components/PageContentWrapper.vue
Normal file
39
packages/editor-ui/src/components/PageContentWrapper.vue
Normal 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>
|
||||
633
packages/editor-ui/src/components/ParameterInput.vue
Normal file
633
packages/editor-ui/src/components/ParameterInput.vue
Normal 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 /> - ' + getIssues.join('<br /> - ')"></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>
|
||||
96
packages/editor-ui/src/components/ParameterInputFull.vue
Normal file
96
packages/editor-ui/src/components/ParameterInputFull.vue
Normal 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>
|
||||
228
packages/editor-ui/src/components/ParameterInputList.vue
Normal file
228
packages/editor-ui/src/components/ParameterInputList.vue
Normal 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>
|
||||
628
packages/editor-ui/src/components/RunData.vue
Normal file
628
packages/editor-ui/src/components/RunData.vue
Normal 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>
|
||||
<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) ? ' ' : 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>
|
||||
66
packages/editor-ui/src/components/TextEdit.vue
Normal file
66
packages/editor-ui/src/components/TextEdit.vue
Normal 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>
|
||||
590
packages/editor-ui/src/components/VariableSelector.vue
Normal file
590
packages/editor-ui/src/components/VariableSelector.vue
Normal 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>
|
||||
153
packages/editor-ui/src/components/VariableSelectorItem.vue
Normal file
153
packages/editor-ui/src/components/VariableSelectorItem.vue
Normal 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>
|
||||
179
packages/editor-ui/src/components/WorkflowActivator.vue
Normal file
179
packages/editor-ui/src/components/WorkflowActivator.vue
Normal 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>
|
||||
140
packages/editor-ui/src/components/WorkflowOpen.vue
Normal file
140
packages/editor-ui/src/components/WorkflowOpen.vue
Normal 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>
|
||||
283
packages/editor-ui/src/components/WorkflowSettings.vue
Normal file
283
packages/editor-ui/src/components/WorkflowSettings.vue
Normal 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>
|
||||
201
packages/editor-ui/src/components/mixins/copyPaste.ts
Normal file
201
packages/editor-ui/src/components/mixins/copyPaste.ts
Normal 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
|
||||
}
|
||||
},
|
||||
|
||||
},
|
||||
});
|
||||
77
packages/editor-ui/src/components/mixins/genericHelpers.ts
Normal file
77
packages/editor-ui/src/components/mixins/genericHelpers.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
|
||||
},
|
||||
});
|
||||
162
packages/editor-ui/src/components/mixins/mouseSelect.ts
Normal file
162
packages/editor-ui/src/components/mixins/mouseSelect.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
72
packages/editor-ui/src/components/mixins/moveNodeWorkflow.ts
Normal file
72
packages/editor-ui/src/components/mixins/moveNodeWorkflow.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
293
packages/editor-ui/src/components/mixins/nodeBase.ts
Normal file
293
packages/editor-ui/src/components/mixins/nodeBase.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
251
packages/editor-ui/src/components/mixins/nodeHelpers.ts
Normal file
251
packages/editor-ui/src/components/mixins/nodeHelpers.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
18
packages/editor-ui/src/components/mixins/nodeIndex.ts
Normal file
18
packages/editor-ui/src/components/mixins/nodeIndex.ts
Normal 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();
|
||||
},
|
||||
},
|
||||
});
|
||||
178
packages/editor-ui/src/components/mixins/pushConnection.ts
Normal file
178
packages/editor-ui/src/components/mixins/pushConnection.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
292
packages/editor-ui/src/components/mixins/restApi.ts
Normal file
292
packages/editor-ui/src/components/mixins/restApi.ts
Normal 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`);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
26
packages/editor-ui/src/components/mixins/showMessage.ts
Normal file
26
packages/editor-ui/src/components/mixins/showMessage.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
432
packages/editor-ui/src/components/mixins/workflowHelpers.ts
Normal file
432
packages/editor-ui/src/components/mixins/workflowHelpers.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
175
packages/editor-ui/src/components/mixins/workflowRun.ts
Normal file
175
packages/editor-ui/src/components/mixins/workflowRun.ts
Normal 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 /> - ' + errorMessages.join('<br /> - '),
|
||||
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;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user