mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
feat(editor): implement executions preview via the new executions tab in node view (#4311)
* ✨ Added main header tabs with current workflow execution count * ⚡ feat(editor): header tab navigation (no-changelog) (#4244) * ✨ Adding current workflow execution list to the Vuex store * ✨ Updating current workflow executions after running a workflow from the node view * ✨ Keeping the tab view content alive when switching tabs in main header * ✨ Updating main header controls to work with current workflow regardless of active tab * 🐛 Fixing a bug with previous WF executions still visible after creating a new WF * ⚡ Updating saved status when new WF is created * ✨ Implemented initial version of execution perview * ✨ Keeping the WF view alive when switching to executions tab in new navigation * ✨ Implemented executions landing page * ✨ Simplifying node view navigation * ✨ Updating executions view zoom and selection to work with the new layout * ✨ Using N8nRadioButtons component for main header tabs * 💄 Implementing executions page states. Minor refactoring. * ⚡ Merge conflict fixes and pieces of code that were left behind * ⚡ Fixing layout and scrolling changes introduced after sync with master branch * ⚡ Removing keep-alive from node view which broke template opening and some more leftover code * ✔️ Fixing linting errors * ✔️ One more lint error * ⚡ Implemented executions preview using iframes * ⚡ Fixing zoom menu positioning in iframe and adding different loading types to workflow preview * ⚡ Fixing navigation to and from WF templates and template loading * ⚡ Updating and fixing navigation to and from node view * 👌 Addressing previous PR comments * 🐛 Fixing infinite loading when saving a new workflow * 🐛 Handling opening already opened WF when not on Node view * ✨ Implemented empty states for executions view * ⚡ Adding execute button shake flag to the store so it doesn't mess up navigation by modifying route params * 💄 Started adding new styles to execution sidebar * 💄 Adding hover style for execution list * ⚡ Added ExecutionsCard component and added executions helper mixin * ✔️ Fixing leftover conflict * ✔️ One more conflict * ✨ Implemented retry execution menu and manual execution icon. Other minor updates * ✨ Implemented executions filtering * 💄 Updating running executions details in preview * ⚡ Added info accordion to executions sidebar * ✨ Implemented auto-refresh for executions sidebar * 💄 Adding running execution landing page, minor fixes * 💄 General refactoring * ✔️ Adding leftover conflict changes * ✔️ Updating `InfoTip` component test snapshots * ✔️ Fixing linting error * ✔️ Fixing lint errors in vuex store module * 👌 Started addressing review feedback * ⚡ Updating executions preview behaviour when filters are applied * 🐛 Fixing a bug where nodes and connections disappear if something is saved from executions view before loading WF in the main NodeView * 🐛 Fixing pasting in executions view and wrong workflow activator state * ⚡ Improved workflow switching and navigation, updated error message when trying to paste into execution * ⚡ Some more navigation updates * 💄 Fixing tab centering, execution filter button layout, added auto-refresh checkbox * 🐛 Fixing a bug when saving workflow using save button * 💄 Addressing design feedback, added delete execution button * ⚡ Moving main execution logic to the root executions view * ⚡ Implemented execution delete function * ⚡ Updating how switching tabs for new unsaved workflows work * ⚡ Remembering active execution when switching tabs * 💄 Addressing design feedback regarding info accordion * 💄 Updating execution card styling * ⚡ Resetting executions when creating new workflow * Fixing lint error * ⚡ Hiding executions preview is active execution is not in the results. Updated execution list spacing * ⚡ Fixing navigation to and from templates and executions * ⚡ Implemented execution lazy loading and added new background to execution preview * 💄 Disabling import when on executions tab * ⚡ Handling opening executions from different workflow * ⚡ Updating active execution on route change * ⚡ Updating execution tab detection * ⚡ Simplifying and updating navigation. Adding new route for new workflows * ⚡ Updating workflow saving logic to work with new routes * 🐛 Fixing a bug when returning to executions from different workflow * 💄 Updating executions info accordion and node details view modal in execution preview * 💄 Updating workflow activated modal to point to new executions view * ⚡ Implemented opening new executions view from execution modal * ⚡ Handling jsplumb init errors, updating unknown executions style * ⚡ Updating main sidebar after syncing branch * ⚡ Opening new trigger menu from executions view * 💄 Updating sidebar resize behaviour * ✔️ Fixing lint errors * ⚡ Loading executions when mounting executions view * ⚡ Resetting execution data when creating a new workflow * 💄 Minor wording updates * ⚡ Not reloading node view when new workflows are saved * Removing leftover console log * 🐛 Fixed a bug with save dialog not appearing when leaving executions tab * ⚡ Updating manual execution settings detection in info accordion * 💄 Addressing UI issues found during bug bash * Fixing workflow saving logic * ⚡ Preventing navigation if clicked tab is already opened * ⚡ Updating lazy loading behaviour * ⚡ Updating delete executions flow * ⚡ Added retry executions button to the execution preview * ⚡ Adding empty execution state, updating trigger detection logic, removing listeners when node view is not active * 💄 Cosmetic code improvements * ⚡ Trying the performance fix for nodeBase * ⚡ Removing the `NodeBase`fix * 🐛 Fixing a bug when saving the current workflow * 👌 Addressing code review feedback
This commit is contained in:
parent
99157cf581
commit
d833345092
24
package-lock.json
generated
24
package-lock.json
generated
|
@ -46646,7 +46646,7 @@
|
|||
"@oclif/errors": "^1.3.6",
|
||||
"@oclif/parser": "^3.8.8",
|
||||
"debug": "^4.3.4",
|
||||
"globby": "^11.0.2",
|
||||
"globby": "^11.1.0",
|
||||
"is-wsl": "^2.1.1",
|
||||
"tslib": "^2.3.1"
|
||||
},
|
||||
|
@ -46672,10 +46672,10 @@
|
|||
"clean-stack": "^3.0.1",
|
||||
"cli-progress": "^3.10.0",
|
||||
"debug": "^4.3.4",
|
||||
"ejs": "^3.1.8",
|
||||
"ejs": "^3.1.6",
|
||||
"fs-extra": "^9.1.0",
|
||||
"get-package-type": "^0.1.0",
|
||||
"globby": "^11.0.2",
|
||||
"globby": "^11.1.0",
|
||||
"hyperlinker": "^1.0.0",
|
||||
"indent-string": "^4.0.0",
|
||||
"is-wsl": "^2.2.0",
|
||||
|
@ -47028,7 +47028,7 @@
|
|||
"@oclif/errors": "^1.3.3",
|
||||
"@oclif/parser": "^3.8.0",
|
||||
"debug": "^4.1.1",
|
||||
"globby": "^11.0.2",
|
||||
"globby": "^11.0.1",
|
||||
"is-wsl": "^2.1.1",
|
||||
"tslib": "^2.0.0"
|
||||
}
|
||||
|
@ -47534,7 +47534,7 @@
|
|||
"css-loader": "^3.6.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"find-up": "^5.0.0",
|
||||
"fork-ts-checker-webpack-plugin": "^6.0.4",
|
||||
"fork-ts-checker-webpack-plugin": "^4.1.6",
|
||||
"glob": "^7.1.6",
|
||||
"glob-promise": "^3.4.0",
|
||||
"global": "^4.4.0",
|
||||
|
@ -50091,7 +50091,7 @@
|
|||
"@typescript-eslint/types": "5.40.0",
|
||||
"@typescript-eslint/visitor-keys": "5.40.0",
|
||||
"debug": "^4.3.4",
|
||||
"globby": "^11.0.2",
|
||||
"globby": "^11.1.0",
|
||||
"is-glob": "^4.0.3",
|
||||
"semver": "^7.3.7",
|
||||
"tsutils": "^3.21.0"
|
||||
|
@ -51409,7 +51409,7 @@
|
|||
"integrity": "sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"browserslist": "^4.21.3",
|
||||
"browserslist": "^4.12.0",
|
||||
"caniuse-lite": "^1.0.30001109",
|
||||
"normalize-range": "^0.1.2",
|
||||
"num2fraction": "^1.2.2",
|
||||
|
@ -54206,7 +54206,7 @@
|
|||
"integrity": "sha512-ovcyhs2DEBUIE0MGEKHP4olCUW/XYte3Vroyxuh38rD1wAO4dHohsovUC4eAOuzFxE6b+RXvBU3UZ9o0YhUTkA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"browserslist": "^4.21.3"
|
||||
"browserslist": "^4.21.4"
|
||||
}
|
||||
},
|
||||
"core-js-pure": {
|
||||
|
@ -54280,7 +54280,7 @@
|
|||
"requires": {
|
||||
"arrify": "^2.0.1",
|
||||
"cp-file": "^7.0.0",
|
||||
"globby": "^11.0.2",
|
||||
"globby": "^9.2.0",
|
||||
"has-glob": "^1.0.0",
|
||||
"junk": "^3.1.0",
|
||||
"nested-error-stacks": "^2.1.0",
|
||||
|
@ -55883,7 +55883,7 @@
|
|||
"functional-red-black-tree": "^1.0.1",
|
||||
"glob-parent": "^6.0.1",
|
||||
"globals": "^13.15.0",
|
||||
"globby": "^11.0.2",
|
||||
"globby": "^11.1.0",
|
||||
"grapheme-splitter": "^1.0.4",
|
||||
"ignore": "^5.2.0",
|
||||
"import-fresh": "^3.0.0",
|
||||
|
@ -68649,7 +68649,7 @@
|
|||
"fs-extra": "^6.0.1",
|
||||
"get-stream": "^5.1.0",
|
||||
"glob": "^7.1.2",
|
||||
"globby": "^11.0.2",
|
||||
"globby": "^10.0.1",
|
||||
"http-call": "^5.1.2",
|
||||
"load-json-file": "^6.2.0",
|
||||
"pkg-dir": "^4.2.0",
|
||||
|
@ -74342,7 +74342,7 @@
|
|||
"consola": "^2.15.3",
|
||||
"dotenv": "^16.0.0",
|
||||
"dotenv-expand": "^8.0.2",
|
||||
"ejs": "^3.1.8",
|
||||
"ejs": "^3.1.6",
|
||||
"fast-glob": "^3.2.11",
|
||||
"fs-extra": "^10.0.1",
|
||||
"html-minifier-terser": "^6.1.0",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div :class="['action-dropdown-container', $style.actionDropdownContainer]">
|
||||
<el-dropdown :placement="placement" :trigger="trigger" @command="onSelect">
|
||||
<div :class="$style.activator">
|
||||
<div :class="$style.activator" @click.prevent>
|
||||
<n8n-icon :icon="activatorIcon"/>
|
||||
</div>
|
||||
<el-dropdown-menu slot="dropdown" :class="$style.userActionsMenu">
|
||||
|
@ -18,7 +18,7 @@
|
|||
[item.customClass]: item.customClass !== undefined,
|
||||
}">
|
||||
<span v-if="item.icon" :class="$style.icon">
|
||||
<n8n-icon :icon="item.icon"/>
|
||||
<n8n-icon :icon="item.icon" :size="item.iconSize"/>
|
||||
</span>
|
||||
<span :class="$style.label">
|
||||
{{ item.label }}
|
||||
|
@ -75,6 +75,12 @@ export default Vue.extend({
|
|||
type: String,
|
||||
default: 'ellipsis-v',
|
||||
},
|
||||
iconSize: {
|
||||
type: String,
|
||||
default: 'medium',
|
||||
validator: (value: string): boolean =>
|
||||
['small', 'medium', 'large'].includes(value),
|
||||
},
|
||||
trigger: {
|
||||
type: String,
|
||||
default: 'click',
|
||||
|
|
|
@ -1,15 +1,21 @@
|
|||
<template>
|
||||
<div :class="['accordion', $style.container]" >
|
||||
<div :class="{[$style.header]: true, [$style.expanded]: expanded}" @click="toggle">
|
||||
<n8n-text color="text-base" size="small" align="left" bold>{{ title }}</n8n-text>
|
||||
|
||||
<n8n-icon
|
||||
:icon="expanded? 'chevron-up' : 'chevron-down'"
|
||||
bold
|
||||
/>
|
||||
|
||||
<div :class="{[$style.header]: true, [$style.expanded]: expanded }" @click="toggle">
|
||||
<n8n-icon v-if="headerIcon" :icon="headerIcon.icon" :color="headerIcon.color" size="small" class="mr-2xs"/>
|
||||
<n8n-text :class="$style.headerText" color="text-base" size="small" align="left" bold>{{ title }}</n8n-text>
|
||||
<n8n-icon :icon="expanded? 'chevron-up' : 'chevron-down'" bold />
|
||||
</div>
|
||||
<div v-if="expanded" :class="{[$style.description]: true, [$style.collapsed]: !expanded}" @click="onClick">
|
||||
<!-- Info accordion can display list of items with icons or just a HTML description -->
|
||||
<div v-if="items.length > 0" :class="$style.accordionItems">
|
||||
<div v-for="item in items" :key="item.id" :class="$style.accordionItem">
|
||||
<n8n-tooltip :disabled="!item.tooltip">
|
||||
<div slot="content" v-html="item.tooltip" @click="onTooltipClick(item.id, $event)"></div>
|
||||
<n8n-icon :icon="item.icon" :color="item.iconColor" size="small" class="mr-2xs"/>
|
||||
</n8n-tooltip>
|
||||
<n8n-text size="small" color="text-base">{{ item.label }}</n8n-text>
|
||||
</div>
|
||||
</div>
|
||||
<n8n-text color="text-base" size="small" align="left">
|
||||
<span v-html="description"></span>
|
||||
</n8n-text>
|
||||
|
@ -17,11 +23,16 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import N8nText from '../N8nText';
|
||||
import N8nIcon from '../N8nIcon';
|
||||
import Vue, { PropType } from 'vue';
|
||||
|
||||
import Vue from 'vue';
|
||||
interface IAccordionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'n8n-info-accordion',
|
||||
|
@ -36,11 +47,26 @@ export default Vue.extend({
|
|||
description: {
|
||||
type: String,
|
||||
},
|
||||
items: {
|
||||
type: Array as PropType<IAccordionItem[]>,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
initiallyExpanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
headerIcon: {
|
||||
type: Object as () => { icon: string, color: string },
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$on('expand', () => {
|
||||
this.expanded = true;
|
||||
});
|
||||
this.expanded = this.initiallyExpanded;
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -54,6 +80,9 @@ export default Vue.extend({
|
|||
onClick(e) {
|
||||
this.$emit('click', e);
|
||||
},
|
||||
onTooltipClick(item: string, event: MouseEvent) {
|
||||
this.$emit('tooltipClick', item, event);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
@ -67,8 +96,9 @@ export default Vue.extend({
|
|||
cursor: pointer;
|
||||
display: flex;
|
||||
padding: var(--spacing-s);
|
||||
align-items: center;
|
||||
|
||||
*:first-child {
|
||||
.headerText {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
@ -77,6 +107,18 @@ export default Vue.extend({
|
|||
padding: var(--spacing-s) var(--spacing-s) var(--spacing-2xs) var(--spacing-s);
|
||||
}
|
||||
|
||||
.accordionItems {
|
||||
display: flex;
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.accordionItem {
|
||||
display: block !important;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
padding: 0 var(--spacing-s) var(--spacing-s) var(--spacing-s);
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`N8nInfoTip > should render correctly as note 1`] = `"<div class=\\"n8n-info-tip _info_3egb8_33 _note_3egb8_16 _base_3egb8_1 _bold_3egb8_12\\"><span class=\\"_iconText_3egb8_28\\"><span class=\\"n8n-icon n8n-text _compact_odhsl_34 _size-medium_odhsl_19 _regular_odhsl_5\\"></span><span>Need help doing something?<a href=\\"/docs\\" target=\\"_blank\\">Open docs</a></span></span></div>"`;
|
||||
exports[`N8nInfoTip > should render correctly as note 1`] = `"<div class=\\"n8n-info-tip _info_3egb8_33 _note_3egb8_16 _base_3egb8_1 _bold_3egb8_12\\"><span class=\\"_iconText_3egb8_28\\"><span class=\\"n8n-icon n8n-text _compact_e4k11_34 _size-medium_e4k11_19 _regular_e4k11_5\\"></span><span>Need help doing something?<a href=\\"/docs\\" target=\\"_blank\\">Open docs</a></span></span></div>"`;
|
||||
|
||||
exports[`N8nInfoTip > should render correctly as tooltip 1`] = `
|
||||
"<div class=\\"n8n-info-tip _info_3egb8_33 _tooltip_3egb8_23 _base_3egb8_1 _bold_3egb8_12\\">
|
||||
<n8n-tooltip-stub justifybuttons=\\"flex-end\\" buttons=\\"\\" placement=\\"top\\"><span class=\\"_iconText_3egb8_28\\"><span class=\\"n8n-icon n8n-text _compact_odhsl_34 _size-medium_odhsl_19 _regular_odhsl_5\\"></span></span><span>Need help doing something?<a href=\\"/docs\\" target=\\"_blank\\">Open docs</a></span></n8n-tooltip-stub>
|
||||
<n8n-tooltip-stub justifybuttons=\\"flex-end\\" buttons=\\"\\" placement=\\"top\\"><span class=\\"_iconText_3egb8_28\\"><span class=\\"n8n-icon n8n-text _compact_e4k11_34 _size-medium_e4k11_19 _regular_e4k11_5\\"></span></span><span>Need help doing something?<a href=\\"/docs\\" target=\\"_blank\\">Open docs</a></span></n8n-tooltip-stub>
|
||||
</div>"
|
||||
`;
|
||||
|
|
|
@ -65,6 +65,7 @@ export default Vue.extend({
|
|||
color: var(--color-text-base);
|
||||
transition: background-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
v-bind="option"
|
||||
:active="value === option.value"
|
||||
:size="size"
|
||||
:disabled="disabled"
|
||||
@click="(e) => onClick(option.value, e)"
|
||||
:disabled="disabled || option.disabled"
|
||||
@click="(e) => onClick(option, e)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -36,11 +36,11 @@ export default Vue.extend({
|
|||
RadioButton,
|
||||
},
|
||||
methods: {
|
||||
onClick(value: unknown) {
|
||||
if (this.disabled) {
|
||||
onClick(option: {label: string, value: string, disabled?: boolean}) {
|
||||
if (this.disabled || option.disabled) {
|
||||
return;
|
||||
}
|
||||
this.$emit('input', value);
|
||||
this.$emit('input', option.value);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -21,7 +21,7 @@ export default Vue.extend({
|
|||
},
|
||||
color: {
|
||||
type: String,
|
||||
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight', 'danger', 'success'].includes(value),
|
||||
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight', 'danger', 'success', 'warning'].includes(value),
|
||||
},
|
||||
align: {
|
||||
type: String,
|
||||
|
@ -126,6 +126,10 @@ export default Vue.extend({
|
|||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
|
|
@ -9,14 +9,16 @@
|
|||
[$style.sidebarCollapsed]: sidebarMenuCollapsed
|
||||
}"
|
||||
>
|
||||
<div id="header" :class="$style['header']">
|
||||
<div id="header" :class="$style.header">
|
||||
<router-view name="header"></router-view>
|
||||
</div>
|
||||
<div id="sidebar" :class="$style['sidebar']">
|
||||
<div id="sidebar" :class="$style.sidebar">
|
||||
<router-view name="sidebar"></router-view>
|
||||
</div>
|
||||
<div id="content" :class="$style['content']">
|
||||
<router-view />
|
||||
<div id="content" :class="$style.content">
|
||||
<keep-alive include="NodeView" :max="1">
|
||||
<router-view />
|
||||
</keep-alive>
|
||||
</div>
|
||||
<Modals />
|
||||
<Telemetry />
|
||||
|
@ -35,7 +37,7 @@ import { showMessage } from './components/mixins/showMessage';
|
|||
import { IUser } from './Interface';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { userHelpers } from './components/mixins/userHelpers';
|
||||
import { addHeaders, loadLanguage } from './plugins/i18n';
|
||||
import { loadLanguage } from './plugins/i18n';
|
||||
import { restApi } from '@/components/mixins/restApi';
|
||||
import { globalLinkActions } from '@/components/mixins/globalLinkActions';
|
||||
|
||||
|
|
|
@ -1009,6 +1009,9 @@ export interface IUiState {
|
|||
isPageLoading: boolean;
|
||||
currentView: string;
|
||||
fakeDoorFeatures: IFakeDoor[];
|
||||
nodeViewInitialized: boolean;
|
||||
addFirstStepOnLoad: boolean;
|
||||
executionSidebarAutoRefresh: boolean;
|
||||
}
|
||||
|
||||
export type ILogLevel = 'info' | 'debug' | 'warn' | 'error' | 'verbose';
|
||||
|
@ -1088,7 +1091,12 @@ export interface IUsersState {
|
|||
users: {[userId: string]: IUser};
|
||||
}
|
||||
|
||||
export interface IWorkflowsMap {
|
||||
export interface IWorkflowsState {
|
||||
currentWorkflowExecutions: IExecutionsSummary[];
|
||||
activeWorkflowExecution: IExecutionsSummary | null;
|
||||
finishedExecutionsCount: number;
|
||||
}
|
||||
export interface IWorkflowsMap {
|
||||
[name: string]: IWorkflowDb;
|
||||
}
|
||||
|
||||
|
@ -1145,6 +1153,12 @@ export interface ITab {
|
|||
tooltip?: string;
|
||||
}
|
||||
|
||||
export interface ITabBarItem {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IResourceLocatorReqParams {
|
||||
nodeTypeAndVersion: INodeTypeNameVersion;
|
||||
path: string;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { IRestApiContext } from '@/Interface';
|
||||
import { IDataObject } from 'n8n-workflow';
|
||||
import { makeRestApiRequest } from './helpers';
|
||||
|
||||
export async function getNewWorkflow(context: IRestApiContext, name?: string) {
|
||||
|
@ -19,3 +20,10 @@ export async function getActiveWorkflows(context: IRestApiContext) {
|
|||
return await makeRestApiRequest(context, 'GET', `/active`);
|
||||
}
|
||||
|
||||
export async function getCurrentExecutions(context: IRestApiContext, filter: IDataObject) {
|
||||
return await makeRestApiRequest(context, 'GET', '/executions-current', { filter });
|
||||
}
|
||||
|
||||
export async function getFinishedExecutions(context: IRestApiContext, filter: IDataObject) {
|
||||
return await makeRestApiRequest(context, 'GET', '/executions', { filter });
|
||||
}
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import { WORKFLOW_ACTIVE_MODAL_KEY, EXECUTIONS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, LOCAL_STORAGE_ACTIVATION_FLAG } from '../constants';
|
||||
import { WORKFLOW_ACTIVE_MODAL_KEY, EXECUTIONS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, LOCAL_STORAGE_ACTIVATION_FLAG, VIEWS } from '../constants';
|
||||
import { getActivatableTriggerNodes, getTriggerNodeServiceName } from './helpers';
|
||||
import { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
|
@ -53,11 +53,23 @@ export default Vue.extend({
|
|||
return {
|
||||
WORKFLOW_ACTIVE_MODAL_KEY,
|
||||
checked: false,
|
||||
modalBus: new Vue(),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async showExecutionsList () {
|
||||
this.$store.dispatch('ui/openModal', EXECUTIONS_MODAL_KEY);
|
||||
const activeExecution = this.$store.getters['workflows/getActiveWorkflowExecution'];
|
||||
const currentWorkflow = this.$store.getters.workflowId;
|
||||
|
||||
if (activeExecution) {
|
||||
this.$router.push({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: currentWorkflow, executionId: activeExecution.id },
|
||||
}).catch(()=>{});;
|
||||
} else {
|
||||
this.$router.push({ name: VIEWS.EXECUTION_HOME, params: { name: currentWorkflow } }).catch(() => {});
|
||||
}
|
||||
this.$store.commit('ui/closeModal', WORKFLOW_ACTIVE_MODAL_KEY);
|
||||
},
|
||||
async showSettings() {
|
||||
this.$store.dispatch('ui/openModal', WORKFLOW_SETTINGS_MODAL_KEY);
|
||||
|
|
|
@ -344,16 +344,13 @@ export default mixins(
|
|||
convertToDisplayDate,
|
||||
displayExecution (execution: IExecutionShortResponse, e: PointerEvent) {
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
const route = this.$router.resolve({name: VIEWS.EXECUTION, params: {id: execution.id}});
|
||||
const route = this.$router.resolve({ name: VIEWS.EXECUTION_PREVIEW, params: { name: execution.workflowId, executionId: execution.id } });
|
||||
window.open(route.href, '_blank');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.$router.push({
|
||||
name: VIEWS.EXECUTION,
|
||||
params: { id: execution.id },
|
||||
});
|
||||
this.$router.push({ name: VIEWS.EXECUTION_PREVIEW, params: { name: execution.workflowId, executionId: execution.id } }).catch(()=>{});;
|
||||
this.modalBus.$emit('closeAll');
|
||||
},
|
||||
handleAutoRefreshToggle () {
|
||||
|
@ -409,6 +406,35 @@ export default mixins(
|
|||
|
||||
try {
|
||||
await this.restApi().deleteExecutions(sendData);
|
||||
let removedCurrentlyLoadedExecution = false;
|
||||
let removedActiveExecution = false;
|
||||
const currentWorkflow: string = this.$store.getters.workflowId;
|
||||
const activeExecution: IExecutionsSummary = this.$store.getters['workflows/getActiveWorkflowExecution'];
|
||||
// Also update current workflow executions view if needed
|
||||
for (const selectedId of Object.keys(this.selectedItems)) {
|
||||
const execution: IExecutionsSummary = this.$store.getters['workflows/getExecutionDataById'](selectedId);
|
||||
if (execution && execution.workflowId === currentWorkflow) {
|
||||
this.$store.commit('workflows/deleteExecution', execution);
|
||||
removedCurrentlyLoadedExecution = true;
|
||||
}
|
||||
if (execution.id === activeExecution.id) {
|
||||
removedActiveExecution = true;
|
||||
}
|
||||
}
|
||||
// Also update route if needed
|
||||
if (removedCurrentlyLoadedExecution) {
|
||||
const currentWorkflowExecutions: IExecutionsSummary[] = this.$store.getters['workflows/currentWorkflowExecutions'];
|
||||
if (currentWorkflowExecutions.length === 0) {
|
||||
this.$store.commit('workflows/setActiveWorkflowExecution', null);
|
||||
this.$router.push({ name: VIEWS.EXECUTION_HOME, params: { name: currentWorkflow } });
|
||||
} else if (removedActiveExecution) {
|
||||
this.$store.commit('workflows/setActiveWorkflowExecution', currentWorkflowExecutions[0]);
|
||||
this.$router.push({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: currentWorkflow, executionId: currentWorkflowExecutions[0].id },
|
||||
}).catch(()=>{});;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.isDataLoading = false;
|
||||
this.$showError(
|
||||
|
|
|
@ -0,0 +1,206 @@
|
|||
<template>
|
||||
<div
|
||||
:class="{
|
||||
['execution-card']: true,
|
||||
[$style.executionCard]: true,
|
||||
[$style.active]: isActive,
|
||||
[$style[executionUIDetails.name]]: true,
|
||||
[$style.highlight]: highlight,
|
||||
}"
|
||||
>
|
||||
<router-link
|
||||
:class="$style.executionLink"
|
||||
:to="{ name: VIEWS.EXECUTION_PREVIEW, params: { workflowId: currentWorkflow, executionId: execution.id }}"
|
||||
>
|
||||
<div :class="$style.description">
|
||||
<n8n-text color="text-dark" :bold="true" size="medium">{{ executionUIDetails.startTime }}</n8n-text>
|
||||
<div :class="$style.executionStatus">
|
||||
<n8n-spinner v-if="executionUIDetails.name === 'running'" size="small" :class="[$style.spinner, 'mr-4xs']"/>
|
||||
<n8n-text :class="$style.statusLabel" size="small">{{ executionUIDetails.label }}</n8n-text>
|
||||
<n8n-text v-if="executionUIDetails.name === 'running'" :color="isActive? 'text-dark' : 'text-base'" size="small">
|
||||
{{ $locale.baseText('executionDetails.runningTimeRunning', { interpolate: { time: executionUIDetails.runningTime } }) }}
|
||||
</n8n-text>
|
||||
<n8n-text v-else-if="executionUIDetails.name !== 'waiting' && executionUIDetails.name !== 'unknown'" :color="isActive? 'text-dark' : 'text-base'" size="small">
|
||||
{{ $locale.baseText('executionDetails.runningTimeFinished', { interpolate: { time: executionUIDetails.runningTime } }) }}
|
||||
</n8n-text>
|
||||
</div>
|
||||
<div v-if="execution.mode === 'retry'">
|
||||
<n8n-text :color="isActive? 'text-dark' : 'text-base'" size="small">
|
||||
{{ $locale.baseText('executionDetails.retry') }} #{{ execution.retryOf }}
|
||||
</n8n-text>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.icons">
|
||||
<n8n-action-dropdown
|
||||
v-if="executionUIDetails.name === 'error'"
|
||||
:class="[$style.icon, $style.retry]"
|
||||
:items="retryExecutionActions"
|
||||
activatorIcon="redo"
|
||||
@select="onRetryMenuItemSelect"
|
||||
/>
|
||||
<font-awesome-icon
|
||||
v-if="execution.mode === 'manual'"
|
||||
:class="[$style.icon, $style.manual]"
|
||||
:title="$locale.baseText('executionsList.manual')"
|
||||
icon="flask"
|
||||
/>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { IExecutionsSummary } from '@/Interface';
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import { executionHelpers, IExecutionUIData } from '../mixins/executionsHelpers';
|
||||
import { VIEWS } from '../../constants';
|
||||
import { showMessage } from '../mixins/showMessage';
|
||||
import { restApi } from '../mixins/restApi';
|
||||
|
||||
export default mixins(
|
||||
executionHelpers,
|
||||
showMessage,
|
||||
restApi,
|
||||
).extend({
|
||||
name: 'execution-card',
|
||||
data() {
|
||||
return {
|
||||
VIEWS,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
execution: {
|
||||
type: Object as () => IExecutionsSummary,
|
||||
required: true,
|
||||
},
|
||||
highlight: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
retryExecutionActions(): object[] {
|
||||
return [
|
||||
{ id: 'current-workflow', label: this.$locale.baseText('executionsList.retryWithCurrentlySavedWorkflow') },
|
||||
{ id: 'original-workflow', label: this.$locale.baseText('executionsList.retryWithOriginalWorkflow') },
|
||||
];
|
||||
},
|
||||
executionUIDetails(): IExecutionUIData {
|
||||
return this.getExecutionUIDetails(this.execution);
|
||||
},
|
||||
isActive(): boolean {
|
||||
return this.execution.id === this.$route.params.executionId;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onRetryMenuItemSelect(action: string): void {
|
||||
this.$emit('retryExecution', { execution: this.execution, command: action });
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.executionCard {
|
||||
display: flex;
|
||||
padding-right: var(--spacing-2xs);
|
||||
|
||||
&.active {
|
||||
padding: 0 var(--spacing-2xs) var(--spacing-2xs) 0;
|
||||
border-left: var(--spacing-4xs) var(--border-style-base) transparent !important;
|
||||
|
||||
.executionStatus {
|
||||
color: var(--color-text-dark) !important;
|
||||
}
|
||||
}
|
||||
|
||||
& + &.active { padding-top: var(--spacing-2xs); }
|
||||
|
||||
&:hover, &.active {
|
||||
.executionLink {
|
||||
background-color: var(--color-foreground-base);
|
||||
}
|
||||
}
|
||||
|
||||
&.running {
|
||||
.spinner {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
&, & .executionLink {
|
||||
border-left: var(--spacing-4xs) var(--border-style-base) hsl(var(--color-warning-h), 94%, 80%);
|
||||
}
|
||||
.statusLabel, .spinner { color: var(--color-warning); }
|
||||
}
|
||||
|
||||
&.success {
|
||||
&, & .executionLink {
|
||||
border-left: var(--spacing-4xs) var(--border-style-base) hsl(var(--color-success-h), 60%, 70%);
|
||||
}
|
||||
}
|
||||
|
||||
&.waiting {
|
||||
&, & .executionLink {
|
||||
border-left: var(--spacing-4xs) var(--border-style-base) hsl(var(--color-secondary-h), 94%, 80%);
|
||||
}
|
||||
.statusLabel { color: var(--color-secondary); }
|
||||
}
|
||||
|
||||
&.error {
|
||||
&, & .executionLink {
|
||||
border-left: var(--spacing-4xs) var(--border-style-base) hsl(var(--color-danger-h), 94%, 80%);
|
||||
}
|
||||
.statusLabel { color: var(--color-danger ); }
|
||||
}
|
||||
|
||||
&.unknown {
|
||||
&, & .executionLink {
|
||||
border-left: var(--spacing-4xs) var(--border-style-base) var(--color-text-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.executionLink {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--color-text-base);
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--spacing-xs);
|
||||
padding-right: var(--spacing-s);
|
||||
border-radius: var(--border-radius-base);
|
||||
position: relative;
|
||||
left: calc(-1 * var(--spacing-4xs)); // Hide link border under card border so it's not visible when not hovered
|
||||
|
||||
&:active {
|
||||
.icon, .statusLabel {
|
||||
color: var(--color-text-base);;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: var(--font-size-s);
|
||||
|
||||
&.retry {
|
||||
svg {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&.manual {
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
& + & {
|
||||
margin-left: var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,151 @@
|
|||
<template>
|
||||
<div v-if="executionUIDetails && executionUIDetails.name === 'running'" :class="$style.runningInfo">
|
||||
<div :class="$style.spinner">
|
||||
<font-awesome-icon icon="spinner" spin />
|
||||
</div>
|
||||
<n8n-text :class="$style.runningMessage">
|
||||
{{ $locale.baseText('executionDetails.runningMessage') }}
|
||||
</n8n-text>
|
||||
</div>
|
||||
<div v-else :class="$style.previewContainer">
|
||||
<div :class="{[$style.executionDetails]: true, [$style.sidebarCollapsed]: sidebarCollapsed }" v-if="activeExecution">
|
||||
<div>
|
||||
<n8n-text size="large" color="text-base" :bold="true">{{ executionUIDetails.startTime }}</n8n-text><br>
|
||||
<n8n-spinner v-if="executionUIDetails.name === 'running'" size="small" :class="[$style.spinner, 'mr-4xs']"/>
|
||||
<n8n-text size="medium" :class="[$style.status, $style[executionUIDetails.name]]">{{ executionUIDetails.label }}</n8n-text>
|
||||
<n8n-text v-if="executionUIDetails.name === 'running'" color="text-base" size="medium">
|
||||
{{ $locale.baseText('executionDetails.runningTimeRunning', { interpolate: { time: executionUIDetails.runningTime } }) }} | ID#{{ activeExecution.id }}
|
||||
</n8n-text>
|
||||
<n8n-text v-else-if="executionUIDetails.name !== 'waiting'" color="text-base" size="medium">
|
||||
{{ $locale.baseText('executionDetails.runningTimeFinished', { interpolate: { time: executionUIDetails.runningTime } }) }} | ID#{{ activeExecution.id }}
|
||||
</n8n-text>
|
||||
<n8n-text v-else-if="executionUIDetails.name === 'waiting'" color="text-base" size="medium">
|
||||
| ID#{{ activeExecution.id }}
|
||||
</n8n-text>
|
||||
<br><n8n-text v-if="activeExecution.mode === 'retry'" color="text-base" size= "medium">
|
||||
{{ $locale.baseText('executionDetails.retry') }}
|
||||
<router-link
|
||||
:class="$style.executionLink"
|
||||
:to="{ name: VIEWS.EXECUTION_PREVIEW, params: { workflowId: activeExecution.workflowId, executionId: activeExecution.retryOf }}"
|
||||
>
|
||||
#{{ activeExecution.retryOf }}
|
||||
</router-link>
|
||||
</n8n-text>
|
||||
</div>
|
||||
<div>
|
||||
<el-dropdown v-if="executionUIDetails.name === 'error'" trigger="click" class="mr-xs" @command="handleRetryClick">
|
||||
<span class="retry-button">
|
||||
<n8n-icon-button
|
||||
size="large"
|
||||
type="tertiary"
|
||||
:title="$locale.baseText('executionsList.retryExecution')"
|
||||
icon="redo"
|
||||
/>
|
||||
</span>
|
||||
<el-dropdown-menu slot="dropdown">
|
||||
<el-dropdown-item command="current-workflow">
|
||||
{{ $locale.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="original-workflow">
|
||||
{{ $locale.baseText('executionsList.retryWithOriginalworkflow') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</el-dropdown>
|
||||
<n8n-icon-button :title="$locale.baseText('executionDetails.deleteExecution')" icon="trash" size="large" type="tertiary" @click="onDeleteExecution" />
|
||||
</div>
|
||||
</div>
|
||||
<workflow-preview mode="execution" loaderType="spinner" :executionId="executionId"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import { restApi } from '@/components/mixins/restApi';
|
||||
import { showMessage } from '../mixins/showMessage';
|
||||
import WorkflowPreview from '@/components/WorkflowPreview.vue';
|
||||
import { executionHelpers, IExecutionUIData } from '../mixins/executionsHelpers';
|
||||
import { VIEWS } from '../../constants';
|
||||
|
||||
export default mixins(restApi, showMessage, executionHelpers).extend({
|
||||
name: 'execution-preview',
|
||||
components: {
|
||||
WorkflowPreview,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
VIEWS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
executionUIDetails(): IExecutionUIData | null {
|
||||
return this.activeExecution ? this.getExecutionUIDetails(this.activeExecution) : null;
|
||||
},
|
||||
sidebarCollapsed(): boolean {
|
||||
return this.$store.getters['ui/sidebarMenuCollapsed'];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onDeleteExecution(): Promise<void> {
|
||||
const deleteConfirmed = await this.confirmMessage(
|
||||
this.$locale.baseText('executionDetails.confirmMessage.message'),
|
||||
this.$locale.baseText('executionDetails.confirmMessage.headline'),
|
||||
'warning',
|
||||
this.$locale.baseText('executionDetails.confirmMessage.confirmButtonText'),
|
||||
'',
|
||||
);
|
||||
if (!deleteConfirmed) {
|
||||
return;
|
||||
}
|
||||
this.$emit('deleteCurrentExecution');
|
||||
},
|
||||
handleRetryClick(command: string): void {
|
||||
this.$emit('retryExecution', { execution: this.activeExecution, command });
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
.previewContainer {
|
||||
height: calc(100% - $header-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.executionDetails {
|
||||
position: absolute;
|
||||
padding: var(--spacing-m);
|
||||
padding-right: var(--spacing-xl);
|
||||
width: calc(100% - 510px);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
&.sidebarCollapsed {
|
||||
width: calc(100% - 375px);
|
||||
}
|
||||
}
|
||||
|
||||
.running, .spinner { color: var(--color-warning); }
|
||||
.waiting { color: var(--color-secondary); }
|
||||
.success { color: var(--color-success); }
|
||||
.error { color: var(--color-danger); }
|
||||
|
||||
.runningInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: var(--spacing-4xl);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
font-size: var(--font-size-2xl);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.runningMessage {
|
||||
width: 200px;
|
||||
margin-top: var(--spacing-l);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,174 @@
|
|||
<template>
|
||||
<n8n-info-accordion
|
||||
:class="[$style.accordion, 'mt-2xl']"
|
||||
:title="$locale.baseText('executionsLandingPage.emptyState.accordion.title')"
|
||||
:items="accordionItems"
|
||||
:description="accordionDescription"
|
||||
:initiallyExpanded="shouldExpandAccordion"
|
||||
:headerIcon="accordionIcon"
|
||||
@click="onAccordionClick"
|
||||
@tooltipClick="onItemTooltipClick"
|
||||
></n8n-info-accordion>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
|
||||
import { deepCopy, IWorkflowSettings } from 'n8n-workflow';
|
||||
import Vue from 'vue';
|
||||
|
||||
interface IWorkflowSaveSettings {
|
||||
saveFailedExecutions: boolean,
|
||||
saveSuccessfulExecutions: boolean,
|
||||
saveManualExecutions: boolean,
|
||||
};
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'executions-info-accordion',
|
||||
props: {
|
||||
initiallyExpanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
defaultValues: {
|
||||
saveFailedExecutions: 'all',
|
||||
saveSuccessfulExecutions: 'all',
|
||||
saveManualExecutions: false,
|
||||
},
|
||||
workflowSaveSettings: {
|
||||
saveFailedExecutions: false,
|
||||
saveSuccessfulExecutions: false,
|
||||
saveManualExecutions: false,
|
||||
} as IWorkflowSaveSettings,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.defaultValues.saveFailedExecutions = this.$store.getters.saveDataErrorExecution;
|
||||
this.defaultValues.saveSuccessfulExecutions = this.$store.getters.saveDataSuccessExecution;
|
||||
this.defaultValues.saveManualExecutions = this.$store.getters.saveManualExecutions;
|
||||
this.updateSettings(this.workflowSettings);
|
||||
},
|
||||
watch: {
|
||||
workflowSettings(newSettings: IWorkflowSettings) {
|
||||
this.updateSettings(newSettings);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
accordionItems(): Object[] {
|
||||
return [
|
||||
{
|
||||
id: 'productionExecutions',
|
||||
label: this.$locale.baseText('executionsLandingPage.emptyState.accordion.productionExecutions'),
|
||||
icon: this.productionExecutionsIcon.icon,
|
||||
iconColor: this.productionExecutionsIcon.color,
|
||||
tooltip: this.productionExecutionsStatus === 'unknown' ? this.$locale.baseText('executionsLandingPage.emptyState.accordion.productionExecutionsWarningTooltip') : null,
|
||||
},
|
||||
{
|
||||
id: 'manualExecutions',
|
||||
label: this.$locale.baseText('executionsLandingPage.emptyState.accordion.manualExecutions'),
|
||||
icon: this.workflowSaveSettings.saveManualExecutions ? 'check' : 'times',
|
||||
iconColor: this.workflowSaveSettings.saveManualExecutions ? 'success' : 'danger',
|
||||
},
|
||||
];
|
||||
},
|
||||
shouldExpandAccordion(): boolean {
|
||||
if (this.initiallyExpanded === false) {
|
||||
return false;
|
||||
}
|
||||
return this.workflowSaveSettings.saveFailedExecutions === false ||
|
||||
this.workflowSaveSettings.saveSuccessfulExecutions === false ||
|
||||
this.workflowSaveSettings.saveManualExecutions === false;
|
||||
},
|
||||
productionExecutionsIcon(): { icon: string, color: string } {
|
||||
if (this.productionExecutionsStatus === 'saving') {
|
||||
return { icon: 'check', color: 'success' };
|
||||
} else if (this.productionExecutionsStatus === 'not-saving') {
|
||||
return { icon: 'times', color: 'danger' };
|
||||
}
|
||||
return { icon: 'exclamation-triangle', color: 'warning' };
|
||||
},
|
||||
productionExecutionsStatus(): string {
|
||||
if (this.workflowSaveSettings.saveSuccessfulExecutions === this.workflowSaveSettings.saveFailedExecutions) {
|
||||
if (this.workflowSaveSettings.saveSuccessfulExecutions === true) {
|
||||
return 'saving';
|
||||
}
|
||||
return 'not-saving';
|
||||
} else {
|
||||
return 'unknown';
|
||||
}
|
||||
},
|
||||
workflowSettings(): IWorkflowSettings {
|
||||
const workflowSettings = deepCopy(this.$store.getters.workflowSettings);
|
||||
return workflowSettings;
|
||||
},
|
||||
accordionDescription(): string {
|
||||
return `
|
||||
<footer class="mt-2xs">
|
||||
${this.$locale.baseText('executionsLandingPage.emptyState.accordion.footer')}
|
||||
</footer>
|
||||
`;
|
||||
},
|
||||
accordionIcon(): { icon: string, color: string }|null {
|
||||
if (this.workflowSaveSettings.saveManualExecutions !== true || this.productionExecutionsStatus !== 'saving') {
|
||||
return { icon: 'exclamation-triangle', color: 'warning' };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateSettings(settingsInStore: IWorkflowSettings): void {
|
||||
this.workflowSaveSettings.saveFailedExecutions = settingsInStore.saveDataErrorExecution !== 'none';
|
||||
this.workflowSaveSettings.saveSuccessfulExecutions = settingsInStore.saveDataSuccessExecution !== 'none';
|
||||
this.workflowSaveSettings.saveManualExecutions = settingsInStore.saveManualExecutions === undefined ? this.defaultValues.saveManualExecutions : settingsInStore.saveManualExecutions as boolean;
|
||||
},
|
||||
onAccordionClick(event: MouseEvent): void {
|
||||
if (event.target instanceof HTMLAnchorElement) {
|
||||
event.preventDefault();
|
||||
this.$store.dispatch('ui/openModal', WORKFLOW_SETTINGS_MODAL_KEY);
|
||||
}
|
||||
},
|
||||
onItemTooltipClick(item: string, event: MouseEvent): void {
|
||||
if (item === 'productionExecutions' && event.target instanceof HTMLAnchorElement) {
|
||||
event.preventDefault();
|
||||
this.$store.dispatch('ui/openModal', WORKFLOW_SETTINGS_MODAL_KEY);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
.accordion {
|
||||
background: none;
|
||||
width: 320px;
|
||||
|
||||
// Accordion header
|
||||
& > div:nth-child(1) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: var(--spacing-xs);
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
color: var(--color-text-base) !important;
|
||||
}
|
||||
|
||||
// Accordion description
|
||||
& > div:nth-child(2) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 0 var(--spacing-l) var(--spacing-s) !important;
|
||||
|
||||
span { width: 100%; }
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,96 @@
|
|||
<template>
|
||||
<div :class="['workflow-executions-container', $style.container]">
|
||||
<div v-if="executionCount === 0" :class="[$style.messageContainer, $style.noExecutionsMessage]">
|
||||
<div v-if="!containsTrigger">
|
||||
<n8n-heading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
|
||||
{{ $locale.baseText('executionsLandingPage.emptyState.noTrigger.heading') }}
|
||||
</n8n-heading>
|
||||
<n8n-text size="medium">
|
||||
{{ $locale.baseText('executionsLandingPage.emptyState.message') }}
|
||||
</n8n-text>
|
||||
<n8n-button class="mt-l" type="tertiary" size="large" @click="onSetupFirstStep">
|
||||
{{ $locale.baseText('executionsLandingPage.emptyState.noTrigger.buttonText') }}
|
||||
</n8n-button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<n8n-heading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
|
||||
{{ $locale.baseText('executionsLandingPage.emptyState.heading') }}
|
||||
</n8n-heading>
|
||||
<n8n-text size="medium">
|
||||
{{ $locale.baseText('executionsLandingPage.emptyState.message') }}
|
||||
</n8n-text>
|
||||
<executions-info-accordion />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants';
|
||||
import { IExecutionsSummary } from '@/Interface';
|
||||
import Vue from 'vue';
|
||||
import ExecutionsInfoAccordion from './ExecutionsInfoAccordion.vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'executions-landing-page',
|
||||
components: {
|
||||
ExecutionsInfoAccordion,
|
||||
},
|
||||
computed: {
|
||||
executionCount(): number {
|
||||
return (this.$store.getters['workflows/currentWorkflowExecutions'] as IExecutionsSummary[]).length;
|
||||
},
|
||||
containsTrigger(): boolean {
|
||||
return this.$store.getters.workflowTriggerNodes.length > 0;
|
||||
},
|
||||
currentWorkflowId(): string {
|
||||
return this.$store.getters.workflowId;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onSetupFirstStep(event: MouseEvent): void {
|
||||
this.$store.commit('ui/setAddFirstStepOnLoad', true);
|
||||
const workflowRoute = this.getWorkflowRoute();
|
||||
this.$router.push(workflowRoute);
|
||||
},
|
||||
getWorkflowRoute(): { name: string, params: {}} {
|
||||
const workflowId = this.currentWorkflowId || this.$route.params.name;
|
||||
if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
|
||||
return { name: VIEWS.NEW_WORKFLOW, params: {} };
|
||||
} else {
|
||||
return { name: VIEWS.WORKFLOW, params: { name: workflowId } };
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
background-color: var(--color-background-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.messageContainer {
|
||||
margin-top: var(--spacing-4xl);
|
||||
color: var(--color-text-base);
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
color: var(--color-foreground-dark);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,270 @@
|
|||
<template>
|
||||
<div :class="['executions-sidebar', $style.container]">
|
||||
<div :class="$style.heading">
|
||||
<n8n-heading tag="h2" size="medium" color="text-dark">
|
||||
{{ $locale.baseText('generic.executions') }}
|
||||
</n8n-heading>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<el-checkbox v-model="autoRefresh" @change="onAutoRefreshToggle">{{ $locale.baseText('executionsList.autoRefresh') }}</el-checkbox>
|
||||
<n8n-popover trigger="click" >
|
||||
<template slot="reference">
|
||||
<div :class="$style.filterButton">
|
||||
<n8n-button icon="filter" type="tertiary" size="medium" :active="statusFilterApplied">
|
||||
<n8n-badge v-if="statusFilterApplied" theme="primary" class="mr-4xs">1</n8n-badge>
|
||||
{{ $locale.baseText('executionsList.filters') }}
|
||||
</n8n-button>
|
||||
</div>
|
||||
</template>
|
||||
<div :class="$style['filters-dropdown']">
|
||||
<div class="mb-s">
|
||||
<n8n-input-label
|
||||
:label="$locale.baseText('executions.ExecutionStatus')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
color="text-base"
|
||||
class="mb-3xs"
|
||||
/>
|
||||
<n8n-select
|
||||
v-model="filter.status"
|
||||
size="small"
|
||||
ref="typeInput"
|
||||
:class="$style['type-input']"
|
||||
:placeholder="$locale.baseText('generic.any')"
|
||||
@change="onFilterChange"
|
||||
>
|
||||
<n8n-option
|
||||
v-for="item in executionStatuses"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id">
|
||||
</n8n-option>
|
||||
</n8n-select>
|
||||
</div>
|
||||
<div :class="[$style.filterMessage, 'mt-s']" v-if="statusFilterApplied">
|
||||
<n8n-link @click="resetFilters">
|
||||
{{ $locale.baseText('generic.reset') }}
|
||||
</n8n-link>
|
||||
</div>
|
||||
</div>
|
||||
</n8n-popover>
|
||||
</div>
|
||||
<div v-show="statusFilterApplied" class="mb-xs">
|
||||
<n8n-info-tip :bold="false">
|
||||
{{ $locale.baseText('generic.filtersApplied') }}
|
||||
<n8n-link @click="resetFilters" size="small">
|
||||
{{ $locale.baseText('generic.resetAllFilters') }}
|
||||
</n8n-link>
|
||||
</n8n-info-tip>
|
||||
</div>
|
||||
<div :class="$style.executionList" ref="executionList" @scroll="loadMore">
|
||||
<div v-if="loading" class="mr-m">
|
||||
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
|
||||
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
|
||||
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
|
||||
</div>
|
||||
<execution-card
|
||||
v-else
|
||||
v-for="execution in executions"
|
||||
:key="execution.id"
|
||||
:execution="execution"
|
||||
@refresh="onRefresh"
|
||||
@retryExecution="onRetryExecution"
|
||||
/>
|
||||
<div v-if="loadingMore" class="mr-m">
|
||||
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.infoAccordion">
|
||||
<executions-info-accordion :initiallyExpanded="false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import ExecutionCard from '@/components/ExecutionsView/ExecutionCard.vue';
|
||||
import ExecutionsInfoAccordion from '@/components/ExecutionsView/ExecutionsInfoAccordion.vue';
|
||||
import { VIEWS } from '../../constants';
|
||||
import { range as _range } from 'lodash';
|
||||
import { IExecutionsSummary } from "@/Interface";
|
||||
import { Route } from 'vue-router';
|
||||
import Vue from 'vue';
|
||||
import { PropType } from 'vue';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'executions-sidebar',
|
||||
components: {
|
||||
ExecutionCard,
|
||||
ExecutionsInfoAccordion,
|
||||
},
|
||||
props: {
|
||||
executions: {
|
||||
type: Array as PropType<IExecutionsSummary[]>,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
loadingMore: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
VIEWS,
|
||||
filter: {
|
||||
status: '',
|
||||
},
|
||||
autoRefresh: false,
|
||||
autoRefreshInterval: undefined as undefined | NodeJS.Timer,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
statusFilterApplied(): boolean {
|
||||
return this.filter.status !== '';
|
||||
},
|
||||
executionStatuses(): Array<{ id: string, name: string }> {
|
||||
return [
|
||||
{ id: 'error', name: this.$locale.baseText('executionsList.error') },
|
||||
{ id: 'running', name: this.$locale.baseText('executionsList.running') },
|
||||
{ id: 'success', name: this.$locale.baseText('executionsList.success') },
|
||||
{ id: 'waiting', name: this.$locale.baseText('executionsList.waiting') },
|
||||
];
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
$route (to: Route, from: Route) {
|
||||
if (from.name === VIEWS.EXECUTION_PREVIEW && to.name === VIEWS.EXECUTION_HOME) {
|
||||
// Skip parent route when navigating through executions with back button
|
||||
this.$router.go(-1);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.autoRefresh = this.$store.getters['ui/isExecutionSidebarAutoRefreshOn'];
|
||||
if (this.autoRefresh) {
|
||||
this.autoRefreshInterval = setInterval(() => this.onRefresh(), 4000);
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.autoRefreshInterval) {
|
||||
clearInterval(this.autoRefreshInterval);
|
||||
this.autoRefreshInterval = undefined;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadMore(): void {
|
||||
if (!this.loading) {
|
||||
const executionsList = this.$refs.executionList as HTMLElement;
|
||||
if (executionsList) {
|
||||
const diff = executionsList.offsetHeight - (executionsList.scrollHeight - executionsList.scrollTop);
|
||||
if (diff > -10 && diff < 10) {
|
||||
this.$emit('loadMore');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onRetryExecution(payload: Object) {
|
||||
this.$emit('retryExecution', payload);
|
||||
},
|
||||
onRefresh(): void {
|
||||
this.$emit('refresh');
|
||||
},
|
||||
onFilterChange(): void {
|
||||
this.$emit('filterUpdated', this.prepareFilter());
|
||||
},
|
||||
reloadExecutions(): void {
|
||||
this.$emit('reloadExecutions');
|
||||
},
|
||||
onAutoRefreshToggle(): void {
|
||||
this.$store.commit('ui/setExecutionsSidebarAutoRefresh', this.autoRefresh);
|
||||
if (this.autoRefreshInterval) {
|
||||
// Clear any previously existing intervals (if any - there shouldn't)
|
||||
clearInterval(this.autoRefreshInterval);
|
||||
this.autoRefreshInterval = undefined;
|
||||
}
|
||||
if (this.autoRefresh) {
|
||||
this.autoRefreshInterval = setInterval(() => this.onRefresh(), 4 * 1000); // refresh data every 4 secs
|
||||
}
|
||||
},
|
||||
async resetFilters(): Promise<void> {
|
||||
this.filter.status = '';
|
||||
this.$emit('filterUpdated', this.prepareFilter());
|
||||
},
|
||||
prepareFilter(): object {
|
||||
return {
|
||||
finished: this.filter.status !== 'running',
|
||||
status: this.filter.status,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
flex: 310px 0 0;
|
||||
background-color: var(--color-background-xlight);
|
||||
border-right: var(--border-base);
|
||||
padding: var(--spacing-l) 0 var(--spacing-l) var(--spacing-l);
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding-right: var(--spacing-l);
|
||||
}
|
||||
|
||||
.controls {
|
||||
padding: var(--spacing-s) 0 var(--spacing-xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-right: var(--spacing-l);
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.executionList {
|
||||
height: calc(100% - 10.5em);
|
||||
overflow: auto;
|
||||
margin-bottom: var(--spacing-m);
|
||||
background-color: var(--color-background-xlight) !important;
|
||||
|
||||
// Scrolling fader
|
||||
&::before {
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 270px;
|
||||
height: 6px;
|
||||
background: linear-gradient(to bottom, rgba(251, 251, 251, 1) 0%, rgba(251, 251, 251, 0) 100%);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
// Lower first execution card so fader is not visible when not scrolled
|
||||
& > div:first-child {
|
||||
margin-top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.infoAccordion {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
margin-left: calc(-1 * var(--spacing-l));
|
||||
border-top: var(--border-base);
|
||||
|
||||
& > div {
|
||||
width: 309px;
|
||||
background-color: var(--color-background-light);
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,517 @@
|
|||
<template>
|
||||
<div :class="$style.container" v-if="!loading">
|
||||
<executions-sidebar
|
||||
v-if="showSidebar"
|
||||
:executions="executions"
|
||||
:loading="loading"
|
||||
:loadingMore="loadingMore"
|
||||
@reloadExecutions="setExecutions"
|
||||
@filterUpdated="onFilterUpdated"
|
||||
@loadMore="loadMore"
|
||||
@retryExecution="onRetryExecution"
|
||||
/>
|
||||
<div :class="$style.content" v-if="!hidePreview">
|
||||
<router-view name="executionPreview" @deleteCurrentExecution="onDeleteCurrentExecution" @retryExecution="onRetryExecution"/>
|
||||
</div>
|
||||
<div v-if="executions.length === 0 && filterApplied" :class="$style.noResultsContainer">
|
||||
<n8n-text color="text-base" size="medium" align="center">
|
||||
{{ $locale.baseText('executionsLandingPage.noResults') }}
|
||||
</n8n-text>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import ExecutionsSidebar from '@/components/ExecutionsView/ExecutionsSidebar.vue';
|
||||
import { MODAL_CANCEL, MODAL_CLOSE, MODAL_CONFIRMED, PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS, WEBHOOK_NODE_TYPE } from '@/constants';
|
||||
import { IExecutionsListResponse, IExecutionsSummary, INodeUi, ITag, IWorkflowDb } from '@/Interface';
|
||||
import { IConnection, IConnections, IDataObject, INodeTypeDescription, INodeTypeNameVersion, NodeHelpers } from 'n8n-workflow';
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import { restApi } from '../mixins/restApi';
|
||||
import { showMessage } from '../mixins/showMessage';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { Route } from 'vue-router';
|
||||
import { executionHelpers } from '../mixins/executionsHelpers';
|
||||
import { range as _range } from 'lodash';
|
||||
import { debounceHelper } from '../mixins/debounce';
|
||||
import { getNodeViewTab } from '../helpers';
|
||||
import { workflowHelpers } from '../mixins/workflowHelpers';
|
||||
|
||||
export default mixins(restApi, showMessage, executionHelpers, debounceHelper, workflowHelpers).extend({
|
||||
name: 'executions-page',
|
||||
components: {
|
||||
ExecutionsSidebar,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
filter: { finished: true, status: '' },
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hidePreview(): boolean {
|
||||
const nothingToShow = this.executions.length === 0 && this.filterApplied;
|
||||
const activeNotPresent = this.filterApplied && (this.executions as IExecutionsSummary[]).find(ex => ex.id === this.activeExecution.id) === undefined;
|
||||
return this.loading || nothingToShow || activeNotPresent;
|
||||
},
|
||||
showSidebar(): boolean {
|
||||
if (this.executions.length === 0) {
|
||||
return this.filterApplied;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
filterApplied(): boolean {
|
||||
return this.filter.status !== '';
|
||||
},
|
||||
workflowDataNotLoaded(): boolean {
|
||||
return this.$store.getters.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID && this.$store.getters.workflowName === '';
|
||||
},
|
||||
loadedFinishedExecutionsCount(): number {
|
||||
return (this.$store.getters['workflows/getAllLoadedFinishedExecutions'] as IExecutionsSummary[]).length;
|
||||
},
|
||||
totalFinishedExecutionsCount(): number {
|
||||
return this.$store.getters['workflows/getTotalFinishedExecutionsCount'];
|
||||
},
|
||||
},
|
||||
watch:{
|
||||
$route (to: Route, from: Route) {
|
||||
const workflowChanged = from.params.name !== to.params.name;
|
||||
this.initView(workflowChanged);
|
||||
|
||||
if (to.params.executionId) {
|
||||
const execution = this.$store.getters['workflows/getExecutionDataById'](to.params.executionId);
|
||||
if (execution) {
|
||||
this.$store.commit('workflows/setActiveWorkflowExecution', execution);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
async beforeRouteLeave(to, from, next) {
|
||||
const nextTab = getNodeViewTab(to);
|
||||
// When leaving for a page that's not a workflow view tab, ask to save changes
|
||||
if (!nextTab) {
|
||||
const result = this.$store.getters.getStateIsDirty;
|
||||
if (result) {
|
||||
const confirmModal = await this.confirmModal(
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
|
||||
'warning',
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
|
||||
true,
|
||||
);
|
||||
|
||||
if (confirmModal === MODAL_CONFIRMED) {
|
||||
const saved = await this.saveCurrentWorkflow({}, false);
|
||||
if (saved) this.$store.dispatch('settings/fetchPromptsData');
|
||||
this.$store.commit('setStateDirty', false);
|
||||
next();
|
||||
} else if (confirmModal === MODAL_CANCEL) {
|
||||
this.$store.commit('setStateDirty', false);
|
||||
next();
|
||||
} else if (confirmModal === MODAL_CLOSE) {
|
||||
next(false);
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
next();
|
||||
},
|
||||
async mounted() {
|
||||
this.loading = true;
|
||||
const workflowUpdated = this.$route.params.name !== this.$store.getters.workflowId;
|
||||
const onNewWorkflow = this.$route.params.name === 'new' && this.$store.getters.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID;
|
||||
const shouldUpdate = workflowUpdated && !onNewWorkflow;
|
||||
await this.initView(shouldUpdate);
|
||||
if (!shouldUpdate) {
|
||||
await this.setExecutions();
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
methods: {
|
||||
async initView(loadWorkflow: boolean) : Promise<void> {
|
||||
if (loadWorkflow) {
|
||||
if (this.$store.getters['nodeTypes/allNodeTypes'].length === 0) {
|
||||
await this.$store.dispatch('nodeTypes/getNodeTypes');
|
||||
}
|
||||
await this.openWorkflow(this.$route.params.name);
|
||||
this.$store.commit('ui/setNodeViewInitialized', false);
|
||||
this.setExecutions();
|
||||
if (this.activeExecution) {
|
||||
this.$router.push({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: this.currentWorkflow, executionId: this.activeExecution.id },
|
||||
}).catch(()=>{});;
|
||||
}
|
||||
}
|
||||
},
|
||||
async onLoadMore(): Promise<void> {
|
||||
if (!this.loadingMore) {
|
||||
this.callDebounced("loadMore", { debounceTime: 1000 });
|
||||
}
|
||||
},
|
||||
async loadMore(): Promise<void> {
|
||||
if (this.filter.status === 'running' || this.loadedFinishedExecutionsCount >= this.totalFinishedExecutionsCount) {
|
||||
return;
|
||||
}
|
||||
this.loadingMore = true;
|
||||
|
||||
let lastId: string | number | undefined;
|
||||
if (this.executions.length !== 0) {
|
||||
const lastItem = this.executions.slice(-1)[0];
|
||||
lastId = lastItem.id;
|
||||
}
|
||||
|
||||
const requestFilter: IDataObject = { workflowId: this.currentWorkflow };
|
||||
if (this.filter.status === 'waiting') {
|
||||
requestFilter.waitTill = true;
|
||||
} else if (this.filter.status !== '') {
|
||||
requestFilter.finished = this.filter.status === 'success';
|
||||
}
|
||||
let data: IExecutionsListResponse;
|
||||
try {
|
||||
data = await this.restApi().getPastExecutions(requestFilter, 20, lastId);
|
||||
} catch (error) {
|
||||
this.loadingMore = false;
|
||||
this.$showError(
|
||||
error,
|
||||
this.$locale.baseText('executionsList.showError.loadMore.title'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
data.results = data.results.map((execution) => {
|
||||
// @ts-ignore
|
||||
return { ...execution, mode: execution.mode };
|
||||
});
|
||||
const currentExecutions = [ ...this.executions ];
|
||||
for (const newExecution of data.results) {
|
||||
if (currentExecutions.find(ex => ex.id === newExecution.id) === undefined) {
|
||||
currentExecutions.push(newExecution);
|
||||
}
|
||||
}
|
||||
this.$store.commit('workflows/setCurrentWorkflowExecutions', currentExecutions);
|
||||
this.loadingMore = false;
|
||||
},
|
||||
async onDeleteCurrentExecution(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
await this.restApi().deleteExecutions({ ids: [ this.$route.params.executionId ] });
|
||||
await this.setExecutions();
|
||||
// Select first execution in the list after deleting the current one
|
||||
if (this.executions.length > 0) {
|
||||
this.$store.commit('workflows/setActiveWorkflowExecution', this.executions[0]);
|
||||
this.$router.push({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: this.currentWorkflow, executionId: this.executions[0].id },
|
||||
}).catch(()=>{});;
|
||||
} else { // If there are no executions left, show empty state and clear active execution from the store
|
||||
this.$store.commit('workflows/setActiveWorkflowExecution', null);
|
||||
this.$router.push({ name: VIEWS.EXECUTION_HOME, params: { name: this.currentWorkflow } });
|
||||
}
|
||||
} catch (error) {
|
||||
this.loading = false;
|
||||
this.$showError(
|
||||
error,
|
||||
this.$locale.baseText('executionsList.showError.handleDeleteSelected.title'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.loading = false;
|
||||
|
||||
this.$showMessage({
|
||||
title: this.$locale.baseText('executionsList.showMessage.handleDeleteSelected.title'),
|
||||
type: 'success',
|
||||
});
|
||||
},
|
||||
onFilterUpdated(newFilter: { finished: boolean, status: string }): void {
|
||||
this.filter = newFilter;
|
||||
this.setExecutions();
|
||||
},
|
||||
async setExecutions(): Promise<void> {
|
||||
const workflowExecutions = await this.loadExecutions();
|
||||
this.$store.commit('workflows/setCurrentWorkflowExecutions', workflowExecutions);
|
||||
this.setActiveExecution();
|
||||
},
|
||||
async loadAutoRefresh(): Promise<void> {
|
||||
// Most of the auto-refresh logic is taken from the `ExecutionsList` component
|
||||
const fetchedExecutions: IExecutionsSummary[] = await this.loadExecutions();
|
||||
let existingExecutions: IExecutionsSummary[] = [ ...this.executions ];
|
||||
const alreadyPresentExecutionIds = existingExecutions.map(exec => parseInt(exec.id, 10));
|
||||
let lastId = 0;
|
||||
const gaps = [] as number[];
|
||||
|
||||
for(let i = fetchedExecutions.length - 1; i >= 0; i--) {
|
||||
const currentItem = fetchedExecutions[i];
|
||||
const currentId = parseInt(currentItem.id, 10);
|
||||
if (lastId !== 0 && isNaN(currentId) === false) {
|
||||
if (currentId - lastId > 1) {
|
||||
const range = _range(lastId + 1, currentId);
|
||||
gaps.push(...range);
|
||||
}
|
||||
}
|
||||
lastId = parseInt(currentItem.id, 10) || 0;
|
||||
|
||||
const executionIndex = alreadyPresentExecutionIds.indexOf(currentId);
|
||||
if (executionIndex !== -1) {
|
||||
const existingExecution = existingExecutions.find(ex => ex.id === currentItem.id);
|
||||
const existingStillRunning = existingExecution && existingExecution.finished === false || existingExecution?.stoppedAt === undefined;
|
||||
const currentFinished = currentItem.finished === true || currentItem.stoppedAt !== undefined;
|
||||
|
||||
if (existingStillRunning && currentFinished) {
|
||||
existingExecutions[executionIndex] = currentItem;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let j;
|
||||
for (j = existingExecutions.length - 1; j >= 0; j--) {
|
||||
if (currentId < parseInt(existingExecutions[j].id, 10)) {
|
||||
existingExecutions.splice(j + 1, 0, currentItem);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (j === -1) {
|
||||
existingExecutions.unshift(currentItem);
|
||||
}
|
||||
}
|
||||
|
||||
existingExecutions = existingExecutions.filter(execution => !gaps.includes(parseInt(execution.id, 10)) && lastId >= parseInt(execution.id, 10));
|
||||
this.$store.commit('workflows/setCurrentWorkflowExecutions', existingExecutions);
|
||||
},
|
||||
async loadExecutions(): Promise<IExecutionsSummary[]> {
|
||||
if (!this.currentWorkflow) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const executions: IExecutionsSummary[] =
|
||||
await this.$store.dispatch('workflows/loadCurrentWorkflowExecutions', this.filter);
|
||||
return executions;
|
||||
} catch (error) {
|
||||
this.$showError(
|
||||
error,
|
||||
this.$locale.baseText('executionsList.showError.refreshData.title'),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
setActiveExecution(): void {
|
||||
const activeExecutionId = this.$route.params.executionId;
|
||||
if (activeExecutionId) {
|
||||
const execution = this.$store.getters['workflows/getExecutionDataById'](activeExecutionId);
|
||||
if (execution) {
|
||||
this.$store.commit('workflows/setActiveWorkflowExecution', execution);
|
||||
}
|
||||
}
|
||||
// If there is no execution in the route, select the first one
|
||||
if (this.$store.getters['workflows/getActiveWorkflowExecution'] === null && this.executions.length > 0) {
|
||||
this.$store.commit('workflows/setActiveWorkflowExecution', this.executions[0]);
|
||||
this.$router.push({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: this.currentWorkflow, executionId: this.executions[0].id },
|
||||
}).catch(()=>{});;
|
||||
}
|
||||
},
|
||||
async openWorkflow(workflowId: string): Promise<void> {
|
||||
await this.loadActiveWorkflows();
|
||||
|
||||
let data: IWorkflowDb | undefined;
|
||||
try {
|
||||
data = await this.restApi().getWorkflow(workflowId);
|
||||
} catch (error) {
|
||||
this.$showError(
|
||||
error,
|
||||
this.$locale.baseText('nodeView.showError.openWorkflow.title'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (data === undefined) {
|
||||
throw new Error(
|
||||
this.$locale.baseText(
|
||||
'nodeView.workflowWithIdCouldNotBeFound',
|
||||
{ interpolate: { workflowId } },
|
||||
),
|
||||
);
|
||||
}
|
||||
await this.addNodes(data.nodes, data.connections);
|
||||
|
||||
this.$store.commit('setActive', data.active || false);
|
||||
this.$store.commit('setWorkflowId', workflowId);
|
||||
this.$store.commit('setWorkflowName', { newName: data.name, setStateDirty: false });
|
||||
this.$store.commit('setWorkflowSettings', data.settings || {});
|
||||
this.$store.commit('setWorkflowPinData', data.pinData || {});
|
||||
const tags = (data.tags || []) as ITag[];
|
||||
this.$store.commit('tags/upsertTags', tags);
|
||||
const tagIds = tags.map((tag) => tag.id);
|
||||
this.$store.commit('setWorkflowTagIds', tagIds || []);
|
||||
|
||||
this.$externalHooks().run('workflow.open', { workflowId, workflowName: data.name });
|
||||
this.$store.commit('setStateDirty', false);
|
||||
},
|
||||
async addNodes(nodes: INodeUi[], connections?: IConnections) {
|
||||
if (!nodes || !nodes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadNodesProperties(nodes.map(node => ({ name: node.type, version: node.typeVersion })));
|
||||
|
||||
let nodeType: INodeTypeDescription | null;
|
||||
nodes.forEach((node) => {
|
||||
if (!node.id) {
|
||||
node.id = uuid();
|
||||
}
|
||||
|
||||
nodeType = this.$store.getters['nodeTypes/getNodeType'](node.type, node.typeVersion) as INodeTypeDescription | null;
|
||||
|
||||
// Make sure that some properties always exist
|
||||
if (!node.hasOwnProperty('disabled')) {
|
||||
node.disabled = false;
|
||||
}
|
||||
|
||||
if (!node.hasOwnProperty('parameters')) {
|
||||
node.parameters = {};
|
||||
}
|
||||
|
||||
// Load the defaul parameter values because only values which differ
|
||||
// from the defaults get saved
|
||||
if (nodeType !== null) {
|
||||
let nodeParameters = null;
|
||||
try {
|
||||
nodeParameters = NodeHelpers.getNodeParameters(nodeType.properties, node.parameters, true, false, node);
|
||||
} catch (e) {
|
||||
console.error(this.$locale.baseText('nodeView.thereWasAProblemLoadingTheNodeParametersOfNode') + `: "${node.name}"`); // eslint-disable-line no-console
|
||||
console.error(e); // eslint-disable-line no-console
|
||||
}
|
||||
node.parameters = nodeParameters !== null ? nodeParameters : {};
|
||||
|
||||
// if it's a webhook and the path is empty set the UUID as the default path
|
||||
if (node.type === WEBHOOK_NODE_TYPE && node.parameters.path === '') {
|
||||
node.parameters.path = node.webhookId as string;
|
||||
}
|
||||
}
|
||||
|
||||
this.$store.commit('addNode', node);
|
||||
});
|
||||
|
||||
// Load the connections
|
||||
if (connections !== undefined) {
|
||||
let connectionData;
|
||||
for (const sourceNode of Object.keys(connections)) {
|
||||
for (const type of Object.keys(connections[sourceNode])) {
|
||||
for (let sourceIndex = 0; sourceIndex < connections[sourceNode][type].length; sourceIndex++) {
|
||||
const outwardConnections = connections[sourceNode][type][sourceIndex];
|
||||
if (!outwardConnections) {
|
||||
continue;
|
||||
}
|
||||
outwardConnections.forEach((
|
||||
targetData,
|
||||
) => {
|
||||
connectionData = [
|
||||
{
|
||||
node: sourceNode,
|
||||
type,
|
||||
index: sourceIndex,
|
||||
},
|
||||
{
|
||||
node: targetData.node,
|
||||
type: targetData.type,
|
||||
index: targetData.index,
|
||||
},
|
||||
] as [IConnection, IConnection];
|
||||
|
||||
this.$store.commit('addConnection', { connection: connectionData, setStateDirty: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
|
||||
const allNodes: INodeTypeDescription[] = this.$store.getters['nodeTypes/allNodeTypes'];
|
||||
|
||||
const nodesToBeFetched: INodeTypeNameVersion[] = [];
|
||||
allNodes.forEach(node => {
|
||||
const nodeVersions = Array.isArray(node.version) ? node.version : [node.version];
|
||||
if (!!nodeInfos.find(n => n.name === node.name && nodeVersions.includes(n.version)) && !node.hasOwnProperty('properties')) {
|
||||
nodesToBeFetched.push({
|
||||
name: node.name,
|
||||
version: Array.isArray(node.version)
|
||||
? node.version.slice(-1)[0]
|
||||
: node.version,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (nodesToBeFetched.length > 0) {
|
||||
// Only call API if node information is actually missing
|
||||
await this.$store.dispatch('nodeTypes/getNodesInformation', nodesToBeFetched);
|
||||
}
|
||||
},
|
||||
async loadActiveWorkflows(): Promise<void> {
|
||||
const activeWorkflows = await this.restApi().getActiveWorkflows();
|
||||
this.$store.commit('setActiveWorkflows', activeWorkflows);
|
||||
},
|
||||
async onRetryExecution(payload: { execution: IExecutionsSummary, command: string }) {
|
||||
const loadWorkflow = payload.command === 'current-workflow';
|
||||
|
||||
this.$showMessage({
|
||||
title: this.$locale.baseText('executionDetails.runningMessage'),
|
||||
type: 'info',
|
||||
duration: 2000,
|
||||
});
|
||||
await this.retryExecution(payload.execution, loadWorkflow);
|
||||
this.loadAutoRefresh();
|
||||
|
||||
this.$telemetry.track('User clicked retry execution button', {
|
||||
workflow_id: this.$store.getters.workflowId,
|
||||
execution_id: payload.execution.id,
|
||||
retry_type: loadWorkflow ? 'current' : 'original',
|
||||
});
|
||||
},
|
||||
async retryExecution(execution: IExecutionsSummary, loadWorkflow?: boolean) {
|
||||
try {
|
||||
const retrySuccessful = await this.restApi().retryExecution(execution.id, loadWorkflow);
|
||||
|
||||
if (retrySuccessful === true) {
|
||||
this.$showMessage({
|
||||
title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulTrue.title'),
|
||||
type: 'success',
|
||||
});
|
||||
} else {
|
||||
this.$showMessage({
|
||||
title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulFalse.title'),
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.$showError(
|
||||
error,
|
||||
this.$locale.baseText('executionsList.showError.retryExecution.title'),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.noResultsContainer {
|
||||
width: 100%;
|
||||
margin-top: var(--spacing-2xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
</style>
|
|
@ -4,6 +4,7 @@
|
|||
<div v-show="!hideMenuBar" class="top-menu">
|
||||
<ExecutionDetails v-if="isExecutionPage" />
|
||||
<WorkflowDetails v-else />
|
||||
<tab-bar v-if="onWorkflowPage && !isExecutionPage" :items="tabBarItems" :activeTab="activeHeaderTab" @select="onTabSelected"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -12,27 +13,42 @@
|
|||
<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';
|
||||
import { STICKY_NODE_TYPE, VIEWS } from '@/constants';
|
||||
import { INodeUi } from '@/Interface';
|
||||
import TabBar from '@/components/MainHeader/TabBar.vue';
|
||||
import { MAIN_HEADER_TABS, PLACEHOLDER_EMPTY_WORKFLOW_ID, STICKY_NODE_TYPE, VIEWS } from '@/constants';
|
||||
import { IExecutionsSummary, INodeUi, ITabBarItem } from '@/Interface';
|
||||
import { workflowHelpers } from '../mixins/workflowHelpers';
|
||||
import { Route } from 'vue-router';
|
||||
|
||||
export default mixins(
|
||||
pushConnection,
|
||||
)
|
||||
.extend({
|
||||
workflowHelpers,
|
||||
).extend({
|
||||
name: 'MainHeader',
|
||||
components: {
|
||||
WorkflowDetails,
|
||||
ExecutionDetails,
|
||||
TabBar,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeHeaderTab: MAIN_HEADER_TABS.WORKFLOW,
|
||||
workflowToReturnTo: '',
|
||||
dirtyState: this.$store.getters.getStateIsDirty,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('ui', [
|
||||
'sidebarMenuCollapsed',
|
||||
]),
|
||||
tabBarItems(): ITabBarItem[] {
|
||||
return [
|
||||
{ value: MAIN_HEADER_TABS.WORKFLOW, label: this.$locale.baseText('generic.workflow') },
|
||||
{ value: MAIN_HEADER_TABS.EXECUTIONS, label: this.$locale.baseText('generic.executions') },
|
||||
];
|
||||
},
|
||||
isExecutionPage (): boolean {
|
||||
return this.$route.name === VIEWS.EXECUTION;
|
||||
},
|
||||
|
@ -42,14 +58,82 @@ export default mixins(
|
|||
hideMenuBar(): boolean {
|
||||
return Boolean(this.activeNode && this.activeNode.type !== STICKY_NODE_TYPE);
|
||||
},
|
||||
workflowName (): string {
|
||||
return this.$store.getters.workflowName;
|
||||
},
|
||||
currentWorkflow (): string {
|
||||
return this.$route.params.name || this.$store.getters.workflowId;
|
||||
},
|
||||
onWorkflowPage(): boolean {
|
||||
return this.$route.meta && (this.$route.meta.nodeView || this.$route.meta.keepWorkflowAlive === true);
|
||||
},
|
||||
activeExecution(): IExecutionsSummary {
|
||||
return this.$store.getters['workflows/getActiveWorkflowExecution'];
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
mounted() {
|
||||
this.syncTabsWithRoute(this.$route);
|
||||
// Initialize the push connection
|
||||
this.pushConnect();
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.pushDisconnect();
|
||||
},
|
||||
watch: {
|
||||
$route (to, from){
|
||||
this.syncTabsWithRoute(to);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
syncTabsWithRoute(route: Route): void {
|
||||
if (route.name === VIEWS.EXECUTION_HOME || route.name === VIEWS.EXECUTIONS || route.name === VIEWS.EXECUTION_PREVIEW) {
|
||||
this.activeHeaderTab = MAIN_HEADER_TABS.EXECUTIONS;
|
||||
} else if (route.name === VIEWS.WORKFLOW || route.name === VIEWS.NEW_WORKFLOW) {
|
||||
this.activeHeaderTab = MAIN_HEADER_TABS.WORKFLOW;
|
||||
}
|
||||
const workflowName = route.params.name;
|
||||
if (workflowName !== 'new') {
|
||||
this.workflowToReturnTo = workflowName;
|
||||
}
|
||||
},
|
||||
onTabSelected(tab: string, event: MouseEvent) {
|
||||
switch (tab) {
|
||||
case MAIN_HEADER_TABS.WORKFLOW:
|
||||
if (!['', 'new', PLACEHOLDER_EMPTY_WORKFLOW_ID].includes(this.workflowToReturnTo)) {
|
||||
if (this.$route.name !== VIEWS.WORKFLOW) {
|
||||
this.$router.push({
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: { name: this.workflowToReturnTo },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (this.$route.name !== VIEWS.NEW_WORKFLOW) {
|
||||
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
|
||||
this.$store.commit('setStateDirty', this.dirtyState);
|
||||
}
|
||||
}
|
||||
this.activeHeaderTab = MAIN_HEADER_TABS.WORKFLOW;
|
||||
break;
|
||||
case MAIN_HEADER_TABS.EXECUTIONS:
|
||||
this.dirtyState = this.$store.getters.getStateIsDirty;
|
||||
this.workflowToReturnTo = this.currentWorkflow;
|
||||
const routeWorkflowId = this.currentWorkflow === PLACEHOLDER_EMPTY_WORKFLOW_ID ? 'new' : this.currentWorkflow;
|
||||
if (this.activeExecution) {
|
||||
this.$router.push({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: routeWorkflowId, executionId: this.activeExecution.id },
|
||||
}).catch(()=>{});;
|
||||
} else {
|
||||
this.$router.push({ name: VIEWS.EXECUTION_HOME, params: { name: routeWorkflowId } });
|
||||
}
|
||||
// this.modalBus.$emit('closeAll');
|
||||
this.activeHeaderTab = MAIN_HEADER_TABS.EXECUTIONS;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -68,6 +152,6 @@ export default mixins(
|
|||
font-size: 0.9em;
|
||||
height: $header-height;
|
||||
font-weight: 400;
|
||||
padding: 0 20px;
|
||||
padding: 0 var(--spacing-m) 0 var(--spacing-xs);
|
||||
}
|
||||
</style>
|
||||
|
|
70
packages/editor-ui/src/components/MainHeader/TabBar.vue
Normal file
70
packages/editor-ui/src/components/MainHeader/TabBar.vue
Normal file
|
@ -0,0 +1,70 @@
|
|||
<template>
|
||||
<div v-if="items" :class="{[$style.container]: true, ['tab-bar-container']: true, [$style.menuCollapsed]: mainSidebarCollapsed}">
|
||||
<n8n-radio-buttons
|
||||
:value="activeTab"
|
||||
:options="items"
|
||||
@input="onSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue, { PropType } from 'vue';
|
||||
import { ITabBarItem } from '@/Interface';
|
||||
import { MAIN_HEADER_TABS } from '@/constants';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'tab-bar',
|
||||
data() {
|
||||
return {
|
||||
MAIN_HEADER_TABS,
|
||||
};
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
type: Array as PropType<ITabBarItem[]>,
|
||||
required: true,
|
||||
},
|
||||
activeTab: {
|
||||
type: String,
|
||||
default: MAIN_HEADER_TABS.WORKFLOW,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
mainSidebarCollapsed(): boolean {
|
||||
return this.$store.getters['ui/sidebarMenuCollapsed'];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onSelect(tab: string, event: MouseEvent): void {
|
||||
this.$emit('select', tab, event);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
.container {
|
||||
position: absolute;
|
||||
top: 47px;
|
||||
left: calc(50% + 100px);
|
||||
transform: translateX(-50%);
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
padding: var(--spacing-5xs);
|
||||
background-color: var(--color-foreground-base);
|
||||
border-radius: var(--border-radius-base);
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
&.menuCollapsed {
|
||||
left: 52%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 430px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -86,6 +86,7 @@ import { mapGetters } from "vuex";
|
|||
import {
|
||||
DUPLICATE_MODAL_KEY,
|
||||
MAX_WORKFLOW_NAME_LENGTH,
|
||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
VIEWS, WORKFLOW_MENU_ACTIONS,
|
||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||
} from "@/constants";
|
||||
|
@ -145,29 +146,29 @@ export default mixins(workflowHelpers, titleChange).extend({
|
|||
}),
|
||||
...mapGetters('settings', ['areTagsEnabled']),
|
||||
isNewWorkflow(): boolean {
|
||||
return !this.$route.params.name;
|
||||
return !this.currentWorkflowId || (this.currentWorkflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID || this.currentWorkflowId === 'new');
|
||||
},
|
||||
isWorkflowSaving(): boolean {
|
||||
return this.$store.getters.isActionActive("workflowSaving");
|
||||
},
|
||||
currentWorkflowId(): string {
|
||||
return this.$route.params.name;
|
||||
},
|
||||
currentWorkflow (): string {
|
||||
return this.$route.params.name;
|
||||
return this.$store.getters.workflowId;
|
||||
},
|
||||
workflowName (): string {
|
||||
return this.$store.getters.workflowName;
|
||||
},
|
||||
onWorkflowPage(): boolean {
|
||||
return this.$route.meta && this.$route.meta.nodeView;
|
||||
return this.$route.meta && (this.$route.meta.nodeView || this.$route.meta.keepWorkflowAlive === true);
|
||||
},
|
||||
onExecutionsTab(): boolean {
|
||||
return [ VIEWS.EXECUTION_HOME.toString(), VIEWS.EXECUTIONS.toString(), VIEWS.EXECUTION_PREVIEW ].includes(this.$route.name || '');
|
||||
},
|
||||
workflowMenuItems(): Array<{}> {
|
||||
return [
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
|
||||
label: this.$locale.baseText('menuActions.duplicate'),
|
||||
disabled: !this.onWorkflowPage || !this.currentWorkflow,
|
||||
disabled: !this.onWorkflowPage || !this.currentWorkflowId,
|
||||
},
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.DOWNLOAD,
|
||||
|
@ -177,22 +178,22 @@ export default mixins(workflowHelpers, titleChange).extend({
|
|||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL,
|
||||
label: this.$locale.baseText('menuActions.importFromUrl'),
|
||||
disabled: !this.onWorkflowPage,
|
||||
disabled: !this.onWorkflowPage || this.onExecutionsTab,
|
||||
},
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE,
|
||||
label: this.$locale.baseText('menuActions.importFromFile'),
|
||||
disabled: !this.onWorkflowPage,
|
||||
disabled: !this.onWorkflowPage || this.onExecutionsTab,
|
||||
},
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.SETTINGS,
|
||||
label: this.$locale.baseText('generic.settings'),
|
||||
disabled: !this.onWorkflowPage || !this.currentWorkflow,
|
||||
disabled: !this.onWorkflowPage || this.isNewWorkflow,
|
||||
},
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.DELETE,
|
||||
label: this.$locale.baseText('menuActions.delete'),
|
||||
disabled: !this.onWorkflowPage || !this.currentWorkflow,
|
||||
disabled: !this.onWorkflowPage || this.isNewWorkflow,
|
||||
customClass: this.$style.deleteItem,
|
||||
divided: true,
|
||||
},
|
||||
|
@ -201,7 +202,13 @@ export default mixins(workflowHelpers, titleChange).extend({
|
|||
},
|
||||
methods: {
|
||||
async onSaveButtonClick () {
|
||||
const saved = await this.saveCurrentWorkflow();
|
||||
let currentId = undefined;
|
||||
if (this.currentWorkflowId !== PLACEHOLDER_EMPTY_WORKFLOW_ID) {
|
||||
currentId = this.currentWorkflowId;
|
||||
} else if (this.$route.params.name && this.$route.params.name !== 'new') {
|
||||
currentId = this.$route.params.name;
|
||||
}
|
||||
const saved = await this.saveCurrentWorkflow({ id: currentId, name: this.workflowName, tags: this.currentWorkflowTagIds });
|
||||
if (saved) this.$store.dispatch('settings/fetchPromptsData');
|
||||
},
|
||||
onTagsEditEnable() {
|
||||
|
@ -389,7 +396,7 @@ export default mixins(workflowHelpers, titleChange).extend({
|
|||
}
|
||||
|
||||
try {
|
||||
await this.restApi().deleteWorkflow(this.currentWorkflow);
|
||||
await this.restApi().deleteWorkflow(this.currentWorkflowId);
|
||||
} catch (error) {
|
||||
this.$showError(
|
||||
error,
|
||||
|
|
|
@ -78,6 +78,8 @@ import {
|
|||
VERSIONS_MODAL_KEY,
|
||||
EXECUTIONS_MODAL_KEY,
|
||||
VIEWS,
|
||||
WORKFLOW_OPEN_MODAL_KEY,
|
||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
} from '@/constants';
|
||||
import { userHelpers } from './mixins/userHelpers';
|
||||
import { debounceHelper } from './mixins/debounce';
|
||||
|
@ -194,7 +196,7 @@ export default mixins(
|
|||
{
|
||||
id: 'executions',
|
||||
icon: 'tasks',
|
||||
label: this.$locale.baseText('mainSidebar.executions'),
|
||||
label: this.$locale.baseText('generic.executions'),
|
||||
position: 'top',
|
||||
},
|
||||
{
|
||||
|
@ -267,7 +269,11 @@ export default mixins(
|
|||
if (this.$refs.user) {
|
||||
this.$externalHooks().run('mainSidebar.mounted', { userRef: this.$refs.user });
|
||||
}
|
||||
this.checkWidthAndAdjustSidebar(window.innerWidth);
|
||||
if (window.innerWidth < 900 || this.isNodeView) {
|
||||
this.$store.commit('ui/collapseSidebarMenu');
|
||||
} else {
|
||||
this.$store.commit('ui/expandSidebarMenu');
|
||||
}
|
||||
await Vue.nextTick();
|
||||
this.fullyExpanded = !this.isCollapsed;
|
||||
},
|
||||
|
@ -394,6 +400,7 @@ export default mixins(
|
|||
if (this.$router.currentRoute.name === VIEWS.NEW_WORKFLOW) {
|
||||
this.$root.$emit('newWorkflow');
|
||||
} else {
|
||||
this.$store.commit('setWorkflowId', PLACEHOLDER_EMPTY_WORKFLOW_ID);
|
||||
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
|
||||
}
|
||||
this.$showMessage({
|
||||
|
@ -405,6 +412,7 @@ export default mixins(
|
|||
}
|
||||
} else {
|
||||
if (this.$router.currentRoute.name !== VIEWS.NEW_WORKFLOW) {
|
||||
this.$store.commit('setWorkflowId', PLACEHOLDER_EMPTY_WORKFLOW_ID);
|
||||
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
|
||||
}
|
||||
this.$showMessage({
|
||||
|
@ -438,10 +446,11 @@ export default mixins(
|
|||
this.checkWidthAndAdjustSidebar(browserWidth);
|
||||
},
|
||||
checkWidthAndAdjustSidebar (width: number) {
|
||||
if (width < 900 || this.isNodeView) {
|
||||
if (width < 900) {
|
||||
this.$store.commit('ui/collapseSidebarMenu');
|
||||
} else {
|
||||
this.$store.commit('ui/expandSidebarMenu');
|
||||
Vue.nextTick(() => {
|
||||
this.fullyExpanded = !this.isCollapsed;
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -374,9 +374,11 @@ export default mixins(
|
|||
},
|
||||
mounted() {
|
||||
this.setSubtitle();
|
||||
setTimeout(() => {
|
||||
this.$emit('run', {name: this.data && this.data.name, data: this.nodeRunData, waiting: !!this.waiting});
|
||||
}, 0);
|
||||
if (this.nodeRunData) {
|
||||
setTimeout(() => {
|
||||
this.$emit('run', {name: this.data && this.data.name, data: this.nodeRunData, waiting: !!this.waiting});
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
|
|
|
@ -1,9 +1,15 @@
|
|||
<template>
|
||||
<div :class="$style.container">
|
||||
<n8n-loading :loading="!showPreview" :rows="1" variant="image" />
|
||||
<div v-if="loaderType === 'image' && !showPreview" :class="$style.imageLoader">
|
||||
<n8n-loading :loading="!showPreview" :rows="1" variant="image" />
|
||||
</div>
|
||||
<div v-else-if="loaderType === 'spinner' && !showPreview" :class="$style.spinner">
|
||||
<n8n-spinner type="dots" />
|
||||
</div>
|
||||
<iframe
|
||||
:class="{
|
||||
[$style.workflow]: !this.nodeViewDetailsOpened,
|
||||
[$style.executionPreview]: mode === 'execution',
|
||||
[$style.openNDV]: this.nodeViewDetailsOpened,
|
||||
[$style.show]: this.showPreview,
|
||||
}"
|
||||
|
@ -18,10 +24,36 @@
|
|||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import { showMessage } from '@/components/mixins/showMessage';
|
||||
import { IWorkflowDb } from '../Interface';
|
||||
|
||||
export default mixins(showMessage).extend({
|
||||
name: 'WorkflowPreview',
|
||||
props: ['loading', 'workflow'],
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'workflow',
|
||||
validator: (value: string): boolean =>
|
||||
['workflow', 'execution', 'medium'].includes(value),
|
||||
},
|
||||
workflow: {
|
||||
type: Object as () => IWorkflowDb,
|
||||
required: false,
|
||||
},
|
||||
executionId: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
loaderType: {
|
||||
type: String,
|
||||
default: 'image',
|
||||
validator: (value: string): boolean =>
|
||||
['image', 'spinner'].includes(value),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
nodeViewDetailsOpened: false,
|
||||
|
@ -33,7 +65,12 @@ export default mixins(showMessage).extend({
|
|||
},
|
||||
computed: {
|
||||
showPreview(): boolean {
|
||||
return !this.loading && !!this.workflow && this.ready;
|
||||
return !this.loading &&
|
||||
(
|
||||
(this.mode === 'workflow' && !!this.workflow) ||
|
||||
(this.mode === 'execution' && !!this.executionId)
|
||||
) &&
|
||||
this.ready;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
@ -72,6 +109,29 @@ export default mixins(showMessage).extend({
|
|||
);
|
||||
}
|
||||
},
|
||||
loadExecution() {
|
||||
try {
|
||||
if (!this.executionId) {
|
||||
throw new Error(this.$locale.baseText('workflowPreview.showError.missingExecution'));
|
||||
}
|
||||
const iframe = this.$refs.preview_iframe as HTMLIFrameElement;
|
||||
if (iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
JSON.stringify({
|
||||
command: 'openExecution',
|
||||
executionId: this.executionId,
|
||||
}),
|
||||
'*',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.$showError(
|
||||
error,
|
||||
this.$locale.baseText('workflowPreview.showError.previewError.title'),
|
||||
this.$locale.baseText('workflowPreview.executionMode.showError.previewError.message'),
|
||||
);
|
||||
}
|
||||
},
|
||||
receiveMessage({ data }: MessageEvent) {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
|
@ -96,7 +156,16 @@ export default mixins(showMessage).extend({
|
|||
watch: {
|
||||
showPreview(show) {
|
||||
if (show) {
|
||||
this.loadWorkflow();
|
||||
if (this.mode === 'workflow') {
|
||||
this.loadWorkflow();
|
||||
} else if (this.mode === 'execution') {
|
||||
this.loadExecution();
|
||||
}
|
||||
}
|
||||
},
|
||||
executionId(value) {
|
||||
if (this.mode === 'execution' && this.executionId) {
|
||||
this.loadExecution();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
@ -114,13 +183,12 @@ export default mixins(showMessage).extend({
|
|||
<style lang="scss" module>
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.workflow {
|
||||
border: var(--border-base);
|
||||
border-radius: var(--border-radius-large);
|
||||
|
||||
// firefox bug requires loading iframe as such
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
|
@ -141,4 +209,20 @@ export default mixins(showMessage).extend({
|
|||
width: 100%;
|
||||
z-index: 9999999;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
color: var(--color-primary);
|
||||
position: absolute;
|
||||
top: 50% !important;
|
||||
-ms-transform: translateY(-50%);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.imageLoader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.executionPreview {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -187,7 +187,7 @@ import {
|
|||
IWorkflowShortResponse,
|
||||
} from '@/Interface';
|
||||
import Modal from './Modal.vue';
|
||||
import { WORKFLOW_SETTINGS_MODAL_KEY } from '../constants';
|
||||
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, WORKFLOW_SETTINGS_MODAL_KEY } from '../constants';
|
||||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
|
@ -244,7 +244,7 @@ export default mixins(
|
|||
},
|
||||
|
||||
async mounted () {
|
||||
if (this.$route.params.name === undefined) {
|
||||
if (!this.workflowId || this.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
|
||||
this.$showMessage({
|
||||
title: 'No workflow active',
|
||||
message: `No workflow active to display settings of.`,
|
||||
|
@ -519,7 +519,7 @@ export default mixins(
|
|||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
await this.restApi().updateWorkflow(this.$route.params.name, data);
|
||||
await this.restApi().updateWorkflow(this.workflowId, data);
|
||||
} catch (error) {
|
||||
this.$showError(
|
||||
error,
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, MAPPING_PARAMS, TEMPLATES_NODES_FILTER, NON_ACTIVATABLE_TRIGGER_NODE_TYPES } from '@/constants';
|
||||
import { CORE_NODES_CATEGORY, MAIN_HEADER_TABS, MAPPING_PARAMS, TEMPLATES_NODES_FILTER, VIEWS, NON_ACTIVATABLE_TRIGGER_NODE_TYPES } from '@/constants';
|
||||
import { INodeUi, ITemplatesNode } from '@/Interface';
|
||||
import { isResourceLocatorValue } from '@/typeGuards';
|
||||
import dateformat from 'dateformat';
|
||||
import {IDataObject, INodeProperties, INodeTypeDescription, NodeParameterValueType,INodeExecutionData, jsonParse} from 'n8n-workflow';
|
||||
import { isJsonKeyObject } from "@/utils";
|
||||
import { Route } from 'vue-router';
|
||||
|
||||
const CRED_KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
|
||||
const NODE_KEYWORDS_TO_FILTER = ['Trigger'];
|
||||
|
@ -175,3 +176,21 @@ export const clearJsonKey = (userInput: string | object) => {
|
|||
|
||||
return parsedUserInput.map(item => isJsonKeyObject(item) ? item.json : item);
|
||||
};
|
||||
|
||||
export const getNodeViewTab = (route: Route): string|null => {
|
||||
const routeMeta = route.meta;
|
||||
if (routeMeta && routeMeta.nodeView === true) {
|
||||
return MAIN_HEADER_TABS.WORKFLOW;
|
||||
} else {
|
||||
const executionTabRoutes = [
|
||||
VIEWS.EXECUTION.toString(),
|
||||
VIEWS.EXECUTION_PREVIEW.toString(),
|
||||
VIEWS.EXECUTION_HOME.toString(),
|
||||
];
|
||||
|
||||
if (executionTabRoutes.includes(route.name || '')) {
|
||||
return MAIN_HEADER_TABS.EXECUTIONS;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
import { IExecutionsSummary } from "@/Interface";
|
||||
import dateFormat from "dateformat";
|
||||
import mixins from "vue-typed-mixins";
|
||||
import { genericHelpers } from "./genericHelpers";
|
||||
|
||||
export interface IExecutionUIData {
|
||||
name: string;
|
||||
label: string;
|
||||
startTime: string;
|
||||
runningTime: string;
|
||||
}
|
||||
|
||||
export const executionHelpers = mixins(genericHelpers).extend({
|
||||
computed: {
|
||||
executionId(): string {
|
||||
return this.$route.params.executionId;
|
||||
},
|
||||
workflowName (): string {
|
||||
return this.$store.getters.workflowName;
|
||||
},
|
||||
currentWorkflow (): string {
|
||||
return this.$route.params.name || this.$store.getters.workflowId;
|
||||
},
|
||||
executions(): IExecutionsSummary[] {
|
||||
return this.$store.getters['workflows/currentWorkflowExecutions'];
|
||||
},
|
||||
activeExecution(): IExecutionsSummary {
|
||||
return this.$store.getters['workflows/getActiveWorkflowExecution'];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getExecutionUIDetails(execution: IExecutionsSummary): IExecutionUIData {
|
||||
const status = {
|
||||
name: 'unknown',
|
||||
startTime: this.formatDate(new Date(execution.startedAt)),
|
||||
label: 'Status unknown',
|
||||
runningTime: '',
|
||||
};
|
||||
|
||||
if (execution.waitTill) {
|
||||
status.name = 'waiting';
|
||||
status.label = this.$locale.baseText('executionsList.waiting');
|
||||
} else if (execution.stoppedAt === undefined) {
|
||||
status.name = 'running';
|
||||
status.label = this.$locale.baseText('executionsList.running');
|
||||
status.runningTime = this.displayTimer(new Date().getTime() - new Date(execution.startedAt).getTime(), true);
|
||||
} else if (execution.finished) {
|
||||
status.name = 'success';
|
||||
status.label = this.$locale.baseText('executionsList.succeeded');
|
||||
if (execution.stoppedAt) {
|
||||
status.runningTime = this.displayTimer(new Date(execution.stoppedAt).getTime() - new Date(execution.startedAt).getTime(), true);
|
||||
}
|
||||
} else if (execution.stoppedAt !== null) {
|
||||
status.name = 'error';
|
||||
status.label = this.$locale.baseText('executionsList.error');
|
||||
if (execution.stoppedAt) {
|
||||
status.runningTime = this.displayTimer(new Date(execution.stoppedAt).getTime() - new Date(execution.startedAt).getTime(), true);
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
},
|
||||
formatDate(date: Date) {
|
||||
if (date.getFullYear() === new Date().getFullYear()) {
|
||||
return dateFormat(date.getTime(), 'HH:MM:ss "on" d mmm');
|
||||
}
|
||||
return dateFormat(date.getTime(), 'HH:MM:ss "on" d mmm yyyy');
|
||||
},
|
||||
},
|
||||
});
|
|
@ -36,7 +36,7 @@ export const genericHelpers = mixins(showMessage).extend({
|
|||
// title: 'Workflow can not be changed!',
|
||||
title: this.$locale.baseText('genericHelpers.showMessage.title'),
|
||||
message: this.$locale.baseText('genericHelpers.showMessage.message'),
|
||||
type: 'error',
|
||||
type: 'info',
|
||||
duration: 0,
|
||||
});
|
||||
|
||||
|
|
|
@ -44,7 +44,11 @@
|
|||
}
|
||||
},
|
||||
reload() {
|
||||
window.location.reload();
|
||||
if (window.top) {
|
||||
window.top.location.reload();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -3,7 +3,7 @@ import { INodeUi, XYPosition } from '@/Interface';
|
|||
import mixins from 'vue-typed-mixins';
|
||||
|
||||
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
|
||||
import { getMousePosition, getRelativePosition, HEADER_HEIGHT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_EXPANDED } from '@/views/canvasHelpers';
|
||||
import { getMousePosition, getRelativePosition, HEADER_HEIGHT, INNER_SIDEBAR_WIDTH, SIDEBAR_WIDTH, SIDEBAR_WIDTH_EXPANDED } from '@/views/canvasHelpers';
|
||||
import { VIEWS } from '@/constants';
|
||||
|
||||
export const mouseSelect = mixins(
|
||||
|
@ -49,8 +49,8 @@ export const mouseSelect = mixins(
|
|||
},
|
||||
getMousePositionWithinNodeView (event: MouseEvent | TouchEvent): XYPosition {
|
||||
const [x, y] = getMousePosition(event);
|
||||
const sidebarOffset = this.isDemo ? 0 : this.$store.getters['ui/sidebarMenuCollapsed'] ? SIDEBAR_WIDTH : SIDEBAR_WIDTH_EXPANDED;
|
||||
const headerOffset = this.isDemo ? 0 : HEADER_HEIGHT;
|
||||
const sidebarOffset = this.isDemo ? 0 : this.$store.getters['ui/sidebarMenuCollapsed'] ? SIDEBAR_WIDTH : SIDEBAR_WIDTH_EXPANDED;
|
||||
// @ts-ignore
|
||||
return getRelativePosition(x - sidebarOffset, y - headerOffset, this.nodeViewScale, this.$store.getters.getNodeViewOffsetPosition);
|
||||
},
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { getStyleTokenValue } from '../helpers';
|
||||
import { readonly } from 'vue';
|
||||
|
||||
export const nodeBase = mixins(
|
||||
deviceSupportHelpers,
|
||||
|
@ -16,7 +17,12 @@ export const nodeBase = mixins(
|
|||
mounted () {
|
||||
// Initialize the node
|
||||
if (this.data !== null) {
|
||||
this.__addNode(this.data);
|
||||
try {
|
||||
this.__addNode(this.data);
|
||||
} catch(error) {
|
||||
// This breaks when new nodes are loaded into store but workflow tab is not currently active
|
||||
// Shouldn't affect anything
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
|
@ -245,7 +245,7 @@ export const pushConnection = mixins(
|
|||
action = '<a data-action="open-settings">Turn on saving manual executions</a> and run again to see what happened after this node.';
|
||||
}
|
||||
else {
|
||||
action = `<a href="/execution/${activeExecutionId}" target="_blank">View the execution</a> to see what happened after this node.`;
|
||||
action = `<a href="/workflow/${workflow.id}/executions/${activeExecutionId}">View the execution</a> to see what happened after this node.`;
|
||||
}
|
||||
|
||||
// Workflow did start but had been put to wait
|
||||
|
|
|
@ -680,8 +680,9 @@ export const workflowHelpers = mixins(
|
|||
}
|
||||
},
|
||||
|
||||
async saveCurrentWorkflow({name, tags}: {name?: string, tags?: string[]} = {}, redirect = true): Promise<boolean> {
|
||||
const currentWorkflow = this.$route.params.name;
|
||||
async saveCurrentWorkflow({id, name, tags}: {id?: string, name?: string, tags?: string[]} = {}, redirect = true): Promise<boolean> {
|
||||
const currentWorkflow = id || this.$route.params.name;
|
||||
|
||||
if (!currentWorkflow) {
|
||||
return this.saveAsNewWorkflow({name, tags}, redirect);
|
||||
}
|
||||
|
|
|
@ -230,9 +230,9 @@ export const workflowRun = mixins(
|
|||
this.$store.commit('setWorkflowExecutionData', executionData);
|
||||
this.updateNodesExecutionIssues();
|
||||
|
||||
const runWorkflowApiResponse = await this.runWorkflowApi(startRunData);
|
||||
const runWorkflowApiResponse = await this.runWorkflowApi(startRunData);
|
||||
|
||||
this.$externalHooks().run('workflowRun.runWorkflow', { nodeName, source });
|
||||
this.$externalHooks().run('workflowRun.runWorkflow', { nodeName, source });
|
||||
|
||||
return runWorkflowApiResponse;
|
||||
} catch (error) {
|
||||
|
|
|
@ -287,6 +287,9 @@ export enum VIEWS {
|
|||
HOMEPAGE = "Homepage",
|
||||
COLLECTION = "TemplatesCollectionView",
|
||||
EXECUTION = "ExecutionById",
|
||||
EXECUTIONS = "ExecutionList",
|
||||
EXECUTION_PREVIEW = "ExecutionPreview",
|
||||
EXECUTION_HOME = "ExecutionsLandingPage",
|
||||
TEMPLATE = "TemplatesWorkflowView",
|
||||
TEMPLATES = "TemplatesSearchView",
|
||||
CREDENTIALS = "CredentialsView",
|
||||
|
@ -369,6 +372,11 @@ export enum EnterpriseEditionFeature {
|
|||
}
|
||||
export const MAIN_NODE_PANEL_WIDTH = 360;
|
||||
|
||||
export enum MAIN_HEADER_TABS {
|
||||
WORKFLOW = 'workflow',
|
||||
EXECUTIONS = 'executions',
|
||||
SETTINGS = 'settings',
|
||||
}
|
||||
export const CURL_IMPORT_NOT_SUPPORTED_PROTOCOLS = [
|
||||
'ftp',
|
||||
'ftps',
|
||||
|
|
|
@ -30,6 +30,7 @@ import {
|
|||
IFakeDoorLocation,
|
||||
IRootState,
|
||||
IUiState,
|
||||
XYPosition,
|
||||
IFakeDoor,
|
||||
} from '../Interface';
|
||||
|
||||
|
@ -136,6 +137,9 @@ const module: Module<IUiState, IRootState> = {
|
|||
uiLocations: ['credentialsModal'],
|
||||
},
|
||||
],
|
||||
nodeViewInitialized: false,
|
||||
addFirstStepOnLoad: false,
|
||||
executionSidebarAutoRefresh: true,
|
||||
},
|
||||
getters: {
|
||||
isVersionsOpen: (state: IUiState) => {
|
||||
|
@ -175,6 +179,9 @@ const module: Module<IUiState, IRootState> = {
|
|||
getCurrentView: (state: IUiState) => state.currentView,
|
||||
isNodeView: (state: IUiState) => [VIEWS.NEW_WORKFLOW.toString(), VIEWS.WORKFLOW.toString(), VIEWS.EXECUTION.toString()].includes(state.currentView),
|
||||
getNDVDataIsEmpty: (state: IUiState) => (panel: 'input' | 'output'): boolean => state.ndv[panel].data.isEmpty,
|
||||
isNodeViewInitialized: (state: IUiState) => state.nodeViewInitialized,
|
||||
getAddFirstStepOnLoad: (state: IUiState) => state.addFirstStepOnLoad,
|
||||
isExecutionSidebarAutoRefreshOn: (state: IUiState) => state.executionSidebarAutoRefresh,
|
||||
},
|
||||
mutations: {
|
||||
setMode: (state: IUiState, params: {name: string, mode: string}) => {
|
||||
|
@ -231,6 +238,75 @@ const module: Module<IUiState, IRootState> = {
|
|||
setCurrentView: (state: IUiState, currentView: string) => {
|
||||
state.currentView = currentView;
|
||||
},
|
||||
setNDVSessionId: (state: IUiState) => {
|
||||
Vue.set(state.ndv, 'sessionId', `ndv-${Math.random().toString(36).slice(-8)}`);
|
||||
},
|
||||
resetNDVSessionId: (state: IUiState) => {
|
||||
Vue.set(state.ndv, 'sessionId', '');
|
||||
},
|
||||
setPanelDisplayMode: (state: IUiState, params: {pane: 'input' | 'output', mode: IRunDataDisplayMode}) => {
|
||||
Vue.set(state.ndv[params.pane], 'displayMode', params.mode);
|
||||
},
|
||||
setOutputPanelEditModeEnabled: (state: IUiState, payload: boolean) => {
|
||||
Vue.set(state.ndv.output.editMode, 'enabled', payload);
|
||||
},
|
||||
setOutputPanelEditModeValue: (state: IUiState, payload: string) => {
|
||||
Vue.set(state.ndv.output.editMode, 'value', payload);
|
||||
},
|
||||
setMainPanelRelativePosition(state: IUiState, relativePosition: number) {
|
||||
state.mainPanelPosition = relativePosition;
|
||||
},
|
||||
setMappableNDVInputFocus(state: IUiState, paramName: string) {
|
||||
Vue.set(state.ndv, 'focusedMappableInput', paramName);
|
||||
},
|
||||
draggableStartDragging(state: IUiState, {type, data}: {type: string, data: string}) {
|
||||
state.draggable = {
|
||||
isDragging: true,
|
||||
type,
|
||||
data,
|
||||
canDrop: false,
|
||||
stickyPosition: null,
|
||||
};
|
||||
},
|
||||
draggableStopDragging(state: IUiState) {
|
||||
state.draggable = {
|
||||
isDragging: false,
|
||||
type: '',
|
||||
data: '',
|
||||
canDrop: false,
|
||||
stickyPosition: null,
|
||||
};
|
||||
},
|
||||
setDraggableStickyPos(state: IUiState, position: XYPosition | null) {
|
||||
Vue.set(state.draggable, 'stickyPosition', position);
|
||||
},
|
||||
setDraggableCanDrop(state: IUiState, canDrop: boolean) {
|
||||
Vue.set(state.draggable, 'canDrop', canDrop);
|
||||
},
|
||||
setMappingTelemetry(state: IUiState, telemetry: {[key: string]: string | number | boolean}) {
|
||||
state.ndv.mappingTelemetry = {...state.ndv.mappingTelemetry, ...telemetry};
|
||||
},
|
||||
resetMappingTelemetry(state: IUiState) {
|
||||
state.ndv.mappingTelemetry = {};
|
||||
},
|
||||
setHoveringItem(state: IUiState, item: null | IUiState['ndv']['hoveringItem']) {
|
||||
Vue.set(state.ndv, 'hoveringItem', item);
|
||||
},
|
||||
setNDVBranchIndex(state: IUiState, e: {pane: 'input' | 'output', branchIndex: number}) {
|
||||
Vue.set(state.ndv[e.pane], 'branch', e.branchIndex);
|
||||
},
|
||||
setNDVPanelDataIsEmpty(state: IUiState, payload: {panel: 'input' | 'output', isEmpty: boolean}) {
|
||||
Vue.set(state.ndv[payload.panel].data, 'isEmpty', payload.isEmpty);
|
||||
},
|
||||
setNodeViewInitialized(state: IUiState, isInitialized: boolean) {
|
||||
state.nodeViewInitialized = isInitialized;
|
||||
},
|
||||
setAddFirstStepOnLoad(state: IUiState, addStep: boolean) {
|
||||
state.addFirstStepOnLoad = addStep;
|
||||
},
|
||||
setExecutionsSidebarAutoRefresh(state: IUiState, autoRefresh: boolean) {
|
||||
state.executionSidebarAutoRefresh = autoRefresh;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
openModal: async (context: ActionContext<IUiState, IRootState>, modalKey: string) => {
|
||||
|
|
|
@ -1,14 +1,21 @@
|
|||
import { getNewWorkflow } from '@/api/workflows';
|
||||
import { makeRestApiRequest } from '@/api/helpers';
|
||||
import { getCurrentExecutions, getFinishedExecutions, getNewWorkflow } from '@/api/workflows';
|
||||
import { DUPLICATE_POSTFFIX, MAX_WORKFLOW_NAME_LENGTH, DEFAULT_NEW_WORKFLOW_NAME } from '@/constants';
|
||||
import { IDataObject } from 'n8n-workflow';
|
||||
import { ActionContext, Module } from 'vuex';
|
||||
import {
|
||||
IExecutionsSummary,
|
||||
IRootState,
|
||||
IWorkflowsState,
|
||||
} from '../Interface';
|
||||
|
||||
const module: Module<IWorkflowsState, IRootState> = {
|
||||
namespaced: true,
|
||||
state: {},
|
||||
state: {
|
||||
currentWorkflowExecutions: [],
|
||||
activeWorkflowExecution: null,
|
||||
finishedExecutionsCount: 0,
|
||||
},
|
||||
actions: {
|
||||
getNewWorkflowData: async (context: ActionContext<IWorkflowsState, IRootState>, name?: string): Promise<object> => {
|
||||
let workflowData = {
|
||||
|
@ -43,6 +50,66 @@ const module: Module<IWorkflowsState, IRootState> = {
|
|||
|
||||
return newName;
|
||||
},
|
||||
async loadCurrentWorkflowExecutions (
|
||||
context: ActionContext<IWorkflowsState, IRootState>,
|
||||
filter: { finished: boolean, status: string },
|
||||
): Promise<IExecutionsSummary[]> {
|
||||
let activeExecutions = [];
|
||||
let finishedExecutions = [];
|
||||
const requestFilter: IDataObject = { workflowId: context.rootGetters.workflowId };
|
||||
|
||||
if (!context.rootGetters.workflowId) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
if (filter.status === ''|| !filter.finished) {
|
||||
activeExecutions = await getCurrentExecutions(context.rootGetters.getRestApiContext, requestFilter);
|
||||
}
|
||||
if (filter.status === '' || filter.finished) {
|
||||
if (filter.status === 'waiting') {
|
||||
requestFilter.waitTill = true;
|
||||
} else if (filter.status !== '') {
|
||||
requestFilter.finished = filter.status === 'success';
|
||||
}
|
||||
finishedExecutions = await getFinishedExecutions(context.rootGetters.getRestApiContext, requestFilter);
|
||||
}
|
||||
context.commit('setTotalFinishedExecutionsCount', finishedExecutions.count);
|
||||
return [...activeExecutions, ...finishedExecutions.results || []];
|
||||
} catch (error) {
|
||||
throw(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
setCurrentWorkflowExecutions (state: IWorkflowsState, executions: IExecutionsSummary[]) {
|
||||
state.currentWorkflowExecutions = executions;
|
||||
},
|
||||
setActiveWorkflowExecution (state: IWorkflowsState, executionData: IExecutionsSummary) {
|
||||
state.activeWorkflowExecution = executionData;
|
||||
},
|
||||
setTotalFinishedExecutionsCount (state: IWorkflowsState, count: number) {
|
||||
state.finishedExecutionsCount = count;
|
||||
},
|
||||
deleteExecution (state: IWorkflowsState, execution: IExecutionsSummary) {
|
||||
state.currentWorkflowExecutions.splice(state.currentWorkflowExecutions.indexOf(execution), 1);
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
currentWorkflowExecutions: (state: IWorkflowsState): IExecutionsSummary[] => {
|
||||
return state.currentWorkflowExecutions;
|
||||
},
|
||||
getActiveWorkflowExecution: (state: IWorkflowsState): IExecutionsSummary|null => {
|
||||
return state.activeWorkflowExecution;
|
||||
},
|
||||
getExecutionDataById: (state: IWorkflowsState) => (id: string) => {
|
||||
return state.currentWorkflowExecutions.find(execution => execution.id === id);
|
||||
},
|
||||
getTotalFinishedExecutionsCount: (state: IWorkflowsState) : number => {
|
||||
return state.finishedExecutionsCount;
|
||||
},
|
||||
getAllLoadedFinishedExecutions: (state: IWorkflowsState) : IExecutionsSummary[] => {
|
||||
return state.currentWorkflowExecutions.filter(ex => ex.finished === true || ex.stoppedAt !== undefined);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -13,13 +13,18 @@
|
|||
"_reusableDynamicText.moreInfo": "More info",
|
||||
"_reusableDynamicText.oauth2.clientId": "Client ID",
|
||||
"_reusableDynamicText.oauth2.clientSecret": "Client Secret",
|
||||
"generic.learnMore": "Learn more",
|
||||
"generic.confirm": "Confirm",
|
||||
"generic.any": "Any",
|
||||
"generic.cancel": "Cancel",
|
||||
"generic.confirm": "Confirm",
|
||||
"generic.filtersApplied": "Filters are currently applied.",
|
||||
"generic.learnMore": "Learn more",
|
||||
"generic.reset": "Reset",
|
||||
"generic.resetAllFilters": "Reset all filters",
|
||||
"generic.communityNode": "Community Node",
|
||||
"generic.communityNode.tooltip": "This is a node from our community. It's part of the {packageName} package. <a href=\"{docURL}\" target=\"_blank\" title=\"Read the n8n docs\">Learn more</a>",
|
||||
"generic.delete": "Delete",
|
||||
"generic.copy": "Copy",
|
||||
"generic.delete": "Delete",
|
||||
"generic.executions": "Executions",
|
||||
"generic.or": "or",
|
||||
"generic.clickToCopy": "Click to copy",
|
||||
"generic.copiedToClipboard": "Copied to clipboard",
|
||||
|
@ -31,6 +36,7 @@
|
|||
"generic.unsavedWork.confirmMessage.message": "If you don't save, you will lose your changes.",
|
||||
"generic.unsavedWork.confirmMessage.confirmButtonText": "Save",
|
||||
"generic.unsavedWork.confirmMessage.cancelButtonText": "Leave without saving",
|
||||
"generic.workflow": "Workflow",
|
||||
"about.aboutN8n": "About n8n",
|
||||
"about.close": "Close",
|
||||
"about.license": "License",
|
||||
|
@ -368,6 +374,11 @@
|
|||
"error": "Error",
|
||||
"error.goBack": "Go back",
|
||||
"error.pageNotFound": "Oops, couldn’t find that",
|
||||
"executions.ExecutionStatus": "Execution status",
|
||||
"executionDetails.confirmMessage.confirmButtonText": "Yes, delete",
|
||||
"executionDetails.confirmMessage.headline": "Delete Execution?",
|
||||
"executionDetails.confirmMessage.message": "Are you sure that you want to delete the current execution?",
|
||||
"executionDetails.deleteExecution": "Delete this execution",
|
||||
"executionDetails.executionFailed": "Execution failed",
|
||||
"executionDetails.executionId": "Execution ID",
|
||||
"executionDetails.executionWaiting": "Execution waiting",
|
||||
|
@ -376,7 +387,22 @@
|
|||
"executionDetails.openWorkflow": "Open Workflow",
|
||||
"executionDetails.readOnly.readOnly": "Read only",
|
||||
"executionDetails.readOnly.youreViewingTheLogOf": "You're viewing the log of a previous execution. You cannot<br />\n\t\tmake changes since this execution already occurred. Make changes<br />\n\t\tto this workflow by clicking on its name on the left.",
|
||||
"executionDetails.retry": "Retry of execution",
|
||||
"executionDetails.runningTimeFinished": "in {time}",
|
||||
"executionDetails.runningTimeRunning": "for {time}",
|
||||
"executionDetails.runningMessage": "Execution is running. It will show here once finished.",
|
||||
"executionDetails.workflow": "workflow",
|
||||
"executionsLandingPage.emptyState.noTrigger.heading": "Set up the first step. Then execute your workflow",
|
||||
"executionsLandingPage.emptyState.noTrigger.buttonText": "Add first step...",
|
||||
"executionsLandingPage.clickExecutionMessage": "Click on an execution from the list to view it",
|
||||
"executionsLandingPage.emptyState.heading": " No executions yet",
|
||||
"executionsLandingPage.emptyState.message": "New workflow executions will show here",
|
||||
"executionsLandingPage.emptyState.accordion.title": "Which executions is this workflow saving?",
|
||||
"executionsLandingPage.emptyState.accordion.productionExecutions": "Production executions",
|
||||
"executionsLandingPage.emptyState.accordion.manualExecutions": "Manual executions",
|
||||
"executionsLandingPage.emptyState.accordion.productionExecutionsWarningTooltip": "Not all production executions are being saved. Change this in the workflow's <a href=\"#\">settings</a>",
|
||||
"executionsLandingPage.emptyState.accordion.footer": "You can change this in <a href=\"#\">Workflow Settings</a>",
|
||||
"executionsLandingPage.noResults": "No executions found",
|
||||
"executionsList.allWorkflows": "All Workflows",
|
||||
"executionsList.anyStatus": "Any Status",
|
||||
"executionsList.autoRefresh": "Auto refresh",
|
||||
|
@ -385,7 +411,7 @@
|
|||
"executionsList.confirmMessage.headline": "Delete Executions?",
|
||||
"executionsList.confirmMessage.message": "Are you sure that you want to delete the {numSelected} selected execution(s)?",
|
||||
"executionsList.deleteSelected": "Delete Selected",
|
||||
"executionsList.error": "Error",
|
||||
"executionsList.error": "Failed",
|
||||
"executionsList.filters": "Filters",
|
||||
"executionsList.loadMore": "Load More",
|
||||
"executionsList.mode": "Mode",
|
||||
|
@ -400,12 +426,14 @@
|
|||
"executionsList.retryExecution": "Retry execution",
|
||||
"executionsList.retryOf": "Retry of",
|
||||
"executionsList.retryWithCurrentlySavedWorkflow": "Retry with currently saved workflow (from node with error)",
|
||||
"executionsList.retryWithOriginalworkflow": "Retry with original workflow (from node with error)",
|
||||
"executionsList.retryWithOriginalWorkflow": "Retry with original workflow (from node with error)",
|
||||
"executionsList.running": "Running",
|
||||
"executionsList.succeeded": "Succeeded",
|
||||
"executionsList.runningTime": "Running Time",
|
||||
"executionsList.selectStatus": "Select Status",
|
||||
"executionsList.selectWorkflow": "Select Workflow",
|
||||
"executionsList.selected": "Selected",
|
||||
"executionsList.manual": "Manual execution",
|
||||
"executionsList.showError.handleDeleteSelected.title": "Problem deleting executions",
|
||||
"executionsList.showError.loadMore.title": "Problem loading executions",
|
||||
"executionsList.showError.loadWorkflows.title": "Problem loading workflows",
|
||||
|
@ -435,6 +463,10 @@
|
|||
"executionsList.unsavedWorkflow": "[UNSAVED WORKFLOW]",
|
||||
"executionsList.waiting": "Waiting",
|
||||
"executionsList.workflowExecutions": "Workflow Executions",
|
||||
"executionSidebar.executionName": "Execution {id}",
|
||||
"executionSidebar.searchPlaceholder": "Search executions...",
|
||||
"executionView.onPaste.title": "Cannot paste here",
|
||||
"executionView.onPaste.message": "This view is read-only. Switch to <i>Workflow</i> tab to be able to edit the current workflow",
|
||||
"expressionEdit.editExpression": "Edit Expression",
|
||||
"expressionEdit.expression": "Expression",
|
||||
"expressionEdit.result": "Result",
|
||||
|
@ -483,15 +515,14 @@
|
|||
"genericHelpers.loading": "Loading",
|
||||
"genericHelpers.min": "min",
|
||||
"genericHelpers.sec": "sec",
|
||||
"genericHelpers.showMessage.message": "This is a read-only version of the workflow. To make changes, either open the original workflow or save it under a new name.",
|
||||
"genericHelpers.showMessage.title": "Workflow cannot be changed",
|
||||
"genericHelpers.showMessage.message": "Executions are read-only. Make changes from the <b>Workflow</b> tab.",
|
||||
"genericHelpers.showMessage.title": "Cannot edit execution",
|
||||
"mainSidebar.aboutN8n": "About n8n",
|
||||
"mainSidebar.confirmMessage.workflowDelete.cancelButtonText": "",
|
||||
"mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete",
|
||||
"mainSidebar.confirmMessage.workflowDelete.headline": "Delete Workflow?",
|
||||
"mainSidebar.confirmMessage.workflowDelete.message": "Are you sure that you want to delete '{workflowName}'?",
|
||||
"mainSidebar.credentials": "Credentials",
|
||||
"mainSidebar.executions": "Executions",
|
||||
"mainSidebar.help": "Help",
|
||||
"mainSidebar.helpMenuItems.course": "Course",
|
||||
"mainSidebar.helpMenuItems.documentation": "Documentation",
|
||||
|
@ -1118,7 +1149,6 @@
|
|||
"templates.newButton": "New blank workflow",
|
||||
"templates.noSearchResults": "Nothing found. Try adjusting your search to see more.",
|
||||
"templates.searchPlaceholder": "Search workflows",
|
||||
"templates.workflow": "Workflow",
|
||||
"templates.workflows": "Workflows",
|
||||
"templates.workflowsNotFound": "Workflow could not be found",
|
||||
"textEdit.edit": "Edit",
|
||||
|
@ -1230,6 +1260,8 @@
|
|||
"workflowPreview.showError.arrayEmpty": "Must have an array of nodes",
|
||||
"workflowPreview.showError.missingWorkflow": "Missing workflow",
|
||||
"workflowPreview.showError.previewError.message": "Unable to preview workflow",
|
||||
"workflowPreview.showError.missingExecution": "Missing workflow execution",
|
||||
"workflowPreview.executionMode.showError.previewError.message": "Unable to preview workflow execution",
|
||||
"workflowPreview.showError.previewError.title": "Preview error",
|
||||
"workflowRun.noActiveConnectionToTheServer": "Lost connection to the server",
|
||||
"workflowRun.showError.title": "Problem running workflow",
|
||||
|
@ -1250,11 +1282,11 @@
|
|||
"workflowSettings.minutes": "minutes",
|
||||
"workflowSettings.noWorkflow": "- No Workflow -",
|
||||
"workflowSettings.save": "@:_reusableBaseText.save",
|
||||
"workflowSettings.saveDataErrorExecution": "Save failed executions",
|
||||
"workflowSettings.saveDataErrorExecution": "Save failed production executions",
|
||||
"workflowSettings.saveDataErrorExecutionOptions.defaultSave": "Default - {defaultValue}",
|
||||
"workflowSettings.saveDataErrorExecutionOptions.doNotSave": "Do not save",
|
||||
"workflowSettings.saveDataErrorExecutionOptions.save": "@:_reusableBaseText.save",
|
||||
"workflowSettings.saveDataSuccessExecution": "Save successful executions",
|
||||
"workflowSettings.saveDataSuccessExecution": "Save successful production executions",
|
||||
"workflowSettings.saveDataSuccessExecutionOptions.defaultSave": "Default - {defaultValue}",
|
||||
"workflowSettings.saveDataSuccessExecutionOptions.doNotSave": "Do not save",
|
||||
"workflowSettings.saveDataSuccessExecutionOptions.save": "@:_reusableBaseText.save",
|
||||
|
|
|
@ -50,12 +50,14 @@ import {
|
|||
faFileImport,
|
||||
faFilePdf,
|
||||
faFilter,
|
||||
faFlask,
|
||||
faFolderOpen,
|
||||
faGlobeAmericas,
|
||||
faGift,
|
||||
faGlobe,
|
||||
faGraduationCap,
|
||||
faGripVertical,
|
||||
faHandPointLeft,
|
||||
faHdd,
|
||||
faHome,
|
||||
faHourglass,
|
||||
|
@ -172,11 +174,13 @@ addIcon(faFileExport);
|
|||
addIcon(faFileImport);
|
||||
addIcon(faFilePdf);
|
||||
addIcon(faFilter);
|
||||
addIcon(faFlask);
|
||||
addIcon(faFolderOpen);
|
||||
addIcon(faGift);
|
||||
addIcon(faGlobe);
|
||||
addIcon(faGlobeAmericas);
|
||||
addIcon(faGraduationCap);
|
||||
addIcon(faHandPointLeft);
|
||||
addIcon(faHdd);
|
||||
addIcon(faHome);
|
||||
addIcon(faHourglass);
|
||||
|
|
|
@ -6,6 +6,9 @@ import ForgotMyPasswordView from './views/ForgotMyPasswordView.vue';
|
|||
import MainHeader from '@/components/MainHeader/MainHeader.vue';
|
||||
import MainSidebar from '@/components/MainSidebar.vue';
|
||||
import NodeView from '@/views/NodeView.vue';
|
||||
import ExecutionsView from '@/components/ExecutionsView/ExecutionsView.vue';
|
||||
import ExecutionsLandingPage from '@/components/ExecutionsView/ExecutionsLandingPage.vue';
|
||||
import ExecutionPreview from '@/components/ExecutionsView/ExecutionPreview.vue';
|
||||
import SettingsPersonalView from './views/SettingsPersonalView.vue';
|
||||
import SettingsUsersView from './views/SettingsUsersView.vue';
|
||||
import SettingsCommunityNodesView from './views/SettingsCommunityNodesView.vue';
|
||||
|
@ -22,7 +25,7 @@ import TemplatesSearchView from '@/views/TemplatesSearchView.vue';
|
|||
import CredentialsView from '@/views/CredentialsView.vue';
|
||||
import WorkflowsView from '@/views/WorkflowsView.vue';
|
||||
import { Store } from 'vuex';
|
||||
import { IPermissions, IRootState, IWorkflowsState } from './Interface';
|
||||
import { IPermissions, IRootState } from './Interface';
|
||||
import { LOGIN_STATUS, ROLE } from './modules/userHelpers';
|
||||
import { RouteConfigSingleView } from 'vue-router/types/router';
|
||||
import { VIEWS } from './constants';
|
||||
|
@ -207,7 +210,7 @@ const router = new Router({
|
|||
},
|
||||
},
|
||||
{
|
||||
path: '/workflow',
|
||||
path: '/workflow/new',
|
||||
name: VIEWS.NEW_WORKFLOW,
|
||||
components: {
|
||||
default: NodeView,
|
||||
|
@ -240,6 +243,55 @@ const router = new Router({
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/workflow/:name/executions',
|
||||
name: VIEWS.EXECUTIONS,
|
||||
components: {
|
||||
default: ExecutionsView,
|
||||
header: MainHeader,
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
meta: {
|
||||
keepWorkflowAlive: true,
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: VIEWS.EXECUTION_HOME,
|
||||
components: {
|
||||
executionPreview: ExecutionsLandingPage,
|
||||
},
|
||||
meta: {
|
||||
keepWorkflowAlive: true,
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':executionId',
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
components: {
|
||||
executionPreview: ExecutionPreview,
|
||||
},
|
||||
meta: {
|
||||
keepWorkflowAlive: true,
|
||||
permissions: {
|
||||
allow: {
|
||||
loginStatus: [LOGIN_STATUS.LoggedIn],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/workflows/demo',
|
||||
name: VIEWS.DEMO,
|
||||
|
|
|
@ -462,7 +462,7 @@ export const store = new Vuex.Store({
|
|||
|
||||
// Id
|
||||
setWorkflowId (state, id: string) {
|
||||
state.workflow.id = id;
|
||||
state.workflow.id = id === 'new' ? PLACEHOLDER_EMPTY_WORKFLOW_ID : id;
|
||||
},
|
||||
|
||||
// Name
|
||||
|
|
|
@ -1,8 +1,22 @@
|
|||
<template>
|
||||
<div class="node-view-root" @dragover="onDragOver" @drop="onDrop">
|
||||
<div class="node-view-wrapper" :class="workflowClasses" @touchstart="mouseDown" @touchend="mouseUp"
|
||||
@touchmove="mouseMoveNodeWorkflow" @mousedown="mouseDown" v-touch:tap="touchTap" @mouseup="mouseUp"
|
||||
@wheel="wheelScroll">
|
||||
<div :class="$style['content']">
|
||||
<div
|
||||
class="node-view-root"
|
||||
id="node-view-root"
|
||||
@dragover="onDragOver"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<div
|
||||
class="node-view-wrapper"
|
||||
:class="workflowClasses"
|
||||
@touchstart="mouseDown"
|
||||
@touchend="mouseUp"
|
||||
@touchmove="mouseMoveNodeWorkflow"
|
||||
@mousedown="mouseDown"
|
||||
v-touch:tap="touchTap"
|
||||
@mouseup="mouseUp"
|
||||
@wheel="wheelScroll"
|
||||
>
|
||||
<div id="node-view-background" class="node-view-background" :style="backgroundStyle" />
|
||||
<div
|
||||
id="node-view"
|
||||
|
@ -12,7 +26,7 @@
|
|||
>
|
||||
<canvas-add-button
|
||||
:style="canvasAddButtonStyle"
|
||||
@click="showTriggerCreator('tirger_placeholder_button')"
|
||||
@click="showTriggerCreator('trigger_placeholder_button')"
|
||||
v-show="showCanvasAddButton"
|
||||
:showTooltip="!containsTrigger && showTriggerMissingTooltip"
|
||||
:position="canvasAddButtonPosition"
|
||||
|
@ -117,6 +131,7 @@
|
|||
@click.stop="clearExecutionData()" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
@ -130,6 +145,7 @@ import once from 'lodash/once';
|
|||
|
||||
import {
|
||||
FIRST_ONBOARDING_PROMPT_TIMEOUT,
|
||||
MAIN_HEADER_TABS,
|
||||
MODAL_CANCEL,
|
||||
MODAL_CLOSE,
|
||||
MODAL_CONFIRMED,
|
||||
|
@ -205,11 +221,14 @@ import {
|
|||
IWorkflowToShare,
|
||||
} from '@/Interface';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import '../plugins/N8nCustomConnectorType';
|
||||
import '../plugins/PlusEndpointType';
|
||||
import { getAccountAge } from '@/modules/userHelpers';
|
||||
import { dataPinningEventBus } from "@/event-bus/data-pinning-event-bus";
|
||||
import { debounceHelper } from '@/components/mixins/debounce';
|
||||
import { getNodeViewTab } from '@/components/helpers';
|
||||
import { Route } from 'vue-router';
|
||||
|
||||
interface AddNodeOptions {
|
||||
position?: XYPosition;
|
||||
|
@ -249,8 +268,44 @@ export default mixins(
|
|||
},
|
||||
watch: {
|
||||
// Listen to route changes and load the workflow accordingly
|
||||
'$route': 'initView',
|
||||
activeNode() {
|
||||
'$route' (to: Route, from: Route) {
|
||||
const currentTab = getNodeViewTab(to);
|
||||
const nodeViewNotInitialized = !this.$store.getters['ui/isNodeViewInitialized'];
|
||||
let workflowChanged =
|
||||
from.params.name !== to.params.name &&
|
||||
// Both 'new' and __EMPTY__ are new workflow names, so ignore them when detecting if wf changed
|
||||
!(from.params.name === 'new' && this.currentWorkflow === PLACEHOLDER_EMPTY_WORKFLOW_ID) &&
|
||||
// Also ignore if workflow id changes when saving new workflow
|
||||
to.params.action !== 'workflowSave';
|
||||
const isOpeningTemplate = to.name === VIEWS.TEMPLATE_IMPORT;
|
||||
|
||||
// When entering this tab:
|
||||
if (currentTab === MAIN_HEADER_TABS.WORKFLOW || isOpeningTemplate) {
|
||||
if (workflowChanged || nodeViewNotInitialized || isOpeningTemplate) {
|
||||
this.startLoading();
|
||||
if (nodeViewNotInitialized) {
|
||||
const previousDirtyState = this.$store.getters.getStateIsDirty;
|
||||
this.resetWorkspace();
|
||||
this.$store.commit('setStateDirty', previousDirtyState);
|
||||
}
|
||||
this.initView().then(() => {
|
||||
this.stopLoading();
|
||||
if (this.blankRedirect) {
|
||||
this.blankRedirect = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Also, when landing on executions tab, check if workflow data is changed
|
||||
if (currentTab === MAIN_HEADER_TABS.EXECUTIONS) {
|
||||
workflowChanged = from.params.name !== to.params.name && !(to.params.name === 'new' && from.params.name === undefined);
|
||||
if (workflowChanged) {
|
||||
// This will trigger node view to update next time workflow tab is opened
|
||||
this.$store.commit('ui/setNodeViewInitialized', false);
|
||||
}
|
||||
}
|
||||
},
|
||||
activeNode () {
|
||||
// When a node gets set as active deactivate the create-menu
|
||||
this.createNodeActive = false;
|
||||
},
|
||||
|
@ -261,30 +316,40 @@ export default mixins(
|
|||
},
|
||||
},
|
||||
async beforeRouteLeave(to, from, next) {
|
||||
this.$store.commit('setSubworkflowExecutionError', null);
|
||||
const result = this.$store.getters.getStateIsDirty;
|
||||
if (result) {
|
||||
const confirmModal = await this.confirmModal(
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
|
||||
'warning',
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
|
||||
true,
|
||||
);
|
||||
|
||||
if (confirmModal === MODAL_CONFIRMED) {
|
||||
const saved = await this.saveCurrentWorkflow({}, false);
|
||||
if (saved) this.$store.dispatch('settings/fetchPromptsData');
|
||||
this.$store.commit('setStateDirty', false);
|
||||
const nextTab = getNodeViewTab(to);
|
||||
// Only react if leaving workflow tab and going to a separate page
|
||||
if (!nextTab) {
|
||||
// Skip check if in the middle of template import
|
||||
if (from.name === VIEWS.TEMPLATE_IMPORT) {
|
||||
next();
|
||||
} else if (confirmModal === MODAL_CANCEL) {
|
||||
this.$store.commit('setStateDirty', false);
|
||||
next();
|
||||
} else if (confirmModal === MODAL_CLOSE) {
|
||||
next(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = this.$store.getters.getStateIsDirty;
|
||||
if (result) {
|
||||
const confirmModal = await this.confirmModal(
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
|
||||
'warning',
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
|
||||
true,
|
||||
);
|
||||
|
||||
if (confirmModal === MODAL_CONFIRMED) {
|
||||
const saved = await this.saveCurrentWorkflow({}, false);
|
||||
if (saved) this.$store.dispatch('settings/fetchPromptsData');
|
||||
this.$store.commit('setStateDirty', false);
|
||||
next();
|
||||
} else if (confirmModal === MODAL_CANCEL) {
|
||||
this.$store.commit('setStateDirty', false);
|
||||
next();
|
||||
} else if (confirmModal === MODAL_CLOSE) {
|
||||
next(false);
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
|
@ -351,7 +416,11 @@ export default mixins(
|
|||
};
|
||||
},
|
||||
backgroundStyle(): object {
|
||||
return CanvasHelpers.getBackgroundStyles(this.nodeViewScale, this.getNodeViewOffsetPosition);
|
||||
return CanvasHelpers.getBackgroundStyles(
|
||||
this.nodeViewScale,
|
||||
this.$store.getters.getNodeViewOffsetPosition,
|
||||
this.isExecutionPreview,
|
||||
);
|
||||
},
|
||||
workflowClasses() {
|
||||
const returnClasses = [];
|
||||
|
@ -374,9 +443,14 @@ export default mixins(
|
|||
workflowRunning(): boolean {
|
||||
return this.$store.getters.isActionActive('workflowRunning');
|
||||
},
|
||||
currentWorkflow (): string {
|
||||
return this.$route.params.name || this.$store.getters.workflowId;
|
||||
},
|
||||
workflowName (): string {
|
||||
return this.$store.getters.workflowName;
|
||||
},
|
||||
allTriggersDisabled(): boolean {
|
||||
const disabledTriggerNodes = this.triggerNodes.filter(node => node.disabled);
|
||||
|
||||
return disabledTriggerNodes.length === this.triggerNodes.length;
|
||||
},
|
||||
triggerNodes(): INodeUi[] {
|
||||
|
@ -414,6 +488,7 @@ export default mixins(
|
|||
dropPrevented: false,
|
||||
renamingActive: false,
|
||||
showStickyButton: false,
|
||||
isExecutionPreview: false,
|
||||
showTriggerMissingTooltip: false,
|
||||
canvasAddButtonPosition: [1, 1] as XYPosition,
|
||||
workflowData: null as INewWorkflowData | null,
|
||||
|
@ -442,7 +517,7 @@ export default mixins(
|
|||
this.$externalHooks().run('nodeView.onRunNode', telemetryPayload);
|
||||
this.runWorkflow(nodeName, source);
|
||||
},
|
||||
onRunWorkflow() {
|
||||
async onRunWorkflow() {
|
||||
this.getWorkflowDataToSave().then((workflowData) => {
|
||||
const telemetryPayload = {
|
||||
workflow_id: this.$store.getters.workflowId,
|
||||
|
@ -450,9 +525,10 @@ export default mixins(
|
|||
};
|
||||
this.$telemetry.track('User clicked execute workflow button', telemetryPayload);
|
||||
this.$externalHooks().run('nodeView.onRunWorkflow', telemetryPayload);
|
||||
|
||||
});
|
||||
|
||||
this.runWorkflow();
|
||||
await this.runWorkflow();
|
||||
},
|
||||
onRunContainerClick() {
|
||||
if (this.containsTrigger && !this.allTriggersDisabled) return;
|
||||
|
@ -559,8 +635,8 @@ export default mixins(
|
|||
this.$nextTick(() => this.$store.commit('nodeCreator/setShowTabs', false));
|
||||
},
|
||||
async openExecution(executionId: string) {
|
||||
this.startLoading();
|
||||
this.resetWorkspace();
|
||||
|
||||
let data: IExecutionResponse | undefined;
|
||||
try {
|
||||
data = await this.restApi().getExecution(executionId);
|
||||
|
@ -571,14 +647,11 @@ export default mixins(
|
|||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === undefined) {
|
||||
throw new Error(`Execution with id "${executionId}" could not be found!`);
|
||||
}
|
||||
|
||||
this.$store.commit('setWorkflowName', { newName: data.workflowData.name, setStateDirty: false });
|
||||
this.$store.commit('setWorkflowId', PLACEHOLDER_EMPTY_WORKFLOW_ID);
|
||||
|
||||
this.$store.commit('setWorkflowExecutionData', data);
|
||||
this.$store.commit('setWorkflowPinData', data.workflowData.pinData);
|
||||
|
||||
|
@ -587,8 +660,6 @@ export default mixins(
|
|||
this.zoomToFit();
|
||||
this.$store.commit('setStateDirty', false);
|
||||
});
|
||||
|
||||
|
||||
this.$externalHooks().run('execution.open', { workflowId: data.workflowData.id, workflowName: data.workflowData.name, executionId });
|
||||
this.$telemetry.track('User opened read-only execution', { workflow_id: data.workflowData.id, execution_mode: data.mode, execution_finished: data.finished });
|
||||
|
||||
|
@ -617,7 +688,6 @@ export default mixins(
|
|||
message: errorMessage,
|
||||
type: 'error',
|
||||
}, shouldTrack);
|
||||
|
||||
if (data.data.resultData.error.stack) {
|
||||
// Display some more information for now in console to make debugging easier
|
||||
// TODO: Improve this in the future by displaying in UI
|
||||
|
@ -626,7 +696,6 @@ export default mixins(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((data as IExecutionsSummary).waitTill) {
|
||||
this.$showMessage({
|
||||
title: this.$locale.baseText('nodeView.thisExecutionHasntFinishedYet'),
|
||||
|
@ -635,6 +704,7 @@ export default mixins(
|
|||
duration: 0,
|
||||
});
|
||||
}
|
||||
this.stopLoading();
|
||||
},
|
||||
async importWorkflowExact(data: { workflow: IWorkflowDataUpdate }) {
|
||||
if (!data.workflow.nodes || !data.workflow.connections) {
|
||||
|
@ -654,9 +724,13 @@ export default mixins(
|
|||
});
|
||||
},
|
||||
async openWorkflowTemplate(templateId: string) {
|
||||
this.startLoading();
|
||||
this.setLoadingText(this.$locale.baseText('nodeView.loadingTemplate'));
|
||||
this.resetWorkspace();
|
||||
|
||||
this.$store.commit('workflows/setCurrentWorkflowExecutions', []);
|
||||
this.$store.commit('workflows/setActiveWorkflowExecution', null);
|
||||
|
||||
let data: IWorkflowTemplate | undefined;
|
||||
try {
|
||||
this.$externalHooks().run('template.requested', { templateId });
|
||||
|
@ -685,14 +759,16 @@ export default mixins(
|
|||
this.workflowData = await this.$store.dispatch('workflows/getNewWorkflowData', data.name);
|
||||
this.$nextTick(() => {
|
||||
this.zoomToFit();
|
||||
|
||||
this.$store.commit('setStateDirty', true);
|
||||
});
|
||||
|
||||
this.$externalHooks().run('template.open', { templateId, templateName: data.name, workflow: data.workflow });
|
||||
this.stopLoading();
|
||||
},
|
||||
async openWorkflow(workflowId: string) {
|
||||
this.startLoading();
|
||||
this.resetWorkspace();
|
||||
|
||||
let data: IWorkflowDb | undefined;
|
||||
try {
|
||||
data = await this.restApi().getWorkflow(workflowId);
|
||||
|
@ -712,28 +788,23 @@ export default mixins(
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
this.$store.commit('setActive', data.active || false);
|
||||
this.$store.commit('setWorkflowId', workflowId);
|
||||
this.$store.commit('setWorkflowName', { newName: data.name, setStateDirty: false });
|
||||
this.$store.commit('setWorkflowSettings', data.settings || {});
|
||||
this.$store.commit('setWorkflowPinData', data.pinData || {});
|
||||
|
||||
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);
|
||||
if (!this.credentialsUpdated) {
|
||||
this.$store.commit('setStateDirty', false);
|
||||
}
|
||||
|
||||
this.zoomToFit();
|
||||
|
||||
this.$externalHooks().run('workflow.open', { workflowId, workflowName: data.name });
|
||||
|
||||
this.$store.commit('workflows/setActiveWorkflowExecution', null);
|
||||
this.stopLoading();
|
||||
return data;
|
||||
},
|
||||
touchTap(e: MouseEvent | TouchEvent) {
|
||||
|
@ -1188,7 +1259,7 @@ export default mixins(
|
|||
return;
|
||||
}
|
||||
|
||||
const { zoomLevel, offset } = CanvasHelpers.getZoomToFit(nodes);
|
||||
const {zoomLevel, offset} = CanvasHelpers.getZoomToFit(nodes, !this.isDemo);
|
||||
|
||||
this.setZoomLevel(zoomLevel);
|
||||
this.$store.commit('setNodeViewOffsetPosition', { newOffset: offset });
|
||||
|
@ -1263,13 +1334,15 @@ export default mixins(
|
|||
return;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* This method gets called when data got pasted into the window
|
||||
*/
|
||||
async receivedCopyPasteData(plainTextData: string): Promise<void> {
|
||||
let workflowData: IWorkflowDataUpdate | undefined;
|
||||
|
||||
if (this.editAllowedCheck() === false) {
|
||||
return;
|
||||
}
|
||||
// Check if it is an URL which could contain workflow data
|
||||
if (plainTextData.match(/^http[s]?:\/\/.*\.json$/i)) {
|
||||
// Pasted data points to a possible workflow JSON file
|
||||
|
@ -2089,8 +2162,11 @@ export default mixins(
|
|||
});
|
||||
},
|
||||
async newWorkflow(): Promise<void> {
|
||||
this.startLoading();
|
||||
await this.resetWorkspace();
|
||||
this.workflowData = await this.$store.dispatch('workflows/getNewWorkflowData');
|
||||
this.$store.commit('workflows/setCurrentWorkflowExecutions', []);
|
||||
this.$store.commit('workflows/setActiveWorkflowExecution', null);
|
||||
|
||||
this.$store.commit('setStateDirty', false);
|
||||
this.setZoomLevel(1);
|
||||
|
@ -2123,6 +2199,9 @@ export default mixins(
|
|||
this.$telemetry.track('welcome note inserted');
|
||||
}
|
||||
}
|
||||
this.$store.commit('ui/setNodeViewInitialized', true);
|
||||
this.$store.commit('workflows/setActiveWorkflowExecution', null);
|
||||
this.stopLoading();
|
||||
}),
|
||||
async initView(): Promise<void> {
|
||||
if (this.$route.params.action === 'workflowSave') {
|
||||
|
@ -2131,7 +2210,6 @@ export default mixins(
|
|||
this.$store.commit('setStateDirty', false);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.blankRedirect) {
|
||||
this.blankRedirect = false;
|
||||
}
|
||||
|
@ -2144,7 +2222,6 @@ export default mixins(
|
|||
const executionId = this.$route.params.id;
|
||||
await this.openExecution(executionId);
|
||||
} else {
|
||||
|
||||
const result = this.$store.getters.getStateIsDirty;
|
||||
if (result) {
|
||||
const confirmModal = await this.confirmModal(
|
||||
|
@ -2155,7 +2232,6 @@ export default mixins(
|
|||
this.$locale.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
|
||||
true,
|
||||
);
|
||||
|
||||
if (confirmModal === MODAL_CONFIRMED) {
|
||||
const saved = await this.saveCurrentWorkflow();
|
||||
if (saved) this.$store.dispatch('settings/fetchPromptsData');
|
||||
|
@ -2163,7 +2239,6 @@ export default mixins(
|
|||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
// Load a workflow
|
||||
let workflowId = null as string | null;
|
||||
if (this.$route.params.name) {
|
||||
|
@ -2185,15 +2260,14 @@ export default mixins(
|
|||
// Open existing workflow
|
||||
await this.openWorkflow(workflowId);
|
||||
}
|
||||
} else {
|
||||
} else if (this.$route.meta?.nodeView === true) {
|
||||
// Create new workflow
|
||||
await this.newWorkflow();
|
||||
}
|
||||
}
|
||||
|
||||
this.$store.commit('ui/setNodeViewInitialized', true);
|
||||
document.addEventListener('keydown', this.keyDown);
|
||||
document.addEventListener('keyup', this.keyUp);
|
||||
|
||||
window.addEventListener("beforeunload", (e) => {
|
||||
if (this.isDemo){
|
||||
return;
|
||||
|
@ -2206,7 +2280,6 @@ export default mixins(
|
|||
this.startLoading(
|
||||
this.$locale.baseText('nodeView.redirecting'),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
@ -3097,6 +3170,7 @@ export default mixins(
|
|||
if (json && json.command === 'openWorkflow') {
|
||||
try {
|
||||
await this.importWorkflowExact(json);
|
||||
this.isExecutionPreview = false;
|
||||
} catch (e) {
|
||||
if (window.top) {
|
||||
window.top.postMessage(JSON.stringify({ command: 'error', message: this.$locale.baseText('openWorkflow.workflowImportError') }), '*');
|
||||
|
@ -3107,6 +3181,20 @@ export default mixins(
|
|||
type: 'error',
|
||||
});
|
||||
}
|
||||
} else if (json && json.command === 'openExecution') {
|
||||
try {
|
||||
await this.openExecution(json.executionId);
|
||||
this.isExecutionPreview = true;
|
||||
} catch (e) {
|
||||
if (window.top) {
|
||||
window.top.postMessage(JSON.stringify({ command: 'error', message: this.$locale.baseText('nodeView.showError.openExecution.title') }), '*');
|
||||
}
|
||||
this.$showMessage({
|
||||
title: this.$locale.baseText('nodeView.showError.openExecution.title'),
|
||||
message: (e as Error).message,
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
|
@ -3169,7 +3257,6 @@ export default mixins(
|
|||
this.addNode(nodeTypeName, { position });
|
||||
},
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
this.$titleReset();
|
||||
window.addEventListener('message', this.onPostMessageReceived);
|
||||
|
@ -3178,6 +3265,7 @@ export default mixins(
|
|||
this.$root.$on('importWorkflowUrl', this.onImportWorkflowUrlEvent);
|
||||
|
||||
this.startLoading();
|
||||
this.resetWorkspace();
|
||||
|
||||
const loadPromises = [
|
||||
this.loadActiveWorkflows(),
|
||||
|
@ -3202,7 +3290,9 @@ export default mixins(
|
|||
|
||||
this.instance.ready(async () => {
|
||||
try {
|
||||
this.initNodeView();
|
||||
try {
|
||||
this.initNodeView();
|
||||
} catch {} // This will break if mounted after jsplumb has been initiated from executions preview, so continue if it breaks
|
||||
await this.initView();
|
||||
if (window.top) {
|
||||
window.top.postMessage(JSON.stringify({ command: 'n8nReady', version: this.$store.getters.versionCli }), '*');
|
||||
|
@ -3257,7 +3347,33 @@ export default mixins(
|
|||
dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
|
||||
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
|
||||
},
|
||||
activated() {
|
||||
const openSideMenu = this.$store.getters['ui/getAddFirstStepOnLoad'];
|
||||
if (openSideMenu) {
|
||||
this.showTriggerCreator('trigger_placeholder_button');
|
||||
}
|
||||
this.$store.commit('ui/setAddFirstStepOnLoad', false);
|
||||
|
||||
document.addEventListener('keydown', this.keyDown);
|
||||
document.addEventListener('keyup', this.keyUp);
|
||||
window.addEventListener('message', this.onPostMessageReceived);
|
||||
this.$root.$on('newWorkflow', this.newWorkflow);
|
||||
this.$root.$on('importWorkflowData', this.onImportWorkflowDataEvent);
|
||||
this.$root.$on('importWorkflowUrl', this.onImportWorkflowUrlEvent);
|
||||
dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
|
||||
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
|
||||
},
|
||||
deactivated () {
|
||||
document.removeEventListener('keydown', this.keyDown);
|
||||
document.removeEventListener('keyup', this.keyUp);
|
||||
window.removeEventListener('message', this.onPostMessageReceived);
|
||||
this.$root.$off('newWorkflow', this.newWorkflow);
|
||||
this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent);
|
||||
this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent);
|
||||
|
||||
dataPinningEventBus.$off('pin-data', this.addPinDataConnections);
|
||||
dataPinningEventBus.$off('unpin-data', this.removePinDataConnections);
|
||||
},
|
||||
destroyed() {
|
||||
this.resetWorkspace();
|
||||
this.$store.commit('setStateDirty', false);
|
||||
|
@ -3276,18 +3392,15 @@ export default mixins(
|
|||
.zoom-menu {
|
||||
$--zoom-menu-margin: 15;
|
||||
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
left: $sidebar-width + $--zoom-menu-margin;
|
||||
width: 210px;
|
||||
bottom: 44px;
|
||||
bottom: 108px;
|
||||
left: 35px;
|
||||
line-height: 25px;
|
||||
color: #444;
|
||||
padding-right: 5px;
|
||||
|
||||
&:not(.demo-zoom-menu).expanded {
|
||||
left: $sidebar-expanded-width + $--zoom-menu-margin;
|
||||
}
|
||||
|
||||
button {
|
||||
border: var(--border-base);
|
||||
}
|
||||
|
@ -3315,6 +3428,8 @@ export default mixins(
|
|||
}
|
||||
|
||||
.node-view-root {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-canvas-background);
|
||||
width: 100%;
|
||||
|
@ -3474,3 +3589,36 @@ export default mixins(
|
|||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.shake {
|
||||
animation: 1s 200ms shake;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
10%, 90% {
|
||||
transform: translate3d(-1px, 0, 0);
|
||||
}
|
||||
|
||||
20%, 80% {
|
||||
transform: translate3d(2px, 0, 0);
|
||||
}
|
||||
|
||||
30%, 50%, 70% {
|
||||
transform: translate3d(-4px, 0, 0);
|
||||
}
|
||||
|
||||
40%, 60% {
|
||||
transform: translate3d(4px, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -204,6 +204,7 @@ export default mixins(genericHelpers, debounceHelper).extend({
|
|||
}
|
||||
},
|
||||
openNewWorkflow() {
|
||||
this.$store.commit('ui/setNodeViewInitialized', false);
|
||||
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
|
||||
},
|
||||
onSearchInput(search: string) {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
template.name
|
||||
}}</n8n-heading>
|
||||
<n8n-text v-if="template && template.name" color="text-base" size="small">
|
||||
{{ $locale.baseText('templates.workflow') }}
|
||||
{{ $locale.baseText('generic.workflow') }}
|
||||
</n8n-text>
|
||||
<n8n-loading :loading="!template || !template.name" :rows="2" variant="h1" />
|
||||
</div>
|
||||
|
@ -168,6 +168,10 @@ export default mixins(workflowHelpers).extend({
|
|||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
border: var(--border-base);
|
||||
border-radius: var(--border-radius-large);
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
|
|
|
@ -113,6 +113,7 @@ export default mixins(
|
|||
},
|
||||
methods: {
|
||||
addWorkflow() {
|
||||
this.$store.commit('ui/setNodeViewInitialized', false);
|
||||
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
|
||||
|
||||
this.$telemetry.track('User clicked add workflow button', {
|
||||
|
|
|
@ -33,6 +33,7 @@ export const DEFAULT_START_POSITION_X = 180;
|
|||
export const DEFAULT_START_POSITION_Y = 240;
|
||||
export const HEADER_HEIGHT = 65;
|
||||
export const SIDEBAR_WIDTH = 65;
|
||||
export const INNER_SIDEBAR_WIDTH = 310;
|
||||
export const SIDEBAR_WIDTH_EXPANDED = 200;
|
||||
export const MAX_X_TO_PUSH_DOWNSTREAM_NODES = 300;
|
||||
export const PUSH_NODES_OFFSET = NODE_SIZE * 2 + GRID_SIZE;
|
||||
|
@ -515,10 +516,19 @@ export const getMidCanvasPosition = (scale: number, offset: XYPosition): XYPosit
|
|||
return getRelativePosition(editorWidth / 2, (editorHeight - HEADER_HEIGHT) / 2, scale, offset);
|
||||
};
|
||||
|
||||
export const getBackgroundStyles = (scale: number, offsetPosition: XYPosition) => {
|
||||
export const getBackgroundStyles = (scale: number, offsetPosition: XYPosition, executionPreview: boolean) => {
|
||||
const squareSize = GRID_SIZE * scale;
|
||||
const dotSize = 1 * scale;
|
||||
const dotPosition = (GRID_SIZE / 2) * scale;
|
||||
|
||||
if (executionPreview) {
|
||||
return {
|
||||
'background-image': 'linear-gradient(135deg, #f9f9fb 25%, #ffffff 25%, #ffffff 50%, #f9f9fb 50%, #f9f9fb 75%, #ffffff 75%, #ffffff 100%)',
|
||||
'background-size': `${squareSize}px ${squareSize}px`,
|
||||
'background-position': `left ${offsetPosition[0]}px top ${offsetPosition[1]}px`,
|
||||
};
|
||||
}
|
||||
|
||||
const styles: object = {
|
||||
'background-size': `${squareSize}px ${squareSize}px`,
|
||||
'background-position': `left ${offsetPosition[0]}px top ${offsetPosition[1]}px`,
|
||||
|
@ -530,6 +540,7 @@ export const getBackgroundStyles = (scale: number, offsetPosition: XYPosition) =
|
|||
'background-image': `radial-gradient(circle at ${dotPosition}px ${dotPosition}px, ${dotColor} ${dotSize}px, transparent 0)`,
|
||||
};
|
||||
}
|
||||
|
||||
return styles;
|
||||
};
|
||||
|
||||
|
@ -651,10 +662,10 @@ export const addConnectionOutputSuccess = (connection: Connection, output: {tota
|
|||
const getContentDimensions = (): { editorWidth: number, editorHeight: number } => {
|
||||
let contentWidth = window.innerWidth;
|
||||
let contentHeight = window.innerHeight;
|
||||
const contentElement = document.getElementById('content');
|
||||
const nodeViewRoot = document.getElementById('node-view-root');
|
||||
|
||||
if (contentElement) {
|
||||
const contentBounds = contentElement.getBoundingClientRect();
|
||||
if (nodeViewRoot) {
|
||||
const contentBounds = nodeViewRoot.getBoundingClientRect();
|
||||
contentWidth = contentBounds.width;
|
||||
contentHeight = contentBounds.height;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue