Add tagging of workflows (#1647)

* clean up dropdown

* clean up focusoncreate

*  Ignore mistaken ID in POST /workflows

*  Fix undefined tag ID in PATCH /workflows

*  Shorten response for POST /tags

* remove scss mixins

* clean up imports

*  Implement validation with class-validator

* address ivan's comments

* implement modals

* Fix lint issues

* fix disabling shortcuts

* fix focus issues

* fix focus issues

* fix focus issues with modal

* fix linting issues

* use dispatch

* use constants for modal keys

* fix focus

* fix lint issues

* remove unused prop

* add modal root

* fix lint issues

* remove unused methods

* fix shortcut

* remove max width

*  Fix duplicate entry error for pg and MySQL

* update rename messaging

* update order of buttons

* fix firefox overflow on windows

* fix dropdown height

* 🔨 refactor tag crud controllers

* 🧹 remove unused imports

* use variable for number of items

* fix dropdown spacing

*  Restore type to fix build

*  Fix post-refactor PATCH /workflows/:id

*  Fix PATCH /workflows/:id for zero tags

*  Fix usage count becoming stringified

* address max's comments

* fix filter spacing

* fix blur bug

* address most of ivan's comments

* address tags type concern

* remove defaults

*  return tag id as string

* 🔨 add hooks to tag CUD operations

* 🏎 simplify timestamp pruning

* remove blur event

* fix onblur bug

*  Fix fs import to fix build

* address max's comments

* implement responsive tag container

* fix lint issues

* Set default dates in entities

* 👕 Fix lint in migrations

* update tag limits

* address ivan's comments

* remove rename, refactor header, implement new designs for save, remove responsive tag container

* update styling

* update styling

* implement responsive tag container

* implement header tags edit

* implement header tags edit

* fix lint issues

* implement expandable input

* minor fixes

* minor fixes

* use variable

* rename save as

* duplicate fixes

*  Implement unique workflow names

*  Create /workflows/new endpoint

* minor edit fixes

* lint fixes

* style fixes

* hook up saving name

* hook up tags

* clean up impl

* fix dirty state bug

* update limit

* update notification messages

* on click outside

* fix minor bug with count

* lint fixes

*  Add query string params to /workflows/new

* handle minor edge cases

* handle minor edge cases

* handle minor bugs; fix firefox dropdown issue

* Fix min width

* apply tags only after api success

* remove count fix

* 🚧 Adjust to new qs requirements

* clean up workflow tags impl, fix tags delete bug

* fix minor issue

* fix minor spacing issue

* disable wrap for ops

* fix viewport root; save on click in dropdown

* save button loading when saving name/tags

* implement max width on tags container

* implement cleaner create experience

* disable edit while updating

* codacy hex color

* refactor tags container

* fix clickability

* fix workflow open and count

* clean up structure

* fix up lint issues

*  Create migrations for unique workflow names

* fix button size

* increase workflow name limit for larger screen

* tslint fixes

* disable responsiveness for workflow modal

* rename event

* change min width for tags

* clean up pr

*  Adjust quotes in MySQL migration

*  Adjust quotes in Postgres migration

* address max's comments on styles

* remove success toasts

* add hover mode to name

* minor fixes

* refactor name preview

* fix name input not to jiggle

* finish up name input

* Fix up add tags

* clean up param

* clean up scss

* fix resizing name

* fix resizing name

* fix resize bug

* clean up edit spacing

* ignore on esc

* fix input bug

* focus input on clear

* build

* fix up add tags clickablity

* remove scrollbars

* move into folders

* clean up multiple patch req

* remove padding top from edit

* update tags on enter

* build

* rollout blur on enter behavior

* rollout esc behavior

* fix tags bug when duplicating tags

* move key to reload tags

* update header spacing

* build

* update hex case

* refactor workflow title

* remove unusued prop

* keep focus on error, fix bug on error

* Fix bug with name / tags toggle on error

* impl creating new workflow name

*  Refactor endpoint per new guidelines

* support naming endpoint

*  Refactor to support numeric suffixes

* 👕 Lint migrations for unique workflow names

*  Add migrations set default dates to indexes

* fix connection push bug

*  Lowercase default workflow name

*  Add prefixes to set default dates migration

*  Fix indentation on default dates migrations

*  Add temp ts-ignore for unrelated change

*  Adjust default dates migration for MySQL

Remove change to data column in credentials_entity, already covered by Omar's migration. Also, fix quotes from table prefix addition.

*  Adjust quotes in dates migration for PG

* fix safari color bug

* fix count bug

* fix scroll bugs in dropdown

* expand filter size

* apply box-sizing to main header

* update workflow names in executions to be wrapped by quotes

* fix bug where key is same in dropdown

* fix firefox bug

* move up push connection session

* 🔨 Remove mistakenly added nullable property

* 🔥 Remove unneeded index drop-create (PG)

* 🔥 Remove unneeded table copying

*  Merge dates migration with tags migration

* 🔨 Refactor endpoint and make wf name env

* dropdown colors in firefox

* update colors to use variables

* update thumb color

* change error message

* remove 100 char maximum

* fix bug with saving tags dropdowns multiple times

* update error message when no name

*  Update name missing toast message

*  Update workflow already exists message

* disable saving for executions

* fix bug causing modal to close

* make tags in workflow open clickable

* increase workflow limit to 3

* remove success notifications

* update header spacing

* escape tag names

* update tag and table colors

* remove tags from export

* build

* clean up push connection dependencies

* address ben's comments

* revert tags optional interface

* address comments

* update duplicate message

* build

* fix eol

* add one more eol

*  Update comment

* add hover style for workflow open, fix up font weight

Co-authored-by: Mutasem <mutdmour@gmail.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
This commit is contained in:
Ben Hesseldieck
2021-05-29 20:31:21 +02:00
committed by GitHub
parent 335673d329
commit 05eec87d1d
92 changed files with 4602 additions and 1236 deletions

View File

@@ -134,7 +134,7 @@ export interface IRestApi {
getNodeParameterOptions(nodeType: string, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise<INodePropertyOptions[]>;
removeTestWebhook(workflowId: string): Promise<boolean>;
runWorkflow(runData: IStartRunData): Promise<IExecutionPushResponse>;
createNewWorkflow(sendData: IWorkflowData): Promise<IWorkflowDb>;
createNewWorkflow(sendData: IWorkflowDataUpdate): Promise<IWorkflowDb>;
updateWorkflow(id: string, data: IWorkflowDataUpdate): Promise<IWorkflowDb>;
deleteWorkflow(name: string): Promise<void>;
getWorkflow(id: string): Promise<IWorkflowDb>;
@@ -208,14 +208,17 @@ export interface IWorkflowData {
nodes: INode[];
connections: IConnections;
settings?: IWorkflowSettings;
tags?: string[];
}
export interface IWorkflowDataUpdate {
id?: string;
name?: string;
nodes?: INode[];
connections?: IConnections;
settings?: IWorkflowSettings;
active?: boolean;
tags?: ITag[] | string[]; // string[] when store or requested, ITag[] from API response
}
// Almost identical to cli.Interfaces.ts
@@ -228,6 +231,7 @@ export interface IWorkflowDb {
nodes: INodeUi[];
connections: IConnections;
settings?: IWorkflowSettings;
tags?: ITag[] | string[]; // string[] when store or requested, ITag[] from API response
}
// Identical to cli.Interfaces.ts
@@ -237,6 +241,7 @@ export interface IWorkflowShortResponse {
active: boolean;
createdAt: number | string;
updatedAt: number | string;
tags: ITag[];
}
@@ -445,3 +450,84 @@ export interface ILinkMenuItemProperties {
href: string;
newWindow?: boolean;
}
export interface ITag {
id: string;
name: string;
usageCount?: number;
}
export interface ITagRow {
tag?: ITag;
usage?: string;
create?: boolean;
disable?: boolean;
update?: boolean;
delete?: boolean;
}
export interface IRootState {
activeExecutions: IExecutionsCurrentSummaryExtended[];
activeWorkflows: string[];
activeActions: string[];
activeNode: string | null;
baseUrl: string;
credentials: ICredentialsResponse[] | null;
credentialTypes: ICredentialType[] | null;
endpointWebhook: string;
endpointWebhookTest: string;
executionId: string | null;
executingNode: string | null;
executionWaitingForWebhook: boolean;
pushConnectionActive: boolean;
saveDataErrorExecution: string;
saveDataSuccessExecution: string;
saveManualExecutions: boolean;
timezone: string;
stateIsDirty: boolean;
executionTimeout: number;
maxExecutionTimeout: number;
versionCli: string;
oauthCallbackUrls: object;
n8nMetadata: object;
workflowExecutionData: IExecutionResponse | null;
lastSelectedNode: string | null;
lastSelectedNodeOutputIndex: number | null;
nodeIndex: Array<string | null>;
nodeTypes: INodeTypeDescription[];
nodeViewOffsetPosition: XYPositon;
nodeViewMoveInProgress: boolean;
selectedNodes: INodeUi[];
sessionId: string;
urlBaseWebhook: string;
workflow: IWorkflowDb;
sidebarMenuItems: IMenuItem[];
}
export interface ITagsState {
tags: { [id: string]: ITag };
isLoading: boolean;
fetchedAll: boolean;
fetchedUsageCount: boolean;
}
export interface IModalState {
open: boolean;
}
export interface IUiState {
sidebarMenuCollapsed: boolean;
modalStack: string[];
modals: {
[key: string]: IModalState;
};
isPageLoading: boolean;
}
export interface IWorkflowsState {
}
export interface IRestApiContext {
baseUrl: string;
sessionId: string;
}

View File

@@ -0,0 +1,76 @@
import axios, { AxiosRequestConfig, Method } from 'axios';
import {
IDataObject,
} from 'n8n-workflow';
import {
IRestApiContext,
} from '../Interface';
class ResponseError extends Error {
// The HTTP status code of response
httpStatusCode?: number;
// The error code in the response
errorCode?: number;
// The stack trace of the server
serverStackTrace?: string;
/**
* Creates an instance of ResponseError.
* @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 ResponseError
*/
constructor (message: string, options: {errorCode?: number, httpStatusCode?: number, stack?: string} = {}) {
super(message);
this.name = 'ResponseError';
const { errorCode, httpStatusCode, stack } = options;
if (errorCode) {
this.errorCode = errorCode;
}
if (httpStatusCode) {
this.httpStatusCode = httpStatusCode;
}
if (stack) {
this.serverStackTrace = stack;
}
}
}
export async function makeRestApiRequest(context: IRestApiContext, method: Method, endpoint: string, data?: IDataObject) {
const { baseUrl, sessionId } = context;
const options: AxiosRequestConfig = {
method,
url: endpoint,
baseURL: baseUrl,
headers: {
sessionid: sessionId,
},
};
if (['PATCH', 'POST', 'PUT'].includes(method)) {
options.data = data;
} else {
options.params = data;
}
try {
const response = await axios.request(options);
return response.data.data;
} catch (error) {
if (error.message === 'Network Error') {
throw new ResponseError('API-Server can not be reached. It is probably down.');
}
const errorResponseData = error.response.data;
if (errorResponseData !== undefined && errorResponseData.message !== undefined) {
throw new ResponseError(errorResponseData.message, {errorCode: errorResponseData.code, httpStatusCode: error.response.status, stack: errorResponseData.stack});
}
throw error;
}
}

View File

@@ -0,0 +1,18 @@
import { IRestApiContext, ITag } from '@/Interface';
import { makeRestApiRequest } from './helpers';
export async function getTags(context: IRestApiContext, withUsageCount = false): Promise<ITag[]> {
return await makeRestApiRequest(context, 'GET', '/tags', { withUsageCount });
}
export async function createTag(context: IRestApiContext, params: { name: string }): Promise<ITag> {
return await makeRestApiRequest(context, 'POST', '/tags', params);
}
export async function updateTag(context: IRestApiContext, id: string, params: { name: string }): Promise<ITag> {
return await makeRestApiRequest(context, 'PATCH', `/tags/${id}`, params);
}
export async function deleteTag(context: IRestApiContext, id: string): Promise<boolean> {
return await makeRestApiRequest(context, 'DELETE', `/tags/${id}`);
}

View File

@@ -0,0 +1,6 @@
import { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from './helpers';
export async function getNewWorkflow(context: IRestApiContext, name?: string) {
return await makeRestApiRequest(context, 'GET', `/workflows/new`, name ? { name } : {});
}

View File

@@ -0,0 +1,101 @@
<template>
<span>
<slot v-bind:bp="bp" v-bind:value="value" />
</span>
</template>
<script lang="ts">
import {
BREAKPOINT_SM,
BREAKPOINT_MD,
BREAKPOINT_LG,
BREAKPOINT_XL,
} from "@/constants";
/**
* matching element.io https://element.eleme.io/#/en-US/component/layout#col-attributes
* xs < 768
* sm >= 768
* md >= 992
* lg >= 1200
* xl >= 1920
*/
import mixins from "vue-typed-mixins";
import { genericHelpers } from "@/components/mixins/genericHelpers";
export default mixins(genericHelpers).extend({
name: "BreakpointsObserver",
props: [
"valueXS",
"valueXL",
"valueLG",
"valueMD",
"valueSM",
"valueDefault",
],
data() {
return {
width: window.innerWidth,
};
},
created() {
window.addEventListener("resize", this.onResize);
},
beforeDestroy() {
window.removeEventListener("resize", this.onResize);
},
methods: {
onResize() {
this.callDebounced("onResizeEnd", 50);
},
onResizeEnd() {
this.$data.width = window.innerWidth;
},
},
computed: {
bp(): string {
if (this.$data.width < BREAKPOINT_SM) {
return "XS";
}
if (this.$data.width >= BREAKPOINT_XL) {
return "XL";
}
if (this.$data.width >= BREAKPOINT_LG) {
return "LG";
}
if (this.$data.width >= BREAKPOINT_MD) {
return "MD";
}
return "SM";
},
value(): any | undefined { // tslint:disable-line:no-any
if (this.$props.valueXS !== undefined && this.$data.width < BREAKPOINT_SM) {
return this.$props.valueXS;
}
if (this.$props.valueXL !== undefined && this.$data.width >= BREAKPOINT_XL) {
return this.$props.valueXL;
}
if (this.$props.valueLG !== undefined && this.$data.width >= BREAKPOINT_LG) {
return this.$props.valueLG;
}
if (this.$props.valueMD !== undefined && this.$data.width >= BREAKPOINT_MD) {
return this.$props.valueMD;
}
if (this.$props.valueSM !== undefined) {
return this.$props.valueSM;
}
return this.$props.valueDefault;
},
},
});
</script>

View File

@@ -0,0 +1,125 @@
<template>
<Modal
:name="modalName"
:eventBus="modalBus"
@enter="save"
size="sm"
title="Duplicate Workflow"
>
<template v-slot:content>
<el-row>
<el-input
v-model="name"
ref="nameInput"
placeholder="Enter workflow name"
:maxlength="MAX_WORKFLOW_NAME_LENGTH"
/>
</el-row>
<el-row>
<TagsDropdown
:createEnabled="true"
:currentTagIds="currentTagIds"
:eventBus="dropdownBus"
@blur="onTagsBlur"
@esc="onTagsEsc"
@update="onTagsUpdate"
placeholder="Choose or create a tag"
ref="dropdown"
/>
</el-row>
</template>
<template v-slot:footer="{ close }">
<el-button size="small" @click="save" :loading="isSaving">Save</el-button>
<el-button size="small" @click="close" :disabled="isSaving">Cancel</el-button>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from "vue";
import mixins from "vue-typed-mixins";
import { MAX_WORKFLOW_NAME_LENGTH } from "@/constants";
import { workflowHelpers } from "@/components/mixins/workflowHelpers";
import { showMessage } from "@/components/mixins/showMessage";
import TagsDropdown from "@/components/TagsDropdown.vue";
import Modal from "./Modal.vue";
export default mixins(showMessage, workflowHelpers).extend({
components: { TagsDropdown, Modal },
name: "DuplicateWorkflow",
props: ["dialogVisible", "modalName", "isActive"],
data() {
const currentTagIds = this.$store.getters[
"workflowTags"
] as string[];
return {
name: '',
currentTagIds,
isSaving: false,
modalBus: new Vue(),
dropdownBus: new Vue(),
MAX_WORKFLOW_NAME_LENGTH,
prevTagIds: currentTagIds,
};
},
async mounted() {
this.$data.name = await this.$store.dispatch('workflows/getDuplicateCurrentWorkflowName');
this.$nextTick(() => this.focusOnNameInput());
},
watch: {
isActive(active) {
if (active) {
this.focusOnSelect();
}
},
},
methods: {
focusOnSelect() {
this.dropdownBus.$emit('focus');
},
focusOnNameInput() {
const input = this.$refs.nameInput as HTMLElement;
if (input && input.focus) {
input.focus();
}
},
onTagsBlur() {
this.prevTagIds = this.currentTagIds;
},
onTagsEsc() {
// revert last changes
this.currentTagIds = this.prevTagIds;
},
onTagsUpdate(tagIds: string[]) {
this.currentTagIds = tagIds;
},
async save(): Promise<void> {
const name = this.name.trim();
if (!name) {
this.$showMessage({
title: "Name missing",
message: `Please enter a name.`,
type: "error",
});
return;
}
this.$data.isSaving = true;
const saved = await this.saveAsNewWorkflow({name, tags: this.currentTagIds});
if (saved) {
this.closeDialog();
}
this.$data.isSaving = false;
},
closeDialog(): void {
this.modalBus.$emit("close");
},
},
});
</script>

View File

@@ -0,0 +1,70 @@
<template>
<!-- mock el-input element to apply styles -->
<div :class="{'el-input': true, 'static-size': staticSize}" :data-value="hiddenValue">
<slot></slot>
</div>
</template>
<script lang="ts">
import Vue from "vue";
export default Vue.extend({
name: "ExpandableInputBase",
props: ['value', 'placeholder', 'staticSize'],
computed: {
hiddenValue() {
let value = (this.value as string).replace(/\s/g, '.'); // force input to expand on space chars
if (!value) {
// @ts-ignore
value = this.$props.placeholder;
}
return `${value}`; // adjust for padding
},
},
});
</script>
<style lang="scss" scoped>
$--horiz-padding: 15px;
*,
*::after {
box-sizing: border-box;
}
input {
border: 1px solid transparent;
padding: 0 $--horiz-padding - 2px; // -2px for borders
}
div.el-input {
display: inline-grid;
font: inherit;
padding: 10px 0;
&::after,
input {
grid-area: 1 / 2;
font: inherit;
}
&::after {
content: attr(data-value) ' ';
visibility: hidden;
white-space: nowrap;
padding: 0 $--horiz-padding;
}
&:not(.static-size)::after {
overflow: hidden;
}
&:hover {
input {
border: $--custom-input-border-shadow
}
}
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<ExpandableInputBase :value="value" :placeholder="placeholder">
<input
class="el-input__inner"
:value="value"
:placeholder="placeholder"
:maxlength="maxlength"
@input="onInput"
@keydown.enter="onEnter"
@keydown.esc="onEscape"
ref="input"
size="4"
v-click-outside="onBlur"
/>
</ExpandableInputBase>
</template>
<script lang="ts">
import Vue from "vue";
import ExpandableInputBase from "./ExpandableInputBase.vue";
export default Vue.extend({
components: { ExpandableInputBase },
name: "ExpandableInputEdit",
props: ['value', 'placeholder', 'maxlength', 'autofocus', 'eventBus'],
mounted() {
// autofocus on input element is not reliable
if (this.$props.autofocus && this.$refs.input) {
this.focus();
}
if (this.$props.eventBus) {
this.$props.eventBus.$on('focus', () => {
this.focus();
});
}
},
methods: {
focus() {
if (this.$refs.input) {
(this.$refs.input as HTMLInputElement).focus();
}
},
onInput() {
this.$emit('input', (this.$refs.input as HTMLInputElement).value);
},
onEnter() {
this.$emit('enter', (this.$refs.input as HTMLInputElement).value);
},
onBlur() {
this.$emit('blur', (this.$refs.input as HTMLInputElement).value);
},
onEscape() {
this.$emit('esc');
},
},
});
</script>
<style lang="scss" scoped>
.el-input input.el-input__inner {
border: 1px solid $--color-primary !important;
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<ExpandableInputBase :value="value" :staticSize="true">
<template>
<input
:class="{ 'el-input__inner': true, clickable: true }"
:value="value"
:disabled="true"
size="4"
/>
</template>
</ExpandableInputBase>
</template>
<script lang="ts">
import Vue from "vue";
import ExpandableInputBase from "./ExpandableInputBase.vue";
export default Vue.extend({
components: { ExpandableInputBase },
name: "ExpandableInputPreview",
props: ["value"],
});
</script>
<style lang="scss" scoped>
input,
input:hover {
background-color: unset;
transition: unset;
pointer-events: none; // fix firefox bug
}
input[disabled] {
color: $--custom-font-black;
// override safari colors
-webkit-text-fill-color: $--custom-font-black;
-webkit-opacity: 1;
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<span @keydown.stop class="inline-edit" >
<span v-if="isEditEnabled">
<ExpandableInputEdit
:placeholder="placeholder"
:value="newValue"
:maxlength="maxLength"
:autofocus="true"
:eventBus="inputBus"
@input="onInput"
@esc="onEscape"
@blur="onBlur"
@enter="submit"
/>
</span>
<span @click="onClick" class="preview" v-else>
<ExpandableInputPreview
:value="previewValue || value"
/>
</span>
</span>
</template>
<script lang="ts">
import Vue from "vue";
import ExpandableInputEdit from "@/components/ExpandableInput/ExpandableInputEdit.vue";
import ExpandableInputPreview from "@/components/ExpandableInput/ExpandableInputPreview.vue";
export default Vue.extend({
name: "InlineTextEdit",
components: { ExpandableInputEdit, ExpandableInputPreview },
props: ['isEditEnabled', 'value', 'placeholder', 'maxLength', 'previewValue'],
data() {
return {
newValue: '',
escPressed: false,
disabled: false,
inputBus: new Vue(),
};
},
methods: {
onInput(newValue: string) {
if (this.disabled) {
return;
}
this.newValue = newValue;
},
onClick() {
if (this.disabled) {
return;
}
this.$data.newValue = this.$props.value;
this.$emit('toggle');
},
onBlur() {
if (this.disabled) {
return;
}
if (!this.$data.escPressed) {
this.submit();
}
this.$data.escPressed = false;
},
submit() {
if (this.disabled) {
return;
}
const onSubmit = (updated: boolean) => {
this.$data.disabled = false;
if (!updated) {
this.$data.inputBus.$emit('focus');
}
};
this.$data.disabled = true;
this.$emit('submit', this.newValue, onSubmit);
},
onEscape() {
if (this.disabled) {
return;
}
this.$data.escPressed = true;
this.$emit('toggle');
},
},
});
</script>
<style lang="scss" scoped>
.preview {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<span ref="observed">
<slot></slot>
</span>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import emitter from '@/components/mixins/emitter';
export default mixins(emitter).extend({
name: 'IntersectionObserved',
props: ['enabled'],
mounted() {
if (!this.$props.enabled) {
return;
}
this.$nextTick(() => {
this.$dispatch('IntersectionObserver', 'observe', this.$refs.observed);
});
},
beforeDestroy() {
if (this.$props.enabled) {
this.$dispatch('IntersectionObserver', 'unobserve', this.$refs.observed);
}
},
});
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div ref="root">
<slot></slot>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'IntersectionObserver',
props: ['threshold', 'enabled'],
data() {
return {
observer: null,
};
},
mounted() {
if (!this.$props.enabled) {
return;
}
const options = {
root: this.$refs.root as Element,
rootMargin: '0px',
threshold: this.$props.threshold,
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(({target, isIntersecting}) => {
this.$emit('observed', {
el: target,
isIntersecting,
});
});
}, options);
this.$data.observer = observer;
this.$on('observe', (observed: Element) => {
observer.observe(observed);
});
this.$on('unobserve', (observed: Element) => {
observer.unobserve(observed);
});
},
beforeDestroy() {
if (this.$props.enabled) {
this.$data.observer.disconnect();
}
},
});
</script>

View File

@@ -1,288 +0,0 @@
<template>
<div>
<div class="main-header">
<input type="file" ref="importFile" style="display: none" v-on:change="handleFileImport()">
<div class="top-menu">
<div class="center-item">
<span v-if="isExecutionPage">
Execution Id:
<span v-if="isExecutionPage" class="execution-name">
<strong>{{executionId}}</strong>&nbsp;
<font-awesome-icon icon="check" class="execution-icon success" v-if="executionFinished" title="Execution was successful" />
<font-awesome-icon icon="times" class="execution-icon error" v-else title="Execution did fail" />
</span>
of
<span class="workflow-name clickable" title="Open Workflow">
<span @click="openWorkflow(workflowExecution.workflowId)">"{{workflowName}}"</span>
</span>
workflow
</span>
<span index="workflow-name" class="current-workflow" v-if="!isReadOnly">
<span v-if="currentWorkflow">Workflow: <span class="workflow-name">{{workflowName}}<span v-if="isDirty">*</span></span></span>
<span v-else class="workflow-not-saved">Workflow was not saved!</span>
</span>
<span class="saving-workflow" v-if="isWorkflowSaving">
<font-awesome-icon icon="spinner" spin />
Saving...
</span>
</div>
<div class="push-connection-lost" v-if="!isPushConnectionActive">
<el-tooltip placement="bottom-end" effect="light">
<div slot="content">
Cannot connect to server.<br />
It is either down or you have a connection issue. <br />
It should reconnect automatically once the issue is resolved.
</div>
<span>
<font-awesome-icon icon="exclamation-triangle" />&nbsp;
Connection lost
</span>
</el-tooltip>
</div>
<div class="workflow-active" v-else-if="!isReadOnly">
Active:
<workflow-activator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflow" :disabled="!currentWorkflow"/>
</div>
<div class="read-only" v-if="isReadOnly">
<el-tooltip placement="bottom-end" effect="light">
<div slot="content">
You're viewing the log of a previous execution. You cannot<br />
make changes since this execution already occured. Make changes<br /> to this workflow by clicking on 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 { titleChange } from '@/components/mixins/titleChange';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { saveAs } from 'file-saver';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
pushConnection,
restApi,
showMessage,
titleChange,
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');
},
isDirty () : boolean {
return this.$store.getters.getStateIsDirty;
},
},
methods: {
async openWorkflow (workflowId: string) {
this.$titleSet(this.workflowName, 'IDLE');
// 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;
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="container">
<span class="title">
Execution Id:
<span>
<strong>{{ executionId }}</strong
>&nbsp;
<font-awesome-icon
icon="check"
class="execution-icon success"
v-if="executionFinished"
title="Execution was successful"
/>
<font-awesome-icon
icon="times"
class="execution-icon error"
v-else
title="Execution failed"
/>
</span>
of
<span class="primary-color clickable" title="Open Workflow">
<WorkflowNameShort :name="workflowName">
<template v-slot="{ shortenedName }">
<span @click="openWorkflow(workflowExecution.workflowId)">
"{{ shortenedName }}"
</span>
</template>
</WorkflowNameShort>
</span>
workflow
</span>
<ReadOnly class="read-only" />
</div>
</template>
<script lang="ts">
import mixins from "vue-typed-mixins";
import { IExecutionResponse } from "../../../Interface";
import { titleChange } from "@/components/mixins/titleChange";
import WorkflowNameShort from "@/components/WorkflowNameShort.vue";
import ReadOnly from "@/components/MainHeader/ExecutionDetails/ReadOnly.vue";
export default mixins(titleChange).extend({
name: "ExecutionDetails",
components: {
WorkflowNameShort,
ReadOnly,
},
computed: {
executionId(): string | undefined {
return this.$route.params.id;
},
executionFinished(): boolean {
const fullExecution = this.$store.getters.getWorkflowExecution;
return !!fullExecution && fullExecution.finished;
},
workflowExecution(): IExecutionResponse | null {
return this.$store.getters.getWorkflowExecution;
},
workflowName(): string {
return this.$store.getters.workflowName;
},
},
methods: {
async openWorkflow(workflowId: string) {
this.$titleSet(this.workflowName, "IDLE");
// Change to other workflow
this.$router.push({
name: "NodeViewExisting",
params: { name: workflowId },
});
},
},
});
</script>
<style scoped lang="scss">
* {
box-sizing: border-box;
}
.execution-icon.success {
color: $--custom-success-text-light;
}
.container {
width: 100%;
display: flex;
}
.title {
flex: 1;
text-align: center;
}
.read-only {
align-self: flex-end;
}
</style>

View File

@@ -0,0 +1,13 @@
<template>
<el-tooltip class="primary-color" placement="bottom-end" effect="light">
<div slot="content">
You're viewing the log of a previous execution. You cannot<br />
make changes since this execution already occured. Make changes<br />
to this workflow by clicking on its name on the left.
</div>
<span>
<font-awesome-icon icon="exclamation-triangle" />
Read only
</span>
</el-tooltip>
</template>

View File

@@ -0,0 +1,94 @@
<template>
<div>
<div :class="{'main-header': true, expanded: !sidebarMenuCollapsed}">
<div class="top-menu">
<ExecutionDetails v-if="isExecutionPage" />
<WorkflowDetails v-else />
</div>
</div>
</div>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import { pushConnection } from '@/components/mixins/pushConnection';
import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue';
import ExecutionDetails from '@/components/MainHeader/ExecutionDetails/ExecutionDetails.vue';
export default mixins(
pushConnection,
)
.extend({
name: 'MainHeader',
components: {
WorkflowDetails,
ExecutionDetails,
},
computed: {
...mapGetters('ui', [
'sidebarMenuCollapsed',
]),
isExecutionPage (): boolean {
return ['ExecutionById'].includes(this.$route.name as string);
},
},
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%;
box-sizing: border-box;
padding-left: $--sidebar-width;
&.expanded {
padding-left: $--sidebar-expanded-width;
}
* {
box-sizing: border-box;
}
}
.top-menu {
display: flex;
align-items: center;
font-size: 0.9em;
height: $--header-height;
font-weight: 400;
padding: 0 20px;
}
</style>

View File

@@ -0,0 +1,279 @@
<template>
<div class="container" v-if="workflowName">
<BreakpointsObserver :valueXS="15" :valueSM="25" :valueMD="50" class="name-container">
<template v-slot="{ value }">
<WorkflowNameShort
:name="workflowName"
:limit="value"
:custom="true"
>
<template v-slot="{ shortenedName }">
<InlineTextEdit
:value="workflowName"
:previewValue="shortenedName"
:isEditEnabled="isNameEditEnabled"
:maxLength="MAX_WORKFLOW_NAME_LENGTH"
@toggle="onNameToggle"
@submit="onNameSubmit"
placeholder="Enter workflow name"
class="name"
/>
</template>
</WorkflowNameShort>
</template>
</BreakpointsObserver>
<div
v-if="isTagsEditEnabled"
class="tags">
<TagsDropdown
:createEnabled="true"
:currentTagIds="appliedTagIds"
:eventBus="tagsEditBus"
@blur="onTagsBlur"
@update="onTagsUpdate"
@esc="onTagsEditEsc"
placeholder="Choose or create a tag"
ref="dropdown"
class="tags-edit"
/>
</div>
<div
class="tags"
v-else-if="currentWorkflowTagIds.length === 0"
>
<span
class="add-tag clickable"
@click="onTagsEditEnable"
>
+ Add tag
</span>
</div>
<TagsContainer
v-else
:tagIds="currentWorkflowTagIds"
:clickable="true"
:responsive="true"
:key="currentWorkflowId"
@click="onTagsEditEnable"
class="tags"
/>
<PushConnectionTracker class="actions">
<template>
<span class="activator">
<span>Active:</span>
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" :disabled="!currentWorkflowId"/>
</span>
<SaveWorkflowButton />
</template>
</PushConnectionTracker>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import mixins from "vue-typed-mixins";
import { mapGetters } from "vuex";
import { MAX_WORKFLOW_NAME_LENGTH } from "@/constants";
import WorkflowNameShort from "@/components/WorkflowNameShort.vue";
import TagsContainer from "@/components/TagsContainer.vue";
import PushConnectionTracker from "@/components/PushConnectionTracker.vue";
import WorkflowActivator from "@/components/WorkflowActivator.vue";
import { workflowHelpers } from "@/components/mixins/workflowHelpers";
import SaveWorkflowButton from "@/components/SaveWorkflowButton.vue";
import TagsDropdown from "@/components/TagsDropdown.vue";
import InlineTextEdit from "@/components/InlineTextEdit.vue";
import BreakpointsObserver from "@/components/BreakpointsObserver.vue";
const hasChanged = (prev: string[], curr: string[]) => {
if (prev.length !== curr.length) {
return true;
}
const set = new Set(prev);
return curr.reduce((accu, val) => accu || !set.has(val), false);
};
export default mixins(workflowHelpers).extend({
name: "WorkflowDetails",
components: {
TagsContainer,
PushConnectionTracker,
WorkflowNameShort,
WorkflowActivator,
SaveWorkflowButton,
TagsDropdown,
InlineTextEdit,
BreakpointsObserver,
},
data() {
return {
isTagsEditEnabled: false,
isNameEditEnabled: false,
appliedTagIds: [],
tagsEditBus: new Vue(),
MAX_WORKFLOW_NAME_LENGTH,
tagsSaving: false,
};
},
computed: {
...mapGetters({
isWorkflowActive: "isActive",
workflowName: "workflowName",
isDirty: "getStateIsDirty",
currentWorkflowTagIds: "workflowTags",
}),
isWorkflowSaving(): boolean {
return this.$store.getters.isActionActive("workflowSaving");
},
currentWorkflowId() {
return this.$route.params.name;
},
},
methods: {
onTagsEditEnable() {
this.$data.appliedTagIds = this.currentWorkflowTagIds;
this.$data.isTagsEditEnabled = true;
setTimeout(() => {
// allow name update to occur before disabling name edit
this.$data.isNameEditEnabled = false;
this.$data.tagsEditBus.$emit('focus');
}, 0);
},
async onTagsUpdate(tags: string[]) {
this.$data.appliedTagIds = tags;
},
async onTagsBlur() {
const current = this.currentWorkflowTagIds;
const tags = this.$data.appliedTagIds;
if (!hasChanged(current, tags)) {
this.$data.isTagsEditEnabled = false;
return;
}
if (this.$data.tagsSaving) {
return;
}
this.$data.tagsSaving = true;
const saved = await this.saveCurrentWorkflow({ tags });
this.$data.tagsSaving = false;
if (saved) {
this.$data.isTagsEditEnabled = false;
}
},
onTagsEditEsc() {
this.$data.isTagsEditEnabled = false;
},
onNameToggle() {
this.$data.isNameEditEnabled = !this.$data.isNameEditEnabled;
if (this.$data.isNameEditEnabled) {
if (this.$data.isTagsEditEnabled) {
// @ts-ignore
this.onTagsBlur();
}
this.$data.isTagsEditEnabled = false;
}
},
async onNameSubmit(name: string, cb: (saved: boolean) => void) {
const newName = name.trim();
if (!newName) {
this.$showMessage({
title: "Name missing",
message: `Please enter a name, or press 'esc' to go back to the old one.`,
type: "error",
});
cb(false);
return;
}
if (newName === this.workflowName) {
this.$data.isNameEditEnabled = false;
cb(true);
return;
}
const saved = await this.saveCurrentWorkflow({ name });
if (saved) {
this.$data.isNameEditEnabled = false;
}
cb(saved);
},
},
watch: {
currentWorkflowId() {
this.$data.isTagsEditEnabled = false;
this.$data.isNameEditEnabled = false;
},
},
});
</script>
<style scoped lang="scss">
$--text-line-height: 24px;
$--header-spacing: 20px;
.container {
width: 100%;
display: flex;
align-items: center;
}
.name-container {
margin-right: $--header-spacing;
}
.name {
color: $--custom-font-dark;
font-size: 15px;
}
.activator {
color: $--custom-font-dark;
font-weight: 400;
font-size: 13px;
line-height: $--text-line-height;
display: flex;
align-items: center;
margin-right: 30px;
> span {
margin-right: 5px;
}
}
.add-tag {
font-size: 12px;
padding: 20px 0; // to be more clickable
color: $--custom-font-very-light;
font-weight: 600;
white-space: nowrap;
&:hover {
color: $--color-primary;
}
}
.tags {
flex: 1;
padding-right: 20px;
margin-right: $--header-spacing;
}
.tags-edit {
min-width: 100px;
max-width: 460px;
}
.actions {
display: flex;
align-items: center;
}
</style>

View File

@@ -4,12 +4,11 @@
<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">
<div id="collapse-change-button" class="clickable" @click="toggleCollapse">
<font-awesome-icon icon="angle-right" class="icon" />
</div>
<el-menu default-active="workflow" @select="handleSelect" :collapse="isCollapsed">
@@ -41,22 +40,16 @@
<span slot="title" class="item-title">Open</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-save" :disabled="!currentWorkflow">
<el-menu-item index="workflow-save">
<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">
<el-menu-item index="workflow-duplicate" :disabled="!currentWorkflow">
<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-rename" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="edit"/>
<span slot="title" class="item-title">Rename</span>
<span slot="title" class="item-title">Duplicate</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-delete" :disabled="!currentWorkflow">
@@ -143,7 +136,6 @@
<script lang="ts">
import Vue from 'vue';
import { MessageBoxInputData } from 'element-ui/types/message-box';
import {
@@ -157,7 +149,6 @@ import About from '@/components/About.vue';
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';
@@ -170,6 +161,7 @@ import { workflowRun } from '@/components/mixins/workflowRun';
import { saveAs } from 'file-saver';
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import MenuItemsIterator from './MainSidebarMenuItemsIterator.vue';
const helpMenuItems: IMenuItem[] = [
@@ -220,7 +212,6 @@ export default mixins(
CredentialsEdit,
CredentialsList,
ExecutionsList,
WorkflowOpen,
WorkflowSettings,
MenuItemsIterator,
},
@@ -229,17 +220,18 @@ export default mixins(
aboutDialogVisible: false,
// @ts-ignore
basePath: this.$store.getters.getBaseUrl,
isCollapsed: true,
credentialNewDialogVisible: false,
credentialOpenDialogVisible: false,
executionsListDialogVisible: false,
stopExecutionInProgress: false,
workflowOpenDialogVisible: false,
workflowSettingsDialogVisible: false,
helpMenuItems,
};
},
computed: {
...mapGetters('ui', {
isCollapsed: 'sidebarMenuCollapsed',
}),
exeuctionId (): string | undefined {
return this.$route.params.id;
},
@@ -294,6 +286,9 @@ export default mixins(
},
},
methods: {
toggleCollapse () {
this.$store.commit('ui/toggleSidebarMenuCollapse');
},
clearExecutionData () {
this.$store.commit('setWorkflowExecutionData', null);
this.updateNodesExecutionIssues();
@@ -301,9 +296,6 @@ export default mixins(
closeAboutDialog () {
this.aboutDialogVisible = false;
},
closeWorkflowOpenDialog () {
this.workflowOpenDialogVisible = false;
},
closeWorkflowSettingsDialog () {
this.workflowSettingsDialogVisible = false;
},
@@ -316,6 +308,9 @@ export default mixins(
closeCredentialNewDialog () {
this.credentialNewDialogVisible = false;
},
openTagManager() {
this.$store.dispatch('ui/openTagsManagerModal');
},
async stopExecution () {
const executionId = this.$store.getters.activeExecutionId;
if (executionId === null) {
@@ -342,7 +337,7 @@ export default mixins(
params: { name: workflowId },
});
this.workflowOpenDialogVisible = false;
this.$store.commit('ui/closeTopModal');
},
async handleFileImport () {
const reader = new FileReader();
@@ -372,7 +367,7 @@ export default mixins(
},
async handleSelect (key: string, keyPath: string) {
if (key === 'workflow-open') {
this.workflowOpenDialogVisible = true;
this.$store.dispatch('ui/openWorklfowOpenModal');
} else if (key === 'workflow-import-file') {
(this.$refs.importFile as HTMLInputElement).click();
} else if (key === 'workflow-import-url') {
@@ -386,49 +381,6 @@ export default mixins(
this.$root.$emit('importWorkflowUrl', { url: promptResponse.value });
} catch (e) {}
} else if (key === 'workflow-rename') {
const workflowName = await this.$prompt(
'Enter new workflow name',
'Rename',
{
inputValue: this.workflowName,
confirmButtonText: 'Rename',
cancelButtonText: 'Cancel',
},
)
.then((data) => {
// @ts-ignore
return data.value;
})
.catch(() => {
// User did cancel
return undefined;
});
if (workflowName === undefined || workflowName === this.workflowName) {
return;
}
const workflowId = this.$store.getters.workflowId;
const updateData = {
name: workflowName,
};
try {
await this.restApi().updateWorkflow(workflowId, updateData);
} catch (error) {
this.$showError(error, 'Problem renaming the workflow', 'There was a problem renaming the workflow:');
return;
}
this.$store.commit('setWorkflowName', {newName: workflowName, setStateDirty: false});
this.$showMessage({
title: 'Workflow renamed',
message: `The workflow got renamed to "${workflowName}"!`,
type: 'success',
});
} 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!');
@@ -454,7 +406,9 @@ export default mixins(
this.$router.push({ name: 'NodeViewNew' });
} else if (key === 'workflow-download') {
const workflowData = await this.getWorkflowDataToSave();
const blob = new Blob([JSON.stringify(workflowData, null, 2)], {
const {tags, ...data} = workflowData;
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json;charset=utf-8',
});
@@ -465,8 +419,8 @@ export default mixins(
saveAs(blob, workflowName + '.json');
} else if (key === 'workflow-save') {
this.saveCurrentWorkflow();
} else if (key === 'workflow-save-as') {
this.saveCurrentWorkflow(true);
} else if (key === 'workflow-duplicate') {
this.$store.dispatch('ui/openDuplicateModal');
} else if (key === 'help-about') {
this.aboutDialogVisible = true;
} else if (key === 'workflow-settings') {
@@ -508,11 +462,6 @@ export default mixins(
}
},
},
async mounted () {
this.$root.$on('openWorkflowDialog', async () => {
this.workflowOpenDialogVisible = true;
});
},
});
</script>
@@ -568,7 +517,7 @@ export default mixins(
&.logo-item {
background-color: $--color-primary !important;
height: 65px;
height: $--header-height;
.icon {
position: relative;
@@ -610,10 +559,10 @@ a.logo {
.side-menu-wrapper {
height: 100%;
width: 65px;
width: $--sidebar-width;
&.expanded {
width: 200px;
width: $--sidebar-expanded-width;
}
}

View File

@@ -0,0 +1,115 @@
<template>
<div v-if="dialogVisible">
<el-dialog
:visible="dialogVisible"
:before-close="closeDialog"
:title="title"
:class="{ 'dialog-wrapper': true, [size]: true }"
:width="width"
append-to-body
>
<template v-slot:title>
<slot name="header" />
</template>
<div class="modal-content" @keydown.stop @keydown.enter="handleEnter" @keydown.esc="closeDialog">
<slot name="content"/>
</div>
<el-row class="modal-footer">
<slot name="footer" :close="closeDialog" />
</el-row>
</el-dialog>
</div>
</template>
<script lang="ts">
import Vue from "vue";
const sizeMap: {[size: string]: string} = {
xl: '80%',
m: '50%',
default: '50%',
};
export default Vue.extend({
name: "Modal",
props: ['name', 'title', 'eventBus', 'size'],
mounted() {
window.addEventListener('keydown', this.onWindowKeydown);
if (this.$props.eventBus) {
this.$props.eventBus.$on('close', () => {
this.closeDialog();
});
}
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
},
beforeDestroy() {
window.removeEventListener('keydown', this.onWindowKeydown);
},
methods: {
onWindowKeydown(event: KeyboardEvent) {
if (!this.isActive) {
return;
}
if (event && event.keyCode === 13) {
this.handleEnter();
}
},
handleEnter() {
if (this.isActive) {
this.$emit('enter');
}
},
closeDialog() {
this.$store.commit('ui/closeTopModal');
},
},
computed: {
width(): string {
return this.$props.size ? sizeMap[this.$props.size] : sizeMap.default;
},
isActive(): boolean {
return this.$store.getters['ui/isModalActive'](this.$props.name);
},
dialogVisible(): boolean {
return this.$store.getters['ui/isModalOpen'](this.$props.name);
},
},
});
</script>
<style lang="scss">
.dialog-wrapper {
* {
box-sizing: border-box;
}
&.xl > div, &.md > div {
min-width: 620px;
}
&.sm {
display: flex;
align-items: center;
justify-content: center;
> div {
max-width: 420px;
}
}
}
.modal-content > .el-row {
margin-bottom: 15px;
}
.modal-footer > .el-button {
float: right;
margin-left: 5px;
}
</style>

View File

@@ -0,0 +1,24 @@
<template>
<div
v-if="isOpen(name)"
>
<slot :modalName="name" :active="isActive(name)"></slot>
</div>
</template>
<script lang="ts">
import Vue from "vue";
export default Vue.extend({
name: "ModalRoot",
props: ["name"],
methods: {
isActive(name: string) {
return this.$store.getters['ui/isModalActive'](name);
},
isOpen(name: string) {
return this.$store.getters['ui/isModalOpen'](name);
},
},
});
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div>
<ModalRoot :name="DUPLICATE_MODAL_KEY">
<template v-slot:default="{ modalName, active }">
<DuplicateWorkflowDialog
:isActive="active"
:modalName="modalName"
/>
</template>
</ModalRoot>
<ModalRoot :name="TAGS_MANAGER_MODAL_KEY">
<template v-slot="{ modalName }">
<TagsManager
:modalName="modalName"
/>
</template>
</ModalRoot>
<ModalRoot :name="WORKLOW_OPEN_MODAL_KEY">
<template v-slot="{ modalName }">
<WorkflowOpen
:modalName="modalName"
/>
</template>
</ModalRoot>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY } from '@/constants';
import TagsManager from "@/components/TagsManager/TagsManager.vue";
import DuplicateWorkflowDialog from "@/components/DuplicateWorkflowDialog.vue";
import WorkflowOpen from "@/components/WorkflowOpen.vue";
import ModalRoot from "./ModalRoot.vue";
export default Vue.extend({
name: "Modals",
components: {
TagsManager,
DuplicateWorkflowDialog,
WorkflowOpen,
ModalRoot,
},
data: () => ({
DUPLICATE_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
WORKLOW_OPEN_MODAL_KEY,
}),
});
</script>

View File

@@ -0,0 +1,29 @@
<template>
<span>
<div class="push-connection-lost primary-color" v-if="!pushConnectionActive">
<el-tooltip placement="bottom-end" effect="light">
<div slot="content">
Cannot connect to server.<br />
It is either down or you have a connection issue. <br />
It should reconnect automatically once the issue is resolved.
</div>
<span>
<font-awesome-icon icon="exclamation-triangle" />&nbsp; Connection lost
</span>
</el-tooltip>
</div>
<slot v-else />
</span>
</template>
<script lang="ts">
import Vue from "vue";
import { mapGetters } from "vuex";
export default Vue.extend({
name: "PushConnectionTracker",
computed: {
...mapGetters(["pushConnectionActive"]),
},
});
</script>

View File

@@ -762,7 +762,7 @@ export default mixins(
background: #fff;;
}
tr:nth-child(odd) {
background: $--custom-table-background-alternative;
background: $--custom-table-background-stripe-color;
}
}
}

View File

@@ -0,0 +1,65 @@
<template>
<el-button :disabled="isWorkflowSaving" :class="{saved: isSaved}" size="small" @click="save">
<font-awesome-icon v-if="isWorkflowSaving" icon="spinner" spin />
<span v-else-if="isDirty || isNewWorkflow">
Save
</span>
<span v-else>Saved</span>
</el-button>
</template>
<script lang="ts">
import mixins from "vue-typed-mixins";
import { mapGetters } from "vuex";
import { workflowHelpers } from "@/components/mixins/workflowHelpers";
export default mixins(workflowHelpers).extend({
name: "SaveWorkflowButton",
computed: {
...mapGetters({
isDirty: "getStateIsDirty",
}),
isWorkflowSaving(): boolean {
return this.$store.getters.isActionActive("workflowSaving");
},
isNewWorkflow(): boolean {
return !this.$route.params.name;
},
isSaved(): boolean {
return !this.isWorkflowSaving && !this.isDirty && !this.isNewWorkflow;
},
},
methods: {
save() {
this.saveCurrentWorkflow();
},
},
});
</script>
<style lang="scss" scoped>
.el-button {
width: 65px;
// override disabled colors
color: white;
background-color: $--color-primary;
&:hover:not(.saved) {
color: white;
background-color: $--color-primary;
}
&.saved {
color: $--custom-font-very-light;
font-size: 12px;
font-weight: 600;
line-height: 12px;
text-align: center;
background-color: unset;
pointer-events: none;
}
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<IntersectionObserver :threshold="1.0" @observed="onObserved" class="tags-container" :enabled="responsive">
<template>
<span class="tags">
<span
v-for="tag in tags"
:key="tag.id"
:class="{clickable: !tag.hidden}"
@click="(e) => onClick(e, tag)"
>
<el-tag
:title="tag.title"
type="info"
size="small"
v-if="tag.isCount"
class="count-container"
>
{{ tag.name }}
</el-tag>
<IntersectionObserved
:class="{hidden: tag.hidden}"
:data-id="tag.id"
:enabled="responsive"
v-else
>
<el-tag
:title="tag.name"
type="info"
size="small"
:class="{hoverable}"
>
{{ tag.name }}
</el-tag>
</IntersectionObserved>
</span>
</span>
</template>
</IntersectionObserver>
</template>
<script lang="ts">
import Vue from 'vue';
import { ITag } from '@/Interface';
import IntersectionObserver from './IntersectionObserver.vue';
import IntersectionObserved from './IntersectionObserved.vue';
// random upper limit if none is set to minimize performance impact of observers
const DEFAULT_MAX_TAGS_LIMIT = 20;
interface TagEl extends ITag {
hidden?: boolean;
title?: string;
isCount?: boolean;
}
export default Vue.extend({
components: { IntersectionObserver, IntersectionObserved },
name: 'TagsContainer',
props: [
"tagIds",
"limit",
"clickable",
"responsive",
"hoverable",
],
data() {
return {
visibility: {} as {[id: string]: boolean},
};
},
computed: {
tags() {
const tags = this.$props.tagIds.map((tagId: string) => this.$store.getters['tags/getTagById'](tagId))
.filter(Boolean); // if tag has been deleted from store
const limit = this.$props.limit || DEFAULT_MAX_TAGS_LIMIT;
let toDisplay: TagEl[] = limit ? tags.slice(0, limit) : tags;
toDisplay = toDisplay.map((tag: ITag) => ({...tag, hidden: this.$props.responsive && !this.$data.visibility[tag.id]}));
let visibleCount = toDisplay.length;
if (this.$props.responsive) {
visibleCount = Object.values(this.visibility).reduce((accu, val) => val ? accu + 1 : accu, 0);
}
if (visibleCount < tags.length) {
const hidden = tags.slice(visibleCount);
const hiddenTitle = hidden.reduce((accu: string, tag: ITag) => {
return accu ? `${accu}, ${tag.name}` : tag.name;
}, '');
const countTag: TagEl = {
id: 'count',
name: `+${hidden.length}`,
title: hiddenTitle,
isCount: true,
};
toDisplay.splice(visibleCount, 0, countTag);
}
return toDisplay;
},
},
methods: {
onObserved({el, isIntersecting}: {el: HTMLElement, isIntersecting: boolean}) {
if (el.dataset.id) {
Vue.set(this.$data.visibility, el.dataset.id, isIntersecting);
}
},
onClick(e: MouseEvent, tag: TagEl) {
e.stopPropagation();
// if tag is hidden or not displayed
if (!tag.hidden) {
this.$emit('click', tag.id);
}
},
},
});
</script>
<style lang="scss" scoped>
.tags-container {
display: inline-flex;
overflow: hidden;
}
.tags {
display: flex;
> span {
padding-right: 4px; // why not margin? for space between tags to be clickable
}
}
.hidden {
visibility: hidden;
}
.el-tag.hoverable:hover {
border-color: $--color-primary;
}
.count-container {
position: absolute;
max-width: 40px;
text-overflow: ellipsis;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,369 @@
<template>
<div :class="{'tags-container': true, focused}" @keydown.stop v-click-outside="onBlur">
<el-select
:popperAppendToBody="false"
:value="appliedTags"
:loading="isLoading"
:placeholder="placeholder"
:filter-method="filterOptions"
@change="onTagsUpdated"
@visible-change="onVisibleChange"
@remove-tag="onRemoveTag"
filterable
multiple
ref="select"
loading-text="..."
popper-class="tags-dropdown"
>
<el-option
v-if="options.length === 0 && filter && createEnabled"
:key="CREATE_KEY"
:value="CREATE_KEY"
class="ops"
ref="create"
>
<font-awesome-icon icon="plus-circle" />
<span>Create tag "{{ filter }}"</span>
</el-option>
<el-option v-else-if="options.length === 0" value="message" disabled>
<span v-if="createEnabled">Type to create a tag</span>
<span v-else-if="allTags.length > 0">No matching tags exist</span>
<span v-else>No tags exist</span>
</el-option>
<!-- key is id+index for keyboard navigation to work well with filter -->
<el-option
v-for="(tag, i) in options"
:value="tag.id"
:key="tag.id + '_' + i"
:label="tag.name"
class="tag"
ref="tag"
/>
<el-option :key="MANAGE_KEY" :value="MANAGE_KEY" class="ops manage-tags">
<font-awesome-icon icon="cog" />
<span>Manage tags</span>
</el-option>
</el-select>
</div>
</template>
<script lang="ts">
import mixins from "vue-typed-mixins";
import { mapGetters } from "vuex";
import { ITag } from "@/Interface";
import { MAX_TAG_NAME_LENGTH } from "@/constants";
import { showMessage } from "@/components/mixins/showMessage";
const MANAGE_KEY = "__manage";
const CREATE_KEY = "__create";
export default mixins(showMessage).extend({
name: "TagsDropdown",
props: ["placeholder", "currentTagIds", "createEnabled", "eventBus"],
data() {
return {
filter: "",
MANAGE_KEY,
CREATE_KEY,
focused: false,
preventUpdate: false,
};
},
mounted() {
const select = this.$refs.select as (Vue | undefined);
if (select) {
const input = select.$refs.input as (Element | undefined);
if (input) {
input.setAttribute('maxlength', `${MAX_TAG_NAME_LENGTH}`);
input.addEventListener('keydown', (e: Event) => {
const keyboardEvent = e as KeyboardEvent;
// events don't bubble outside of select, so need to hook onto input
if (keyboardEvent.key === 'Escape') {
this.$emit('esc');
}
else if (keyboardEvent.key === 'Enter' && this.filter.length === 0) {
this.$data.preventUpdate = true;
this.$emit('blur');
// @ts-ignore
if (this.$refs.select && typeof this.$refs.select.blur === 'function') {
// @ts-ignore
this.$refs.select.blur();
}
}
});
}
}
if (this.$props.eventBus) {
this.$props.eventBus.$on('focus', () => {
this.focusOnInput();
this.focusOnTopOption();
});
}
this.$store.dispatch("tags/fetchAll");
},
computed: {
...mapGetters("tags", ["allTags", "isLoading", "hasTags"]),
options(): ITag[] {
return this.allTags
.filter((tag: ITag) =>
tag && tag.name.toLowerCase().includes(this.$data.filter.toLowerCase()),
);
},
appliedTags(): string[] {
return this.$props.currentTagIds.filter((id: string) =>
this.$store.getters['tags/getTagById'](id),
);
},
},
methods: {
filterOptions(filter = "") {
this.$data.filter = filter.trim();
this.$nextTick(() => this.focusOnTopOption());
},
async onCreate() {
const name = this.$data.filter;
try {
const newTag = await this.$store.dispatch("tags/create", name);
this.$emit("update", [...this.$props.currentTagIds, newTag.id]);
this.$nextTick(() => this.focusOnTag(newTag.id));
this.$data.filter = "";
} catch (error) {
this.$showError(
error,
"New tag was not created",
`A problem occurred when trying to create the "${name}" tag`,
);
}
},
onTagsUpdated(selected: string[]) {
const ops = selected.find(
(value) => value === MANAGE_KEY || value === CREATE_KEY,
);
if (ops === MANAGE_KEY) {
this.$data.filter = "";
this.$store.dispatch("ui/openTagsManagerModal");
} else if (ops === CREATE_KEY) {
this.onCreate();
} else {
setTimeout(() => {
if (!this.$data.preventUpdate) {
this.$emit("update", selected);
}
this.$data.preventUpdate = false;
}, 0);
}
},
focusOnTopOption() {
const tags = this.$refs.tag as Vue[] | undefined;
const create = this.$refs.create as Vue | undefined;
//@ts-ignore // focus on create option
if (create && create.hoverItem) {
// @ts-ignore
create.hoverItem();
}
//@ts-ignore // focus on top option after filter
else if (tags && tags[0] && tags[0].hoverItem) {
// @ts-ignore
tags[0].hoverItem();
// @ts-ignore
if (tags[0] && tags[0].$el && tags[0].$el.scrollIntoView) {
// @ts-ignore
tags[0].$el.scrollIntoView();
}
}
},
focusOnTag(tagId: string) {
const tagOptions = (this.$refs.tag as Vue[]) || [];
if (tagOptions && tagOptions.length) {
const added = tagOptions.find((ref: any) => ref.value === tagId); // tslint:disable-line:no-any
// @ts-ignore // focus on newly created item
if (added && added.$el && added.$el.scrollIntoView && added.hoverItem) {
// @ts-ignore
added.hoverItem();
added.$el.scrollIntoView();
}
}
},
focusOnInput() {
const select = this.$refs.select as Vue;
const input = select && select.$refs.input as HTMLElement;
if (input && input.focus) {
input.focus();
this.focused = true;
}
},
onVisibleChange(visible: boolean) {
if (!visible) {
this.$data.filter = '';
this.focused = false;
}
else {
this.focused = true;
}
},
onRemoveTag() {
this.$nextTick(() => {
this.focusOnInput();
});
},
onBlur() {
this.$emit('blur');
},
},
watch: {
allTags() {
// keep applied tags in sync with store
// for example in case tag is deleted from store
if (this.currentTagIds.length !== this.appliedTags.length) {
this.$emit("update", this.appliedTags);
}
},
},
});
</script>
<style lang="scss" scoped>
$--max-input-height: 60px;
$--border-radius: 20px;
.tags-container {
overflow: hidden;
border: 1px solid transparent;
border-radius: $--border-radius;
&.focused {
border: 1px solid $--color-primary;
}
}
/deep/ .el-select {
.el-select__tags {
max-height: $--max-input-height;
border-radius: $--border-radius;
overflow-y: scroll;
overflow-x: hidden;
// firefox fix for scrollbars
scrollbar-color: $--scrollbar-thumb-color transparent;
}
.el-input.is-focus {
border-radius: $--border-radius;
}
input {
max-height: $--max-input-height;
}
}
</style>
<style lang="scss">
.tags-dropdown {
$--item-font-size: 14px;
$--item-line-height: 18px;
$--item-vertical-padding: 10px;
$--item-horizontal-padding: 20px;
$--item-height: $--item-line-height + $--item-vertical-padding * 2;
$--items-to-show: 7;
$--item-padding: $--item-vertical-padding $--item-horizontal-padding;
$--dropdown-height: $--item-height * $--items-to-show;
$--dropdown-width: 224px;
min-width: $--dropdown-width !important;
max-width: $--dropdown-width;
*,*:after {
box-sizing: border-box;
}
.el-tag {
white-space: normal;
}
.el-scrollbar {
position: relative;
max-height: $--dropdown-height;
> div {
overflow: auto;
margin-bottom: 0 !important;
}
ul {
padding: 0;
max-height: $--dropdown-height - $--item-height;
}
&:after {
content: " ";
display: block;
min-height: $--item-height;
width: $--dropdown-width;
padding: $--item-padding;
}
// override theme scrollbars in safari when overscrolling
::-webkit-scrollbar-thumb {
display: none;
}
}
li {
height: $--item-height;
background-color: white;
padding: $--item-padding;
margin: 0;
line-height: $--item-line-height;
font-weight: 400;
font-size: $--item-font-size;
&.is-disabled {
color: $--custom-font-light;
cursor: default;
}
&.selected {
font-weight: bold;
> span {
display: inline-block;
width: calc(100% - #{$--item-font-size});
overflow: hidden;
text-overflow: ellipsis;
}
&:after { // selected check
font-size: $--item-font-size !important;
}
}
&.ops {
color: $--color-primary;
cursor: pointer;
:first-child {
margin-right: 5px;
}
}
&.tag {
border-top: none;
}
&.manage-tags {
position: absolute;
bottom: 0;
min-width: $--dropdown-width;
}
}
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<div class="container">
<el-col class="notags" :span="16">
<div class="icon">🗄</div>
<div>
<div class="headline">Ready to organize your workflows?</div>
<div class="description">
With workflow tags, you're free to create the perfect tagging system for
your flows
</div>
</div>
<el-button ref="create" @click="$emit('enableCreate')"> Create a tag </el-button>
</el-col>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'NoTagsView',
});
</script>
<style lang="scss" scoped>
$--footer-spacing: 45px;
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: $--tags-manager-min-height - $--footer-spacing;
margin-top: $--footer-spacing;
}
.notags {
word-break: normal;
text-align: center;
> * {
margin-bottom: 32px;
}
}
.icon {
font-size: 36px;
line-height: 14px;
}
.headline {
font-size: 17.6px;
color: black;
margin-bottom: 12px;
}
.description {
font-size: 14px;
line-height: 21px;
}
</style>

View File

@@ -0,0 +1,190 @@
<template>
<Modal
title="Manage tags"
:name="modalName"
:eventBus="modalBus"
@enter="onEnter"
size="md"
>
<template v-slot:content>
<el-row>
<TagsView
v-if="hasTags || isCreating"
:isLoading="isLoading"
:tags="tags"
@create="onCreate"
@update="onUpdate"
@delete="onDelete"
@disableCreate="onDisableCreate"
/>
<NoTagsView
@enableCreate="onEnableCreate"
v-else />
</el-row>
</template>
<template v-slot:footer="{ close }">
<el-button size="small" @click="close">Done</el-button>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from "vue";
import mixins from "vue-typed-mixins";
import { mapGetters } from "vuex";
import { ITag } from "@/Interface";
import { showMessage } from "@/components/mixins/showMessage";
import TagsView from "@/components/TagsManager/TagsView/TagsView.vue";
import NoTagsView from "@/components/TagsManager/NoTagsView.vue";
import Modal from "@/components/Modal.vue";
export default mixins(showMessage).extend({
name: "TagsManager",
created() {
this.$store.dispatch("tags/fetchAll", {force: true, withUsageCount: true});
},
props: ['modalName'],
data() {
const tagIds = (this.$store.getters['tags/allTags'] as ITag[])
.map((tag) => tag.id);
return {
tagIds,
isCreating: false,
modalBus: new Vue(),
};
},
components: {
TagsView,
NoTagsView,
Modal,
},
computed: {
...mapGetters("tags", ["isLoading"]),
tags(): ITag[] {
return this.$data.tagIds.map((tagId: string) => this.$store.getters['tags/getTagById'](tagId))
.filter(Boolean); // if tag is deleted from store
},
hasTags(): boolean {
return this.tags.length > 0;
},
},
methods: {
onEnableCreate() {
this.$data.isCreating = true;
},
onDisableCreate() {
this.$data.isCreating = false;
},
async onCreate(name: string, cb: (tag: ITag | null, error?: Error) => void) {
try {
if (!name) {
throw new Error("Tag name cannot be empty");
}
const newTag = await this.$store.dispatch("tags/create", name);
this.$data.tagIds = [newTag.id].concat(this.$data.tagIds);
cb(newTag);
} catch (error) {
const escapedName = escape(name);
this.$showError(
error,
"New tag was not created",
`A problem occurred when trying to create the "${escapedName}" tag`,
);
cb(null, error);
}
},
async onUpdate(id: string, name: string, cb: (tag: boolean, error?: Error) => void) {
const tag = this.$store.getters['tags/getTagById'](id);
const oldName = tag.name;
try {
if (!name) {
throw new Error("Tag name cannot be empty");
}
if (name === oldName) {
cb(true);
return;
}
const updatedTag = await this.$store.dispatch("tags/rename", { id, name });
cb(!!updatedTag);
const escapedName = escape(name);
const escapedOldName = escape(oldName);
this.$showMessage({
title: "Tag was updated",
message: `The "${escapedOldName}" tag was successfully updated to "${escapedName}"`,
type: "success",
});
} catch (error) {
const escapedName = escape(oldName);
this.$showError(
error,
"Tag was not updated",
`A problem occurred when trying to update the "${escapedName}" tag`,
);
cb(false, error);
}
},
async onDelete(id: string, cb: (deleted: boolean, error?: Error) => void) {
const tag = this.$store.getters['tags/getTagById'](id);
const name = tag.name;
try {
const deleted = await this.$store.dispatch("tags/delete", id);
if (!deleted) {
throw new Error('Could not delete tag');
}
this.$data.tagIds = this.$data.tagIds.filter((tagId: string) => tagId !== id);
cb(deleted);
const escapedName = escape(name);
this.$showMessage({
title: "Tag was deleted",
message: `The "${escapedName}" tag was successfully deleted from your tag collection`,
type: "success",
});
} catch (error) {
const escapedName = escape(name);
this.$showError(
error,
"Tag was not deleted",
`A problem occurred when trying to delete the "${escapedName}" tag`,
);
cb(false, error);
}
},
onEnter() {
if (this.isLoading) {
return;
}
else if (!this.hasTags) {
this.onEnableCreate();
}
else {
this.modalBus.$emit('close');
}
},
},
});
</script>
<style lang="scss" scoped>
.el-row {
min-height: $--tags-manager-min-height;
}
</style>

View File

@@ -0,0 +1,228 @@
<template>
<el-table
stripe
max-height="450"
ref="table"
empty-text="No matching tags exist"
:data="rows"
:span-method="getSpan"
:row-class-name="getRowClasses"
v-loading="isLoading"
>
<el-table-column label="Name">
<template slot-scope="scope">
<div class="name" :key="scope.row.id" @keydown.stop>
<transition name="fade" mode="out-in">
<el-input
v-if="scope.row.create || scope.row.update"
:value="newName"
:maxlength="maxLength"
@input="onNewNameChange"
ref="nameInput"
></el-input>
<span v-else-if="scope.row.delete">
<span>Are you sure you want to delete this tag?</span>
<input ref="deleteHiddenInput" class="hidden" />
</span>
<span v-else :class="{ disabled: scope.row.disable }">
{{ scope.row.tag.name }}
</span>
</transition>
</div>
</template>
</el-table-column>
<el-table-column label="Usage" width="150">
<template slot-scope="scope">
<transition name="fade" mode="out-in">
<div v-if="!scope.row.create && !scope.row.delete" :class="{ disabled: scope.row.disable }">
{{ scope.row.usage }}
</div>
</transition>
</template>
</el-table-column>
<el-table-column>
<template slot-scope="scope">
<transition name="fade" mode="out-in">
<div class="ops" v-if="scope.row.create">
<el-button title="Cancel" @click.stop="cancel" size="small" plain :disabled="isSaving">Cancel</el-button>
<el-button title="Create Tag" @click.stop="apply" size="small" :loading="isSaving">
Create tag
</el-button>
</div>
<div class="ops" v-else-if="scope.row.update">
<el-button title="Cancel" @click.stop="cancel" size="small" plain :disabled="isSaving">Cancel</el-button>
<el-button title="Save Tag" @click.stop="apply" size="small" :loading="isSaving">Save changes</el-button>
</div>
<div class="ops" v-else-if="scope.row.delete">
<el-button title="Cancel" @click.stop="cancel" size="small" plain :disabled="isSaving">Cancel</el-button>
<el-button title="Delete Tag" @click.stop="apply" size="small" :loading="isSaving">Delete tag</el-button>
</div>
<div class="ops main" v-else-if="!scope.row.disable">
<el-button title="Edit Tag" @click.stop="enableUpdate(scope.row)" icon="el-icon-edit" circle></el-button>
<el-button title="Delete Tag" @click.stop="enableDelete(scope.row)" icon="el-icon-delete" circle></el-button>
</div>
</transition>
</template>
</el-table-column>
</el-table>
</template>
<script lang="ts">
import { MAX_TAG_NAME_LENGTH } from "@/constants";
import { ITagRow } from "@/Interface";
import Vue from "vue";
const INPUT_TRANSITION_TIMEOUT = 350;
const DELETE_TRANSITION_TIMEOUT = 100;
export default Vue.extend({
name: "TagsTable",
props: ["rows", "isLoading", "newName", "isSaving"],
data() {
return {
maxLength: MAX_TAG_NAME_LENGTH,
};
},
mounted() {
if (this.$props.rows.length === 1 && this.$props.rows[0].create) {
this.focusOnInput();
}
},
methods: {
getRowClasses: ({ row }: { row: ITagRow }): string => {
return row.disable ? "disabled" : "";
},
getSpan({ row, columnIndex }: { row: ITagRow, columnIndex: number }): number | number[] {
// expand text column with delete message
if (columnIndex === 0 && row.tag && row.delete) {
return [1, 2];
}
// hide usage column on delete
if (columnIndex === 1 && row.tag && row.delete) {
return [0, 0];
}
return 1;
},
enableUpdate(row: ITagRow): void {
if (row.tag) {
this.$emit('updateEnable', row.tag.id);
this.$emit('newNameChange', row.tag.name);
this.focusOnInput();
}
},
enableDelete(row: ITagRow): void {
if (row.tag) {
this.$emit('deleteEnable', row.tag.id);
this.focusOnDelete();
}
},
cancel(): void {
this.$emit('cancelOperation');
},
apply(): void {
this.$emit('applyOperation');
},
onNewNameChange(name: string): void {
this.$emit('newNameChange', name);
},
focusOnInput(): void {
setTimeout(() => {
const input = this.$refs.nameInput as any; // tslint:disable-line:no-any
if (input && input.focus) {
input.focus();
}
}, INPUT_TRANSITION_TIMEOUT);
},
focusOnDelete(): void {
setTimeout(() => {
const input = this.$refs.deleteHiddenInput as any; // tslint:disable-line:no-any
if (input && input.focus) {
input.focus();
}
}, DELETE_TRANSITION_TIMEOUT);
},
focusOnCreate(): void {
((this.$refs.table as Vue).$refs.bodyWrapper as Element).scrollTop = 0;
this.focusOnInput();
},
},
watch: {
rows(newValue: ITagRow[] | undefined) {
if (newValue && newValue[0] && newValue[0].create) {
this.focusOnCreate();
}
},
},
});
</script>
<style lang="scss" scoped>
.name {
min-height: 45px;
display: flex;
align-items: center;
/deep/ input {
border: 1px solid $--color-primary;
background: white;
}
}
.ops {
min-height: 45px;
justify-content: flex-end;
align-items: center;
display: flex;
flex-wrap: nowrap;
> .el-button {
margin: 2px;
}
}
.disabled {
color: #afafaf;
}
.hidden {
position: absolute;
z-index: 0;
opacity: 0;
}
.ops.main > .el-button {
display: none;
float: right;
margin-left: 2px;
}
/deep/ tr.disabled {
pointer-events: none;
}
/deep/ tr:hover .ops:not(.disabled) .el-button {
display: block;
}
/deep/ .el-input.is-disabled > input {
border: none;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<el-row class="tags-header">
<el-col :span="10">
<el-input
placeholder="Search tags"
:value="search"
@input="onSearchChange"
:disabled="disabled"
clearable
:maxlength="maxLength"
>
<i slot="prefix" class="el-input__icon el-icon-search"></i>
</el-input>
</el-col>
<el-col :span="14">
<el-button @click="onAddNew" :disabled="disabled" plain>
<font-awesome-icon icon="plus" />
<div class="next-icon-text">Add new</div>
</el-button>
</el-col>
</el-row>
</template>
<script lang="ts">
import { MAX_TAG_NAME_LENGTH } from "@/constants";
import Vue from "vue";
export default Vue.extend({
props: {
disabled: {
default: false,
},
search: {
default: "",
},
},
data() {
return {
maxLength: MAX_TAG_NAME_LENGTH,
};
},
methods: {
onAddNew() {
this.$emit("createEnable");
},
onSearchChange(search: string) {
this.$emit("searchChange", search);
},
},
});
</script>
<style lang="scss" scoped>
.tags-header {
margin-bottom: 15px;
}
.el-button {
float: right;
}
</style>

View File

@@ -0,0 +1,180 @@
<template>
<div @keyup.enter="applyOperation" @keyup.esc="cancelOperation">
<TagsTableHeader
:search="search"
:disabled="isHeaderDisabled()"
@searchChange="onSearchChange"
@createEnable="onCreateEnable"
/>
<TagsTable
:rows="rows"
:isLoading="isLoading"
:isSaving="isSaving"
:newName="newName"
@newNameChange="onNewNameChange"
@updateEnable="onUpdateEnable"
@deleteEnable="onDeleteEnable"
@cancelOperation="cancelOperation"
@applyOperation="applyOperation"
ref="tagsTable"
/>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import { ITag, ITagRow } from "@/Interface";
import TagsTableHeader from "@/components/TagsManager/TagsView/TagsTableHeader.vue";
import TagsTable from "@/components/TagsManager/TagsView/TagsTable.vue";
const matches = (name: string, filter: string) => name.toLowerCase().trim().includes(filter.toLowerCase().trim());
const getUsage = (count: number | undefined) => count && count > 0 ? `${count} workflow${count > 1 ? "s" : ""}` : 'Not being used';
export default Vue.extend({
components: { TagsTableHeader, TagsTable },
name: "TagsView",
props: ["tags", "isLoading"],
data() {
return {
createEnabled: false,
deleteId: "",
updateId: "",
search: "",
newName: "",
stickyIds: new Set(),
isSaving: false,
};
},
computed: {
isCreateEnabled(): boolean {
return (this.$props.tags || []).length === 0 || this.$data.createEnabled;
},
rows(): ITagRow[] {
const disabled = this.isCreateEnabled || this.$data.updateId || this.$data.deleteId;
const tagRows = (this.$props.tags || [])
.filter((tag: ITag) => this.stickyIds.has(tag.id) || matches(tag.name, this.$data.search))
.map((tag: ITag): ITagRow => ({
tag,
usage: getUsage(tag.usageCount),
disable: disabled && tag.id !== this.deleteId && tag.id !== this.$data.updateId,
update: disabled && tag.id === this.$data.updateId,
delete: disabled && tag.id === this.$data.deleteId,
}));
return this.isCreateEnabled
? [{ create: true }, ...tagRows]
: tagRows;
},
},
methods: {
onNewNameChange(name: string): void {
this.newName = name;
},
onSearchChange(search: string): void {
this.$data.stickyIds.clear();
this.$data.search = search;
},
isHeaderDisabled(): boolean {
return (
this.$props.isLoading ||
!!(this.isCreateEnabled || this.$data.updateId || this.$data.deleteId)
);
},
onUpdateEnable(updateId: string): void {
this.updateId = updateId;
},
disableUpdate(): void {
this.updateId = "";
this.newName = "";
},
updateTag(): void {
this.$data.isSaving = true;
const name = this.newName.trim();
const onUpdate = (updated: boolean) => {
this.$data.isSaving = false;
if (updated) {
this.stickyIds.add(this.updateId);
this.disableUpdate();
}
};
this.$emit("update", this.updateId, name, onUpdate);
},
onDeleteEnable(deleteId: string): void {
this.deleteId = deleteId;
},
disableDelete(): void {
this.deleteId = "";
},
deleteTag(): void {
this.$data.isSaving = true;
const onDelete = (deleted: boolean) => {
if (deleted) {
this.disableDelete();
}
this.$data.isSaving = false;
};
this.$emit("delete", this.deleteId, onDelete);
},
onCreateEnable(): void {
this.$data.createEnabled = true;
this.$data.newName = "";
},
disableCreate(): void {
this.$data.createEnabled = false;
this.$emit("disableCreate");
},
createTag(): void {
this.$data.isSaving = true;
const name = this.$data.newName.trim();
const onCreate = (created: ITag | null, error?: Error) => {
if (created) {
this.stickyIds.add(created.id);
this.disableCreate();
}
this.$data.isSaving = false;
};
this.$emit("create", name, onCreate);
},
applyOperation(): void {
if (this.$data.isSaving) {
return;
}
else if (this.isCreateEnabled) {
this.createTag();
}
else if (this.$data.updateId) {
this.updateTag();
}
else if (this.$data.deleteId) {
this.deleteTag();
}
},
cancelOperation(): void {
if (this.$data.isSaving) {
return;
}
else if (this.isCreateEnabled) {
this.disableCreate();
}
else if (this.$data.updateId) {
this.disableUpdate();
}
else if (this.$data.deleteId) {
this.disableDelete();
}
},
},
});
</script>

View File

@@ -0,0 +1,32 @@
<template>
<span :title="name">
<slot :shortenedName="shortenedName"></slot>
</span>
</template>
<script lang="ts">
import Vue from "vue";
const DEFAULT_WORKFLOW_NAME_LIMIT = 25;
const WORKFLOW_NAME_END_COUNT_TO_KEEP = 4;
export default Vue.extend({
name: "WorkflowNameShort",
props: ["name", "limit"],
computed: {
shortenedName(): string {
const name = this.$props.name;
const limit = this.$props.limit || DEFAULT_WORKFLOW_NAME_LIMIT;
if (name.length <= limit) {
return name;
}
const first = name.slice(0, limit - WORKFLOW_NAME_END_COUNT_TO_KEEP);
const last = name.slice(name.length - WORKFLOW_NAME_END_COUNT_TO_KEEP, name.length);
return `${first}...${last}`;
},
},
});
</script>

View File

@@ -1,44 +1,68 @@
<template>
<span>
<el-dialog :visible="dialogVisible" append-to-body width="80%" title="Open Workflow" :before-close="closeDialog" top="5vh">
<Modal
:name="modalName"
size="xl"
>
<template v-slot:header>
<div class="workflows-header">
<div class="title">
<h1>Open Workflow</h1>
</div>
<div class="tags-filter">
<TagsDropdown
placeholder="Filter by tags..."
:currentTagIds="filterTagIds"
:createEnabled="false"
@update="updateTagsFilter"
@esc="onTagsFilterEsc"
@blur="onTagsFilterBlur"
/>
</div>
<div class="search-filter">
<el-input placeholder="Search workflows..." ref="inputFieldFilter" v-model="filterText">
<i slot="prefix" class="el-input__icon el-icon-search"></i>
</el-input>
</div>
</div>
</template>
<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 v-slot:content>
<el-table class="search-table" :data="filteredWorkflows" stripe @cell-click="openWorkflow" :default-sort = "{prop: 'updatedAt', order: 'descending'}" v-loading="isDataLoading">
<el-table-column property="name" label="Name" class-name="clickable" sortable>
<template slot-scope="scope">
<div :key="scope.row.id">
<span class="name">{{scope.row.name}}</span>
<TagsContainer class="hidden-sm-and-down" :tagIds="getIds(scope.row.tags)" :limit="3" @click="onTagClick" :hoverable="true"/>
</div>
</template>
</el-table-column>
<el-table-column property="createdAt" label="Created" class-name="clickable" width="155" sortable></el-table-column>
<el-table-column property="updatedAt" label="Updated" class-name="clickable" width="155" sortable></el-table-column>
<el-table-column label="Active" width="75">
<template slot-scope="scope">
<workflow-activator :workflow-active="scope.row.active" :workflow-id="scope.row.id" @workflowActiveChanged="workflowActiveChanged" />
</template>
</el-table-column>
</el-table>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import mixins from 'vue-typed-mixins';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { ITag, IWorkflowShortResponse } from '@/Interface';
import { restApi } from '@/components/mixins/restApi';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import { titleChange } from '@/components/mixins/titleChange';
import { IWorkflowShortResponse } from '@/Interface';
import mixins from 'vue-typed-mixins';
import Modal from '@/components/Modal.vue';
import TagsContainer from '@/components/TagsContainer.vue';
import TagsDropdown from '@/components/TagsDropdown.vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
export default mixins(
genericHelpers,
@@ -47,48 +71,63 @@ export default mixins(
workflowHelpers,
).extend({
name: 'WorkflowOpen',
props: [
'dialogVisible',
],
components: {
WorkflowActivator,
TagsContainer,
TagsDropdown,
Modal,
},
props: ['modalName'],
data () {
return {
filterText: '',
isDataLoading: false,
workflows: [] as IWorkflowShortResponse[],
filterTagIds: [] as string[],
prevFilterTagIds: [] as string[],
};
},
computed: {
filteredWorkflows (): IWorkflowShortResponse[] {
return this.workflows.filter((workflow: IWorkflowShortResponse) => {
if (this.filterText === '' || workflow.name.toLowerCase().indexOf(this.filterText.toLowerCase()) !== -1) {
return true;
}
return false;
});
return this.workflows
.filter((workflow: IWorkflowShortResponse) => {
if (this.filterText && !workflow.name.toLowerCase().includes(this.filterText.toLowerCase())) {
return false;
}
if (this.filterTagIds.length === 0) {
return true;
}
if (!workflow.tags || workflow.tags.length === 0) {
return false;
}
return this.filterTagIds.reduce((accu: boolean, id: string) => accu && !!workflow.tags.find(tag => tag.id === id), true);
});
},
},
watch: {
dialogVisible (newValue, oldValue) {
if (newValue) {
this.filterText = '';
this.openDialog();
mounted() {
this.filterText = '';
this.filterTagIds = [];
this.openDialog();
Vue.nextTick(() => {
// Make sure that users can directly type in the filter
(this.$refs.inputFieldFilter as HTMLInputElement).focus();
});
}
},
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;
getIds(tags: ITag[] | undefined) {
return (tags || []).map((tag) => tag.id);
},
updateTagsFilter(tags: string[]) {
this.filterTagIds = tags;
},
onTagClick(tagId: string) {
if (tagId !== 'count' && !this.filterTagIds.includes(tagId)) {
this.filterTagIds.push(tagId);
}
},
async openWorkflow (data: IWorkflowShortResponse, column: any) { // tslint:disable-line:no-any
if (column.label !== 'Active') {
@@ -114,11 +153,19 @@ export default mixins(
} else {
// This is used to avoid duplicating the message
this.$store.commit('setStateDirty', false);
this.$emit('openWorkflow', data.id);
this.$router.push({
name: 'NodeViewExisting',
params: { name: data.id },
});
}
} else {
this.$emit('openWorkflow', data.id);
this.$router.push({
name: 'NodeViewExisting',
params: { name: data.id },
});
}
this.$store.commit('ui/closeTopModal');
}
},
openDialog () {
@@ -149,21 +196,45 @@ export default mixins(
}
}
},
onTagsFilterBlur() {
this.prevFilterTagIds = this.filterTagIds;
},
onTagsFilterEsc() {
// revert last applied tags
this.filterTagIds = this.prevFilterTagIds;
},
},
});
</script>
<style scoped lang="scss">
.workflows-header {
display: flex;
.search-wrapper {
position: absolute;
right: 20px;
top: 20px;
width: 200px;
.title {
flex-grow: 1;
h1 {
font-weight: 600;
line-height: 24px;
font-size: 18px;
}
}
.search-filter {
margin-left: 10px;
min-width: 160px;
}
.tags-filter {
flex-grow: 1;
max-width: 270px;
min-width: 220px;
}
}
.search-table {
margin-top: 2em;
.search-table .name {
font-weight: 400;
margin-right: 10px;
}
</style>

View File

@@ -0,0 +1,40 @@
import Vue from 'vue';
function broadcast(componentName: string, eventName: string, params: any) { // tslint:disable-line:no-any
// @ts-ignore
(this as Vue).$children.forEach(child => {
const name = child.$options.name;
if (name === componentName) {
// @ts-ignore
child.$emit.apply(child, [eventName].concat(params));
} else {
// @ts-ignore
broadcast.apply(child, [componentName, eventName].concat([params]));
}
});
}
export default Vue.extend({
methods: {
$dispatch(componentName: string, eventName: string, params: any) { // tslint:disable-line:no-any
let parent = this.$parent || this.$root;
let name = parent.$options.name;
while (parent && (!name || name !== componentName)) {
parent = parent.$parent;
if (parent) {
name = parent.$options.name;
}
}
if (parent) {
// @ts-ignore
parent.$emit.apply(parent, [eventName].concat(params));
}
},
$broadcast(componentName: string, eventName: string, params: any) { // tslint:disable-line:no-any
broadcast.call(this, componentName, eventName, params);
},
},
});

View File

@@ -1,11 +1,11 @@
import { IExternalHooks } from '@/Interface';
import { IExternalHooks, IRootState } from '@/Interface';
import { IDataObject } from 'n8n-workflow';
import Vue from 'vue';
import { Store } from 'vuex';
export async function runExternalHook(
eventName: string,
store: Store<IDataObject>,
store: Store<IRootState>,
metadata?: IDataObject,
) {
// @ts-ignore

View File

@@ -2,6 +2,7 @@ import dateformat from 'dateformat';
import { showMessage } from '@/components/mixins/showMessage';
import { MessageType } from '@/Interface';
import { debounce } from 'lodash';
import mixins from 'vue-typed-mixins';
@@ -9,6 +10,7 @@ export const genericHelpers = mixins(showMessage).extend({
data () {
return {
loadingService: null as any | null, // tslint:disable-line:no-any
debouncedFunctions: [] as any[], // tslint:disable-line:no-any
};
},
computed: {
@@ -73,6 +75,19 @@ export const genericHelpers = mixins(showMessage).extend({
}
},
async callDebounced (...inputParameters: any[]): Promise<void> { // tslint:disable-line:no-any
const functionName = inputParameters.shift() as string;
const debounceTime = inputParameters.shift() as number;
// @ts-ignore
if (this.debouncedFunctions[functionName] === undefined) {
// @ts-ignore
this.debouncedFunctions[functionName] = debounce(this[functionName], debounceTime, { leading: true });
}
// @ts-ignore
await this.debouncedFunctions[functionName].apply(this, inputParameters);
},
async confirmMessage (message: string, headline: string, type = 'warning' as MessageType, confirmButtonText = 'OK', cancelButtonText = 'Cancel'): Promise<boolean> {
try {
await this.$confirm(message, headline, {

View File

@@ -12,6 +12,7 @@ import { externalHooks } from '@/components/mixins/externalHooks';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import { titleChange } from '@/components/mixins/titleChange';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
@@ -20,6 +21,7 @@ export const pushConnection = mixins(
nodeHelpers,
showMessage,
titleChange,
workflowHelpers,
)
.extend({
data () {
@@ -227,7 +229,7 @@ export const pushConnection = mixins(
runDataExecutedErrorMessage = errorMessage;
this.$titleSet(workflow.name, 'ERROR');
this.$titleSet(workflow.name as string, 'ERROR');
this.$showMessage({
title: 'Problem executing workflow',
message: errorMessage,
@@ -235,7 +237,7 @@ export const pushConnection = mixins(
});
} else {
// Workflow did execute without a problem
this.$titleSet(workflow.name, 'IDLE');
this.$titleSet(workflow.name as string, 'IDLE');
this.$showMessage({
title: 'Workflow got executed',
message: 'Workflow did get executed successfully!',

View File

@@ -30,6 +30,7 @@ import {
INodePropertyOptions,
INodeTypeDescription,
} from 'n8n-workflow';
import { makeRestApiRequest } from '@/api/helpers';
/**
* Unflattens the Execution data.
@@ -55,75 +56,13 @@ function unflattenExecutionData (fullExecutionData: IExecutionFlattedResponse):
return returnData;
}
export class ResponseError 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 ResponseError.
* @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 ResponseError
*/
constructor (message: string, errorCode?: number, httpStatusCode?: number, stack?: string) {
super(message);
this.name = 'ResponseError';
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: Method, 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 ResponseError('API-Server can not be reached. It is probably down.');
}
const errorResponseData = error.response.data;
if (errorResponseData !== undefined && errorResponseData.message !== undefined) {
throw new ResponseError(errorResponseData.message, errorResponseData.code, error.response.status, errorResponseData.stack);
}
throw error;
}
return makeRestApiRequest(self.$store.getters.getRestApiContext, method, endpoint, data);
},
getActiveWorkflows: (): Promise<string[]> => {
return self.restApi().makeRestApiRequest('GET', `/active`);
@@ -179,7 +118,7 @@ export const restApi = Vue.extend({
},
// Creates new credentials
createNewWorkflow: (sendData: IWorkflowData): Promise<IWorkflowDb> => {
createNewWorkflow: (sendData: IWorkflowDataUpdate): Promise<IWorkflowDb> => {
return self.restApi().makeRestApiRequest('POST', `/workflows`, sendData);
},

View File

@@ -28,6 +28,7 @@ import {
IWorkflowDb,
IWorkflowDataUpdate,
XYPositon,
ITag,
} from '../../Interface';
import { externalHooks } from '@/components/mixins/externalHooks';
@@ -238,6 +239,7 @@ export const workflowHelpers = mixins(
connections: workflowConnections,
active: this.$store.getters.isActive,
settings: this.$store.getters.workflowSettings,
tags: this.$store.getters.workflowTags,
};
const workflowId = this.$store.getters.workflowId;
@@ -383,86 +385,43 @@ export const workflowHelpers = mixins(
return returnData['__xxxxxxx__'];
},
// Saves the currently loaded workflow to the database.
async saveCurrentWorkflow (withNewName = false) {
async saveCurrentWorkflow({name, tags}: {name?: string, tags?: string[]} = {}): Promise<boolean> {
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;
}
if (!currentWorkflow) {
return this.saveAsNewWorkflow({name, tags});
}
// Workflow exists already so update it
try {
this.$store.commit('addActiveAction', 'workflowSaving');
let workflowData: IWorkflowData = await this.getWorkflowDataToSave();
const workflowDataRequest: IWorkflowDataUpdate = 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 entry in database
workflowData.name = workflowName!.trim() as string;
if (withNewName === true) {
// If an existing workflow gets resaved with a new name
// make sure that the new ones is not active
workflowData.active = false;
}
workflowData = await this.restApi().createNewWorkflow(workflowData);
this.$store.commit('setActive', workflowData.active || false);
this.$store.commit('setWorkflowId', workflowData.id);
this.$store.commit('setWorkflowName', {newName: workflowData.name, setStateDirty: false});
this.$store.commit('setWorkflowSettings', workflowData.settings || {});
this.$store.commit('setStateDirty', false);
} else {
// Workflow exists already so update it
await this.restApi().updateWorkflow(currentWorkflow, workflowData);
if (name) {
workflowDataRequest.name = name.trim();
}
if (this.$route.params.name !== workflowData.id) {
this.$router.push({
name: 'NodeViewExisting',
params: { name: workflowData.id as string, action: 'workflowSave' },
});
if (tags) {
workflowDataRequest.tags = tags;
}
const workflowData = await this.restApi().updateWorkflow(currentWorkflow, workflowDataRequest);
if (name) {
this.$store.commit('setWorkflowName', {newName: workflowData.name});
}
if (tags) {
const createdTags = (workflowData.tags || []) as ITag[];
const tagIds = createdTags.map((tag: ITag): string => tag.id);
this.$store.commit('setWorkflowTagIds', tagIds);
}
this.$store.commit('removeActiveAction', 'workflowSaving');
this.$store.commit('setStateDirty', false);
this.$showMessage({
title: 'Workflow saved',
message: `The workflow "${workflowData.name}" got saved!`,
type: 'success',
});
this.$store.commit('removeActiveAction', 'workflowSaving');
this.$externalHooks().run('workflow.afterUpdate', { workflowData });
return true;
} catch (e) {
this.$store.commit('removeActiveAction', 'workflowSaving');
@@ -471,6 +430,58 @@ export const workflowHelpers = mixins(
message: `There was a problem saving the workflow: "${e.message}"`,
type: 'error',
});
return false;
}
},
async saveAsNewWorkflow ({name, tags}: {name?: string, tags?: string[]} = {}): Promise<boolean> {
try {
this.$store.commit('addActiveAction', 'workflowSaving');
const workflowDataRequest: IWorkflowDataUpdate = await this.getWorkflowDataToSave();
// make sure that the new ones are not active
workflowDataRequest.active = false;
if (name) {
workflowDataRequest.name = name.trim();
}
if (tags) {
workflowDataRequest.tags = tags;
}
const workflowData = await this.restApi().createNewWorkflow(workflowDataRequest);
this.$store.commit('setActive', workflowData.active || false);
this.$store.commit('setWorkflowId', workflowData.id);
this.$store.commit('setWorkflowName', {newName: workflowData.name, setStateDirty: false});
this.$store.commit('setWorkflowSettings', workflowData.settings || {});
this.$store.commit('setStateDirty', false);
const createdTags = (workflowData.tags || []) as ITag[];
const tagIds = createdTags.map((tag: ITag): string => tag.id);
this.$store.commit('setWorkflowTagIds', tagIds);
this.$router.push({
name: 'NodeViewExisting',
params: { name: workflowData.id as string, action: 'workflowSave' },
});
this.$store.commit('removeActiveAction', 'workflowSaving');
this.$store.commit('setStateDirty', false);
this.$externalHooks().run('workflow.afterUpdate', { workflowData });
return true;
} 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',
});
return false;
}
},

View File

@@ -1,4 +1,22 @@
export const MAX_DISPLAY_DATA_SIZE = 204800;
export const MAX_DISPLAY_ITEMS_AUTO_ALL = 250;
export const NODE_NAME_PREFIX = 'node-';
// workflows
export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__';
export const DEFAULT_NEW_WORKFLOW_NAME = 'My workflow';
export const MIN_WORKFLOW_NAME_LENGTH = 1;
export const MAX_WORKFLOW_NAME_LENGTH = 128;
export const DUPLICATE_POSTFFIX = ' copy';
// tags
export const MAX_TAG_NAME_LENGTH = 24;
export const DUPLICATE_MODAL_KEY = 'duplicate';
export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
export const WORKLOW_OPEN_MODAL_KEY = 'workflowOpen';
export const BREAKPOINT_SM = 768;
export const BREAKPOINT_MD = 992;
export const BREAKPOINT_LG = 1200;
export const BREAKPOINT_XL = 1920;

View File

@@ -19,6 +19,9 @@ import router from './router';
import { runExternalHook } from './components/mixins/externalHooks';
// @ts-ignore
import vClickOutside from 'v-click-outside';
import { library } from '@fortawesome/fontawesome-svg-core';
import {
faAngleDoubleLeft,
@@ -70,6 +73,7 @@ import {
faPlay,
faPlayCircle,
faPlus,
faPlusCircle,
faQuestion,
faQuestionCircle,
faRedo,
@@ -102,6 +106,7 @@ import { store } from './store';
Vue.use(Vue2TouchEvents);
Vue.use(ElementUI, { locale });
Vue.use(vClickOutside);
library.add(faAngleDoubleLeft);
library.add(faAngleDown);
@@ -152,6 +157,7 @@ library.add(faPen);
library.add(faPlay);
library.add(faPlayCircle);
library.add(faPlus);
library.add(faPlusCircle);
library.add(faQuestion);
library.add(faQuestionCircle);
library.add(faRedo);

View File

@@ -0,0 +1,105 @@
import { ActionContext, Module } from 'vuex';
import {
ITag,
ITagsState,
IRootState,
} from '../Interface';
import { createTag, deleteTag, getTags, updateTag } from '../api/tags';
import Vue from 'vue';
const module: Module<ITagsState, IRootState> = {
namespaced: true,
state: {
tags: {},
isLoading: false,
fetchedAll: false,
fetchedUsageCount: false,
},
mutations: {
setLoading: (state: ITagsState, isLoading: boolean) => {
state.isLoading = isLoading;
},
setAllTags: (state: ITagsState, tags: ITag[]) => {
state.tags = tags
.reduce((accu: { [id: string]: ITag }, tag: ITag) => {
accu[tag.id] = tag;
return accu;
}, {});
state.fetchedAll = true;
},
upsertTags(state: ITagsState, tags: ITag[]) {
tags.forEach((tag) => {
const tagId = tag.id;
const currentTag = state.tags[tagId];
if (currentTag) {
const newTag = {
...currentTag,
...tag,
};
Vue.set(state.tags, tagId, newTag);
}
else {
Vue.set(state.tags, tagId, tag);
}
});
},
deleteTag(state: ITagsState, id: string) {
Vue.delete(state.tags, id);
},
},
getters: {
allTags(state: ITagsState): ITag[] {
return Object.values(state.tags)
.sort((a, b) => a.name.localeCompare(b.name));
},
isLoading: (state: ITagsState): boolean => {
return state.isLoading;
},
hasTags: (state: ITagsState): boolean => {
return Object.keys(state.tags).length > 0;
},
getTagById: (state: ITagsState) => {
return (id: string) => state.tags[id];
},
},
actions: {
fetchAll: async (context: ActionContext<ITagsState, IRootState>, params?: { force?: boolean, withUsageCount?: boolean }) => {
const { force = false, withUsageCount = false } = params || {};
if (!force && context.state.fetchedAll && context.state.fetchedUsageCount === withUsageCount) {
return context.state.tags;
}
context.commit('setLoading', true);
const tags = await getTags(context.rootGetters.getRestApiContext, Boolean(withUsageCount));
context.commit('setAllTags', tags);
context.commit('setLoading', false);
return tags;
},
create: async (context: ActionContext<ITagsState, IRootState>, name: string) => {
const tag = await createTag(context.rootGetters.getRestApiContext, { name });
context.commit('upsertTags', [tag]);
return tag;
},
rename: async (context: ActionContext<ITagsState, IRootState>, { id, name }: { id: string, name: string }) => {
const tag = await updateTag(context.rootGetters.getRestApiContext, id, { name });
context.commit('upsertTags', [tag]);
return tag;
},
delete: async (context: ActionContext<ITagsState, IRootState>, id: string) => {
const deleted = await deleteTag(context.rootGetters.getRestApiContext, id);
if (deleted) {
context.commit('deleteTag', id);
context.commit('removeWorkflowTagId', id, {root: true});
}
return deleted;
},
},
};
export default module;

View File

@@ -0,0 +1,67 @@
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY } from '@/constants';
import Vue from 'vue';
import { ActionContext, Module } from 'vuex';
import {
IRootState,
IUiState,
} from '../Interface';
const module: Module<IUiState, IRootState> = {
namespaced: true,
state: {
modals: {
[DUPLICATE_MODAL_KEY]: {
open: false,
},
[TAGS_MANAGER_MODAL_KEY]: {
open: false,
},
[WORKLOW_OPEN_MODAL_KEY]: {
open: false,
},
},
modalStack: [],
sidebarMenuCollapsed: true,
isPageLoading: true,
},
getters: {
isModalOpen: (state: IUiState) => {
return (name: string) => state.modals[name].open;
},
isModalActive: (state: IUiState) => {
return (name: string) => state.modalStack.length > 0 && name === state.modalStack[0];
},
anyModalsOpen: (state: IUiState) => {
return state.modalStack.length > 0;
},
sidebarMenuCollapsed: (state: IUiState): boolean => state.sidebarMenuCollapsed,
},
mutations: {
openModal: (state: IUiState, name: string) => {
Vue.set(state.modals[name], 'open', true);
state.modalStack = [name].concat(state.modalStack);
},
closeTopModal: (state: IUiState) => {
const name = state.modalStack[0];
Vue.set(state.modals[name], 'open', false);
state.modalStack = state.modalStack.slice(1);
},
toggleSidebarMenuCollapse: (state: IUiState) => {
state.sidebarMenuCollapsed = !state.sidebarMenuCollapsed;
},
},
actions: {
openTagsManagerModal: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', TAGS_MANAGER_MODAL_KEY);
},
openWorklfowOpenModal: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', WORKLOW_OPEN_MODAL_KEY);
},
openDuplicateModal: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', DUPLICATE_MODAL_KEY);
},
},
};
export default module;

View File

@@ -0,0 +1,48 @@
import { getNewWorkflow } from '@/api/workflows';
import { DUPLICATE_POSTFFIX, MAX_WORKFLOW_NAME_LENGTH, DEFAULT_NEW_WORKFLOW_NAME } from '@/constants';
import { ActionContext, Module } from 'vuex';
import {
IRootState,
IWorkflowsState,
} from '../Interface';
const module: Module<IWorkflowsState, IRootState> = {
namespaced: true,
state: {},
actions: {
setNewWorkflowName: async (context: ActionContext<IWorkflowsState, IRootState>): Promise<void> => {
let newName = '';
try {
const newWorkflow = await getNewWorkflow(context.rootGetters.getRestApiContext);
newName = newWorkflow.name;
}
catch (e) {
// in case of error, default to original name
newName = DEFAULT_NEW_WORKFLOW_NAME;
}
context.commit('setWorkflowName', { newName }, { root: true });
},
getDuplicateCurrentWorkflowName: async (context: ActionContext<IWorkflowsState, IRootState>): Promise<string> => {
const currentWorkflowName = context.rootGetters.workflowName;
if (currentWorkflowName && (currentWorkflowName.length + DUPLICATE_POSTFFIX.length) >= MAX_WORKFLOW_NAME_LENGTH) {
return currentWorkflowName;
}
let newName = `${currentWorkflowName}${DUPLICATE_POSTFFIX}`;
try {
const newWorkflow = await getNewWorkflow(context.rootGetters.getRestApiContext, newName );
newName = newWorkflow.name;
}
catch (e) {
}
return newName;
},
},
};
export default module;

View File

@@ -6,6 +6,8 @@ $--color-primary-light: #fbebed;
$--custom-dialog-text-color: #666;
$--custom-dialog-background: #fff;
$--custom-font-black: #000;
$--custom-font-dark: #595e67;
$--custom-font-light: #777;
$--custom-font-very-light: #999;
@@ -21,6 +23,7 @@ $--custom-error-text : #eb2222;
$--custom-running-background : #ffffe5;
$--custom-running-text : #eb9422;
$--custom-success-background : #e3f0e4;
$--custom-success-text-light: #2f4;
$--custom-success-text : #40c351;
$--custom-warning-background : #ffffe5;
$--custom-warning-text : #eb9422;
@@ -28,14 +31,35 @@ $--custom-warning-text : #eb9422;
$--custom-node-view-background : #faf9fe;
// Table
$--custom-table-background-main: $--custom-header-background ;
$--custom-table-background-alternative: #f5f5f5;
$--custom-table-background-alternative2: lighten($--custom-table-background-main, 60% );
$--custom-table-background-main: $--custom-header-background;
$--custom-table-background-stripe-color: #f6f6f6;
$--custom-table-background-hover-color: #e9f0f4;
$--custom-input-background: #f0f0f0;
$--custom-input-background-disabled: #ccc;
$--custom-input-font: #333;
$--custom-input-border-color: #dcdfe6;
$--custom-input-font-disabled: #555;
$--custom-input-border-shadow: 1px solid $--custom-input-border-color;
$--header-height: 65px;
$--sidebar-width: 65px;
$--sidebar-expanded-width: 200px;
$--tags-manager-min-height: 300px;
// based on element.io breakpoints
$--breakpoint-xs: 768px;
$--breakpoint-sm: 992px;
$--breakpoint-md: 1200px;
$--breakpoint-lg: 1920px;
// scrollbars
$--scrollbar-thumb-color: lighten($--color-primary, 20%);
// tags
$--tag-background-color: #dce1e9;
$--tag-text-color: #3d3f46;
$--tag-close-background-color: #717782;
$--tag-close-background-hover-color: #3d3f46;
$--table-row-hover-background: lighten( $--custom-table-background-alternative, 15% );
$--table-current-row-background: $--table-row-hover-background;

View File

@@ -6,6 +6,7 @@
$--font-path: '~element-ui/lib/theme-chalk/fonts';
@import "~element-ui/packages/theme-chalk/src/index";
@import "~element-ui/lib/theme-chalk/display.css";
body {
font-family: 'Open Sans', sans-serif;
@@ -197,14 +198,14 @@ h1, h2, h3, h4, h5, h6 {
.el-table--striped {
.el-table__body {
tr.el-table__row--striped {
background-color: $--custom-table-background-alternative;
background-color: $--custom-table-background-stripe-color;
td {
background: none;
}
}
tr.el-table__row:hover,
tr.el-table__row:hover > td {
background-color: $--custom-table-background-alternative2;
background-color: $--custom-table-background-hover-color;
}
}
}
@@ -327,7 +328,7 @@ h1, h2, h3, h4, h5, h6 {
}
.el-input-number__decrease.is-disabled,
.el-input-number__increase.is-disabled {
background-color: $--custom-table-background-alternative2;
background-color: $--custom-input-background-disabled;
}
}
@@ -384,7 +385,11 @@ h1, h2, h3, h4, h5, h6 {
border-color: #555;
color: $--custom-input-font-disabled;
}
.el-button.is-plain,.el-button.is-plain:hover {
color: $--color-primary;
border: 1px solid $--color-primary;
background-color: #fff;
}
// Textarea
.ql-editor,
@@ -477,7 +482,7 @@ h1, h2, h3, h4, h5, h6 {
}
::-webkit-scrollbar-thumb {
border-radius: 6px;
background: lighten($--color-primary, 20%);
background: $--scrollbar-thumb-color;
}
::-webkit-scrollbar-thumb:hover {
background: $--color-primary;
@@ -493,3 +498,28 @@ h1, h2, h3, h4, h5, h6 {
border-radius: 6px;
}
}
.tags-container {
.el-tag {
color: $--tag-text-color;
font-size: 12px;
background-color: $--tag-background-color;
border-radius: 12px;
height: auto;
border-color: $--tag-background-color;
font-weight: 400;
.el-icon-close {
color: $--tag-background-color;
background-color: $--tag-close-background-color !important;
max-height: 15px;
max-width: 15px;
margin-right: 6px;
&:hover {
background-color: $--tag-close-background-hover-color !important;
}
}
}
}

View File

@@ -1,6 +1,6 @@
import Vue from 'vue';
import Router from 'vue-router';
import MainHeader from '@/components/MainHeader.vue';
import MainHeader from '@/components/MainHeader/MainHeader.vue';
import MainSidebar from '@/components/MainSidebar.vue';
import NodeView from '@/views/NodeView.vue';

View File

@@ -21,6 +21,7 @@ import {
ICredentialsResponse,
IExecutionResponse,
IExecutionsCurrentSummaryExtended,
IRootState,
IMenuItem,
INodeUi,
INodeUpdatePropertiesInformation,
@@ -29,59 +30,74 @@ import {
IUpdateInformation,
IWorkflowDb,
XYPositon,
IRestApiContext,
} from './Interface';
import tags from './modules/tags';
import ui from './modules/ui';
import workflows from './modules/workflows';
Vue.use(Vuex);
const state: IRootState = {
activeExecutions: [],
activeWorkflows: [],
activeActions: [],
activeNode: null,
// @ts-ignore
baseUrl: process.env.VUE_APP_URL_BASE_API ? process.env.VUE_APP_URL_BASE_API : (window.BASE_PATH === '/%BASE_PATH%/' ? '/' : window.BASE_PATH),
credentials: null,
credentialTypes: null,
endpointWebhook: 'webhook',
endpointWebhookTest: 'webhook-test',
executionId: null,
executingNode: '',
executionWaitingForWebhook: false,
pushConnectionActive: true,
saveDataErrorExecution: 'all',
saveDataSuccessExecution: 'all',
saveManualExecutions: false,
timezone: 'America/New_York',
stateIsDirty: false,
executionTimeout: -1,
maxExecutionTimeout: Number.MAX_SAFE_INTEGER,
versionCli: '0.0.0',
oauthCallbackUrls: {},
n8nMetadata: {},
workflowExecutionData: null,
lastSelectedNode: null,
lastSelectedNodeOutputIndex: null,
nodeIndex: [],
nodeTypes: [],
nodeViewOffsetPosition: [0, 0],
nodeViewMoveInProgress: false,
selectedNodes: [],
sessionId: Math.random().toString(36).substring(2, 15),
urlBaseWebhook: 'http://localhost:5678/',
workflow: {
id: PLACEHOLDER_EMPTY_WORKFLOW_ID,
name: '',
active: false,
createdAt: -1,
updatedAt: -1,
connections: {},
nodes: [],
settings: {},
tags: [],
},
sidebarMenuItems: [],
};
const modules = {
tags,
ui,
workflows,
};
export const store = new Vuex.Store({
strict: process.env.NODE_ENV !== 'production',
state: {
activeExecutions: [] as IExecutionsCurrentSummaryExtended[],
activeWorkflows: [] as string[],
activeActions: [] as string[],
activeNode: null as string | null,
// @ts-ignore
baseUrl: process.env.VUE_APP_URL_BASE_API ? process.env.VUE_APP_URL_BASE_API : (window.BASE_PATH === '/%BASE_PATH%/' ? '/' : window.BASE_PATH),
credentials: null as ICredentialsResponse[] | null,
credentialTypes: null as ICredentialType[] | null,
endpointWebhook: 'webhook',
endpointWebhookTest: 'webhook-test',
executionId: null as string | null,
executingNode: '' as string | null,
executionWaitingForWebhook: false,
pushConnectionActive: false,
saveDataErrorExecution: 'all',
saveDataSuccessExecution: 'all',
saveManualExecutions: false,
timezone: 'America/New_York',
stateIsDirty: false,
executionTimeout: -1,
maxExecutionTimeout: Number.MAX_SAFE_INTEGER,
versionCli: '0.0.0',
oauthCallbackUrls: {},
n8nMetadata: {},
workflowExecutionData: null as IExecutionResponse | null,
lastSelectedNode: null as string | null,
lastSelectedNodeOutputIndex: null as number | null,
nodeIndex: [] as Array<string | null>,
nodeTypes: [] as INodeTypeDescription[],
nodeViewOffsetPosition: [0, 0] as XYPositon,
nodeViewMoveInProgress: false,
selectedNodes: [] as INodeUi[],
sessionId: Math.random().toString(36).substring(2, 15),
urlBaseWebhook: 'http://localhost:5678/',
workflow: {
id: PLACEHOLDER_EMPTY_WORKFLOW_ID,
name: '',
active: false,
createdAt: -1,
updatedAt: -1,
connections: {} as IConnections,
nodes: [] as INodeUi[],
settings: {} as IWorkflowSettings,
} as IWorkflowDb,
sidebarMenuItems: [] as IMenuItem[],
},
modules,
state,
mutations: {
// Active Actions
addActiveAction (state, action: string) {
@@ -565,6 +581,17 @@ export const store = new Vuex.Store({
Vue.set(state.workflow, 'settings', workflowSettings);
},
setWorkflowTagIds (state, tags: string[]) {
Vue.set(state.workflow, 'tags', tags);
},
removeWorkflowTagId (state, tagId: string) {
const tags = state.workflow.tags as string[];
const updated = tags.filter((id: string) => id !== tagId);
Vue.set(state.workflow, 'tags', updated);
},
// Workflow
setWorkflow (state, workflow: IWorkflowDb) {
Vue.set(state, 'workflow', workflow);
@@ -625,6 +652,16 @@ export const store = new Vuex.Store({
}
return `${state.baseUrl}${endpoint}`;
},
getRestApiContext(state): IRestApiContext {
let endpoint = 'rest';
if (process.env.VUE_APP_ENDPOINT_REST) {
endpoint = process.env.VUE_APP_ENDPOINT_REST;
}
return {
baseUrl: `${state.baseUrl}${endpoint}`,
sessionId: state.sessionId,
};
},
getWebhookBaseUrl: (state): string => {
return state.urlBaseWebhook;
},
@@ -818,6 +855,10 @@ export const store = new Vuex.Store({
return state.workflow.settings;
},
workflowTags: (state): string[] => {
return state.workflow.tags as string[];
},
// Workflow Result Data
getWorkflowExecution: (state): IExecutionResponse | null => {
return state.workflowExecutionData;
@@ -845,22 +886,4 @@ export const store = new Vuex.Store({
return state.sidebarMenuItems;
},
},
});
// import Vue from 'vue';
// import Vuex from 'vuex';
// Vue.use(Vuex)
// export default new Vuex.Store({
// state: {
// },
// mutations: {
// },
// actions: {
// }
// });

View File

@@ -38,7 +38,7 @@
@nodeTypeSelected="nodeTypeSelected"
@closeNodeCreator="closeNodeCreator"
></node-creator>
<div class="zoom-menu">
<div :class="{ 'zoom-menu': true, expanded: !sidebarMenuCollapsed }">
<button @click="setZoom('in')" class="button-white" title="Zoom In">
<font-awesome-icon icon="search-plus"/>
</button>
@@ -102,6 +102,7 @@
<font-awesome-icon icon="trash" class="clear-execution-icon" />
</el-button>
</div>
<Modals />
</div>
</template>
@@ -126,6 +127,7 @@ import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { workflowRun } from '@/components/mixins/workflowRun';
import DataDisplay from '@/components/DataDisplay.vue';
import Modals from '@/components/Modals.vue';
import Node from '@/components/Node.vue';
import NodeCreator from '@/components/NodeCreator.vue';
import NodeSettings from '@/components/NodeSettings.vue';
@@ -133,7 +135,6 @@ import RunData from '@/components/RunData.vue';
import mixins from 'vue-typed-mixins';
import { v4 as uuidv4} from 'uuid';
import { debounce } from 'lodash';
import axios from 'axios';
import {
IConnection,
@@ -163,7 +164,9 @@ import {
IWorkflowDataUpdate,
XYPositon,
IPushDataExecutionFinished,
ITag,
} from '../Interface';
import { mapGetters } from 'vuex';
export default mixins(
copyPaste,
@@ -181,6 +184,7 @@ export default mixins(
name: 'NodeView',
components: {
DataDisplay,
Modals,
Node,
NodeCreator,
NodeSettings,
@@ -234,6 +238,9 @@ export default mixins(
}
},
computed: {
...mapGetters('ui', [
'sidebarMenuCollapsed',
]),
activeNode (): INodeUi | null {
return this.$store.getters.activeNode;
},
@@ -303,7 +310,6 @@ export default mixins(
lastClickPosition: [450, 450] as XYPositon,
nodeViewScale: 1,
ctrlKeyPressed: false,
debouncedFunctions: [] as any[], // tslint:disable-line:no-any
stopExecutionInProgress: false,
};
},
@@ -314,18 +320,6 @@ export default mixins(
document.removeEventListener('keyup', this.keyUp);
},
methods: {
async callDebounced (...inputParameters: any[]): Promise<void> { // tslint:disable-line:no-any
const functionName = inputParameters.shift() as string;
const debounceTime = inputParameters.shift() as number;
// @ts-ignore
if (this.debouncedFunctions[functionName] === undefined) {
// @ts-ignore
this.debouncedFunctions[functionName] = debounce(this[functionName], debounceTime, { leading: true });
}
// @ts-ignore
await this.debouncedFunctions[functionName].apply(this, inputParameters);
},
clearExecutionData () {
this.$store.commit('setWorkflowExecutionData', null);
this.updateNodesExecutionIssues();
@@ -378,6 +372,12 @@ export default mixins(
this.$store.commit('setWorkflowName', {newName: data.name, setStateDirty: false});
this.$store.commit('setWorkflowSettings', data.settings || {});
const tags = (data.tags || []) as ITag[];
this.$store.commit('tags/upsertTags', tags);
const tagIds = tags.map((tag) => tag.id);
this.$store.commit('setWorkflowTagIds', tagIds || []);
await this.addNodes(data.nodes, data.connections);
this.$store.commit('setStateDirty', false);
@@ -440,6 +440,10 @@ export default mixins(
return;
}
}
const anyModalsOpen = this.$store.getters['ui/anyModalsOpen'];
if (anyModalsOpen) {
return;
}
if (e.key === 'd') {
this.callDebounced('deactivateSelectedNode', 350);
@@ -485,7 +489,7 @@ export default mixins(
e.stopPropagation();
e.preventDefault();
this.$root.$emit('openWorkflowDialog');
this.$store.dispatch('ui/openWorklfowOpenModal');
} else if (e.key === 'n' && this.isCtrlKeyPressed(e) === true && e.altKey === true) {
// Create a new workflow
e.stopPropagation();
@@ -503,7 +507,9 @@ export default mixins(
e.stopPropagation();
e.preventDefault();
this.$store.commit('setStateDirty', false);
if (this.isReadOnly) {
return;
}
this.callDebounced('saveCurrentWorkflow', 1000);
} else if (e.key === 'Enter') {
@@ -1392,6 +1398,8 @@ export default mixins(
},
async newWorkflow (): Promise<void> {
await this.resetWorkspace();
await this.$store.dispatch('workflows/setNewWorkflowName');
this.$store.commit('setStateDirty', false);
// Create start node
const defaultNodes = [
@@ -1440,6 +1448,9 @@ export default mixins(
}
if (workflowId !== null) {
const workflow = await this.restApi().getWorkflow(workflowId);
if (!workflow) {
throw new Error('Could not find workflow');
}
this.$titleSet(workflow.name, 'IDLE');
// Open existing workflow
await this.openWorkflow(workflowId);
@@ -1988,6 +1999,7 @@ export default mixins(
this.$store.commit('setWorkflowId', PLACEHOLDER_EMPTY_WORKFLOW_ID);
this.$store.commit('setWorkflowName', {newName: '', setStateDirty: false});
this.$store.commit('setWorkflowSettings', {});
this.$store.commit('setWorkflowTagIds', []);
this.$store.commit('setActiveExecutionId', null);
this.$store.commit('setExecutingNode', null);
@@ -2097,14 +2109,20 @@ export default mixins(
<style scoped lang="scss">
.zoom-menu {
$--zoom-menu-margin: 5;
position: fixed;
left: 70px;
left: $--sidebar-width + $--zoom-menu-margin;
width: 200px;
bottom: 45px;
line-height: 25px;
z-index: 18;
color: #444;
padding-right: 5px;
&.expanded {
left: $--sidebar-expanded-width + $--zoom-menu-margin;
}
}
.node-creator-button {