mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-19 02:51:14 +00:00
✨ 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:
@@ -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;
|
||||
}
|
||||
|
||||
76
packages/editor-ui/src/api/helpers.ts
Normal file
76
packages/editor-ui/src/api/helpers.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
18
packages/editor-ui/src/api/tags.ts
Normal file
18
packages/editor-ui/src/api/tags.ts
Normal 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}`);
|
||||
}
|
||||
6
packages/editor-ui/src/api/workflows.ts
Normal file
6
packages/editor-ui/src/api/workflows.ts
Normal 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 } : {});
|
||||
}
|
||||
101
packages/editor-ui/src/components/BreakpointsObserver.vue
Normal file
101
packages/editor-ui/src/components/BreakpointsObserver.vue
Normal 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>
|
||||
125
packages/editor-ui/src/components/DuplicateWorkflowDialog.vue
Normal file
125
packages/editor-ui/src/components/DuplicateWorkflowDialog.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
100
packages/editor-ui/src/components/InlineTextEdit.vue
Normal file
100
packages/editor-ui/src/components/InlineTextEdit.vue
Normal 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>
|
||||
30
packages/editor-ui/src/components/IntersectionObserved.vue
Normal file
30
packages/editor-ui/src/components/IntersectionObserved.vue
Normal 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>
|
||||
56
packages/editor-ui/src/components/IntersectionObserver.vue
Normal file
56
packages/editor-ui/src/components/IntersectionObserver.vue
Normal 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>
|
||||
@@ -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>
|
||||
<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" />
|
||||
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>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<span class="title">
|
||||
Execution Id:
|
||||
<span>
|
||||
<strong>{{ executionId }}</strong
|
||||
>
|
||||
<font-awesome-icon
|
||||
icon="check"
|
||||
class="execution-icon success"
|
||||
v-if="executionFinished"
|
||||
title="Execution was successful"
|
||||
/>
|
||||
<font-awesome-icon
|
||||
icon="times"
|
||||
class="execution-icon error"
|
||||
v-else
|
||||
title="Execution 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>
|
||||
@@ -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>
|
||||
94
packages/editor-ui/src/components/MainHeader/MainHeader.vue
Normal file
94
packages/editor-ui/src/components/MainHeader/MainHeader.vue
Normal 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>
|
||||
279
packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue
Normal file
279
packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
115
packages/editor-ui/src/components/Modal.vue
Normal file
115
packages/editor-ui/src/components/Modal.vue
Normal 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>
|
||||
24
packages/editor-ui/src/components/ModalRoot.vue
Normal file
24
packages/editor-ui/src/components/ModalRoot.vue
Normal 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>
|
||||
53
packages/editor-ui/src/components/Modals.vue
Normal file
53
packages/editor-ui/src/components/Modals.vue
Normal 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>
|
||||
29
packages/editor-ui/src/components/PushConnectionTracker.vue
Normal file
29
packages/editor-ui/src/components/PushConnectionTracker.vue
Normal 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" /> 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>
|
||||
@@ -762,7 +762,7 @@ export default mixins(
|
||||
background: #fff;;
|
||||
}
|
||||
tr:nth-child(odd) {
|
||||
background: $--custom-table-background-alternative;
|
||||
background: $--custom-table-background-stripe-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
65
packages/editor-ui/src/components/SaveWorkflowButton.vue
Normal file
65
packages/editor-ui/src/components/SaveWorkflowButton.vue
Normal 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>
|
||||
151
packages/editor-ui/src/components/TagsContainer.vue
Normal file
151
packages/editor-ui/src/components/TagsContainer.vue
Normal 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>
|
||||
369
packages/editor-ui/src/components/TagsDropdown.vue
Normal file
369
packages/editor-ui/src/components/TagsDropdown.vue
Normal 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>
|
||||
61
packages/editor-ui/src/components/TagsManager/NoTagsView.vue
Normal file
61
packages/editor-ui/src/components/TagsManager/NoTagsView.vue
Normal 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>
|
||||
190
packages/editor-ui/src/components/TagsManager/TagsManager.vue
Normal file
190
packages/editor-ui/src/components/TagsManager/TagsManager.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
32
packages/editor-ui/src/components/WorkflowNameShort.vue
Normal file
32
packages/editor-ui/src/components/WorkflowNameShort.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
40
packages/editor-ui/src/components/mixins/emitter.ts
Normal file
40
packages/editor-ui/src/components/mixins/emitter.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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!',
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
105
packages/editor-ui/src/modules/tags.ts
Normal file
105
packages/editor-ui/src/modules/tags.ts
Normal 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;
|
||||
67
packages/editor-ui/src/modules/ui.ts
Normal file
67
packages/editor-ui/src/modules/ui.ts
Normal 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;
|
||||
48
packages/editor-ui/src/modules/workflows.ts
Normal file
48
packages/editor-ui/src/modules/workflows.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
// }
|
||||
// });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user