feat(editor): Refactor and unify executions views (no-changelog) (#8538)
This commit is contained in:
@@ -51,6 +51,7 @@ import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useStorage } from '@/composables/useStorage';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ActivationModal',
|
||||
@@ -67,7 +68,7 @@ export default defineComponent({
|
||||
},
|
||||
methods: {
|
||||
async showExecutionsList() {
|
||||
const activeExecution = this.workflowsStore.activeWorkflowExecution;
|
||||
const activeExecution = this.executionsStore.activeExecution;
|
||||
const currentWorkflow = this.workflowsStore.workflowId;
|
||||
|
||||
if (activeExecution) {
|
||||
@@ -93,7 +94,7 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useNodeTypesStore, useUIStore, useWorkflowsStore),
|
||||
...mapStores(useNodeTypesStore, useUIStore, useWorkflowsStore, useExecutionsStore),
|
||||
triggerContent(): string {
|
||||
const foundTriggers = getActivatableTriggerNodes(this.workflowsStore.workflowTriggerNodes);
|
||||
if (!foundTriggers.length) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,775 +0,0 @@
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<ExecutionsSidebar
|
||||
:executions="executions"
|
||||
:loading="loading && !executions.length"
|
||||
:loading-more="loadingMore"
|
||||
:temporary-execution="temporaryExecution"
|
||||
:auto-refresh="autoRefresh"
|
||||
@update:auto-refresh="onAutoRefreshToggle"
|
||||
@reload-executions="setExecutions"
|
||||
@filter-updated="onFilterUpdated"
|
||||
@load-more="onLoadMore"
|
||||
@retry-execution="onRetryExecution"
|
||||
/>
|
||||
<div v-if="!hidePreview" :class="$style.content">
|
||||
<router-view
|
||||
name="executionPreview"
|
||||
@delete-current-execution="onDeleteCurrentExecution"
|
||||
@retry-execution="onRetryExecution"
|
||||
@stop-execution="onStopExecution"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
|
||||
import ExecutionsSidebar from '@/components/ExecutionsView/ExecutionsSidebar.vue';
|
||||
import {
|
||||
MAIN_HEADER_TABS,
|
||||
MODAL_CANCEL,
|
||||
MODAL_CONFIRM,
|
||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
VIEWS,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
import type {
|
||||
ExecutionFilterType,
|
||||
IExecutionsListResponse,
|
||||
INodeUi,
|
||||
ITag,
|
||||
IWorkflowDb,
|
||||
} from '@/Interface';
|
||||
import type {
|
||||
ExecutionSummary,
|
||||
IConnection,
|
||||
IConnections,
|
||||
IDataObject,
|
||||
INodeTypeDescription,
|
||||
INodeTypeNameVersion,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeHelpers } from 'n8n-workflow';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { useRouter, type Route } from 'vue-router';
|
||||
import { executionHelpers } from '@/mixins/executionsHelpers';
|
||||
import { range as _range } from 'lodash-es';
|
||||
import { NO_NETWORK_ERROR_CODE } from '@/utils/apiUtils';
|
||||
import { getNodeViewTab } from '@/utils/canvasUtils';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import { executionFilterToQueryFilter } from '@/utils/executionUtils';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
|
||||
// Number of execution pages that are fetched before temporary execution card is shown
|
||||
const MAX_LOADING_ATTEMPTS = 5;
|
||||
// Number of executions fetched on each page
|
||||
const LOAD_MORE_PAGE_SIZE = 100;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionsList',
|
||||
components: {
|
||||
ExecutionsSidebar,
|
||||
},
|
||||
mixins: [executionHelpers],
|
||||
setup() {
|
||||
const externalHooks = useExternalHooks();
|
||||
const router = useRouter();
|
||||
const workflowHelpers = useWorkflowHelpers({ router });
|
||||
const { callDebounced } = useDebounce();
|
||||
|
||||
return {
|
||||
externalHooks,
|
||||
workflowHelpers,
|
||||
callDebounced,
|
||||
...useToast(),
|
||||
...useMessage(),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
filter: {} as ExecutionFilterType,
|
||||
temporaryExecution: null as ExecutionSummary | null,
|
||||
autoRefresh: false,
|
||||
autoRefreshTimeout: undefined as undefined | NodeJS.Timer,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useTagsStore, useNodeTypesStore, useSettingsStore, useUIStore, useWorkflowsStore),
|
||||
hidePreview(): boolean {
|
||||
const activeNotPresent =
|
||||
this.filterApplied && !this.executions.find((ex) => ex.id === this.activeExecution?.id);
|
||||
return this.loading || !this.executions.length || activeNotPresent;
|
||||
},
|
||||
filterApplied(): boolean {
|
||||
return this.filter.status !== 'all';
|
||||
},
|
||||
workflowDataNotLoaded(): boolean {
|
||||
return (
|
||||
this.workflowsStore.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID &&
|
||||
this.workflowsStore.workflowName === ''
|
||||
);
|
||||
},
|
||||
loadedFinishedExecutionsCount(): number {
|
||||
return this.workflowsStore.getAllLoadedFinishedExecutions.length;
|
||||
},
|
||||
totalFinishedExecutionsCount(): number {
|
||||
return this.workflowsStore.getTotalFinishedExecutionsCount;
|
||||
},
|
||||
requestFilter(): IDataObject {
|
||||
return executionFilterToQueryFilter({
|
||||
...this.filter,
|
||||
workflowId: this.currentWorkflow,
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
$route(to: Route, from: Route) {
|
||||
if (to.params.name) {
|
||||
const workflowChanged = from.params.name !== to.params.name;
|
||||
void this.initView(workflowChanged);
|
||||
}
|
||||
|
||||
if (to.params.executionId) {
|
||||
const execution = this.workflowsStore.getExecutionDataById(to.params.executionId);
|
||||
if (execution) {
|
||||
this.workflowsStore.activeWorkflowExecution = execution;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
async beforeRouteLeave(to, from, next) {
|
||||
if (getNodeViewTab(to) === MAIN_HEADER_TABS.WORKFLOW) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (this.uiStore.stateIsDirty) {
|
||||
const confirmModal = await this.confirm(
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
|
||||
{
|
||||
title: this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
|
||||
type: 'warning',
|
||||
confirmButtonText: this.$locale.baseText(
|
||||
'generic.unsavedWork.confirmMessage.confirmButtonText',
|
||||
),
|
||||
cancelButtonText: this.$locale.baseText(
|
||||
'generic.unsavedWork.confirmMessage.cancelButtonText',
|
||||
),
|
||||
showClose: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmModal === MODAL_CONFIRM) {
|
||||
const saved = await this.workflowHelpers.saveCurrentWorkflow({}, false);
|
||||
if (saved) {
|
||||
await this.settingsStore.fetchPromptsData();
|
||||
}
|
||||
this.uiStore.stateIsDirty = false;
|
||||
next();
|
||||
} else if (confirmModal === MODAL_CANCEL) {
|
||||
this.uiStore.stateIsDirty = false;
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.autoRefresh = this.uiStore.executionSidebarAutoRefresh;
|
||||
},
|
||||
async mounted() {
|
||||
this.loading = true;
|
||||
const workflowUpdated = this.$route.params.name !== this.workflowsStore.workflowId;
|
||||
const onNewWorkflow =
|
||||
this.$route.params.name === 'new' &&
|
||||
this.workflowsStore.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID;
|
||||
const shouldUpdate = workflowUpdated && !onNewWorkflow;
|
||||
await this.initView(shouldUpdate);
|
||||
if (!shouldUpdate) {
|
||||
if (this.workflowsStore.currentWorkflowExecutions.length > 0) {
|
||||
const workflowExecutions = await this.loadExecutions();
|
||||
this.workflowsStore.addToCurrentExecutions(workflowExecutions);
|
||||
await this.setActiveExecution();
|
||||
} else {
|
||||
await this.setExecutions();
|
||||
}
|
||||
}
|
||||
void this.startAutoRefreshInterval();
|
||||
document.addEventListener('visibilitychange', this.onDocumentVisibilityChange);
|
||||
|
||||
this.loading = false;
|
||||
},
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('visibilitychange', this.onDocumentVisibilityChange);
|
||||
this.autoRefresh = false;
|
||||
this.stopAutoRefreshInterval();
|
||||
},
|
||||
methods: {
|
||||
async initView(loadWorkflow: boolean): Promise<void> {
|
||||
if (loadWorkflow) {
|
||||
await this.nodeTypesStore.loadNodeTypesIfNotLoaded();
|
||||
await this.openWorkflow(this.$route.params.name);
|
||||
this.uiStore.nodeViewInitialized = false;
|
||||
if (this.workflowsStore.currentWorkflowExecutions.length === 0) {
|
||||
await 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) {
|
||||
await this.callDebounced(this.loadMore, { debounceTime: 1000 });
|
||||
}
|
||||
},
|
||||
async loadMore(limit = 20): Promise<void> {
|
||||
if (
|
||||
this.filter.status === 'running' ||
|
||||
this.loadedFinishedExecutionsCount >= this.totalFinishedExecutionsCount
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.loadingMore = true;
|
||||
|
||||
let lastId: string | undefined;
|
||||
if (this.executions.length !== 0) {
|
||||
const lastItem = this.executions.slice(-1)[0];
|
||||
lastId = lastItem.id;
|
||||
}
|
||||
|
||||
let data: IExecutionsListResponse;
|
||||
try {
|
||||
data = await this.workflowsStore.getPastExecutions(this.requestFilter, limit, 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);
|
||||
}
|
||||
// If we loaded temp execution, put it into it's place and remove from top of the list
|
||||
if (newExecution.id === this.temporaryExecution?.id) {
|
||||
this.temporaryExecution = null;
|
||||
}
|
||||
}
|
||||
this.workflowsStore.currentWorkflowExecutions = currentExecutions;
|
||||
this.loadingMore = false;
|
||||
},
|
||||
async onDeleteCurrentExecution(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const executionIndex = this.executions.findIndex(
|
||||
(execution: ExecutionSummary) => execution.id === this.$route.params.executionId,
|
||||
);
|
||||
const nextExecution =
|
||||
this.executions[executionIndex + 1] ||
|
||||
this.executions[executionIndex - 1] ||
|
||||
this.executions[0];
|
||||
|
||||
await this.workflowsStore.deleteExecutions({ ids: [this.$route.params.executionId] });
|
||||
this.workflowsStore.deleteExecution(this.executions[executionIndex]);
|
||||
if (this.temporaryExecution?.id === this.$route.params.executionId) {
|
||||
this.temporaryExecution = null;
|
||||
}
|
||||
if (this.executions.length > 0) {
|
||||
await this.$router
|
||||
.replace({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: this.currentWorkflow, executionId: nextExecution.id },
|
||||
})
|
||||
.catch(() => {});
|
||||
this.workflowsStore.activeWorkflowExecution = nextExecution;
|
||||
await this.setExecutions();
|
||||
} else {
|
||||
// If there are no executions left, show empty state and clear active execution from the store
|
||||
this.workflowsStore.activeWorkflowExecution = null;
|
||||
await this.$router.replace({
|
||||
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',
|
||||
});
|
||||
},
|
||||
async onStopExecution(): Promise<void> {
|
||||
const activeExecutionId = this.$route.params.executionId;
|
||||
|
||||
try {
|
||||
await this.workflowsStore.stopCurrentExecution(activeExecutionId);
|
||||
|
||||
this.showMessage({
|
||||
title: this.$locale.baseText('executionsList.showMessage.stopExecution.title'),
|
||||
message: this.$locale.baseText('executionsList.showMessage.stopExecution.message', {
|
||||
interpolate: { activeExecutionId },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
await this.loadAutoRefresh();
|
||||
} catch (error) {
|
||||
this.showError(
|
||||
error,
|
||||
this.$locale.baseText('executionsList.showError.stopExecution.title'),
|
||||
);
|
||||
}
|
||||
},
|
||||
async onFilterUpdated(filter: ExecutionFilterType) {
|
||||
this.filter = filter;
|
||||
await this.setExecutions();
|
||||
},
|
||||
async setExecutions(): Promise<void> {
|
||||
this.workflowsStore.currentWorkflowExecutions = await this.loadExecutions();
|
||||
await this.setActiveExecution();
|
||||
},
|
||||
|
||||
async startAutoRefreshInterval() {
|
||||
if (this.autoRefresh) {
|
||||
await this.loadAutoRefresh();
|
||||
this.stopAutoRefreshInterval();
|
||||
this.autoRefreshTimeout = setTimeout(() => {
|
||||
void this.startAutoRefreshInterval();
|
||||
}, 4000);
|
||||
}
|
||||
},
|
||||
stopAutoRefreshInterval() {
|
||||
clearTimeout(this.autoRefreshTimeout);
|
||||
this.autoRefreshTimeout = undefined;
|
||||
},
|
||||
onAutoRefreshToggle(value: boolean): void {
|
||||
this.autoRefresh = value;
|
||||
this.uiStore.executionSidebarAutoRefresh = this.autoRefresh;
|
||||
|
||||
this.stopAutoRefreshInterval(); // Clear any previously existing intervals (if any - there shouldn't)
|
||||
void this.startAutoRefreshInterval();
|
||||
},
|
||||
onDocumentVisibilityChange() {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
void this.stopAutoRefreshInterval();
|
||||
} else {
|
||||
void this.startAutoRefreshInterval();
|
||||
}
|
||||
},
|
||||
async loadAutoRefresh(): Promise<void> {
|
||||
// Most of the auto-refresh logic is taken from the `ExecutionsList` component
|
||||
const fetchedExecutions: ExecutionSummary[] = await this.loadExecutions();
|
||||
let existingExecutions: ExecutionSummary[] = [...this.executions];
|
||||
const alreadyPresentExecutionIds = existingExecutions.map((exec) => parseInt(exec.id, 10));
|
||||
let lastId = 0;
|
||||
const gaps = [] as number[];
|
||||
let updatedActiveExecution = null;
|
||||
|
||||
for (let i = fetchedExecutions.length - 1; i >= 0; i--) {
|
||||
const currentItem = fetchedExecutions[i];
|
||||
const currentId = parseInt(currentItem.id, 10);
|
||||
if (lastId !== 0 && !isNaN(currentId)) {
|
||||
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;
|
||||
if (currentItem.id === this.activeExecution?.id) {
|
||||
updatedActiveExecution = 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.workflowsStore.currentWorkflowExecutions = existingExecutions;
|
||||
if (updatedActiveExecution !== null) {
|
||||
this.workflowsStore.activeWorkflowExecution = updatedActiveExecution;
|
||||
} else {
|
||||
const activeInList = existingExecutions.some((ex) => ex.id === this.activeExecution?.id);
|
||||
if (!activeInList && this.executions.length > 0 && !this.temporaryExecution) {
|
||||
this.$router
|
||||
.push({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: this.currentWorkflow, executionId: this.executions[0].id },
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (this.executions.length === 0 && this.$route.name === VIEWS.EXECUTION_PREVIEW) {
|
||||
this.$router
|
||||
.push({
|
||||
name: VIEWS.EXECUTION_HOME,
|
||||
params: {
|
||||
name: this.currentWorkflow,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
this.workflowsStore.activeWorkflowExecution = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
async loadExecutions(): Promise<ExecutionSummary[]> {
|
||||
if (!this.currentWorkflow) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return await this.workflowsStore.loadCurrentWorkflowExecutions(this.requestFilter);
|
||||
} catch (error) {
|
||||
if (error.errorCode === NO_NETWORK_ERROR_CODE) {
|
||||
this.showMessage(
|
||||
{
|
||||
title: this.$locale.baseText('executionsList.showError.refreshData.title'),
|
||||
message: error.message,
|
||||
type: 'error',
|
||||
duration: 3500,
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
this.showError(
|
||||
error,
|
||||
this.$locale.baseText('executionsList.showError.refreshData.title'),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
},
|
||||
async setActiveExecution(): Promise<void> {
|
||||
const activeExecutionId = this.$route.params.executionId;
|
||||
if (activeExecutionId) {
|
||||
const execution = this.workflowsStore.getExecutionDataById(activeExecutionId);
|
||||
if (execution) {
|
||||
this.workflowsStore.activeWorkflowExecution = execution;
|
||||
} else {
|
||||
await this.tryToFindExecution(activeExecutionId);
|
||||
}
|
||||
}
|
||||
|
||||
// If there is no execution in the route, select the first one
|
||||
if (
|
||||
this.workflowsStore.activeWorkflowExecution === null &&
|
||||
this.executions.length > 0 &&
|
||||
!this.temporaryExecution
|
||||
) {
|
||||
this.workflowsStore.activeWorkflowExecution = this.executions[0];
|
||||
|
||||
if (this.$route.name === VIEWS.EXECUTION_HOME) {
|
||||
this.$router
|
||||
.push({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: this.currentWorkflow, executionId: this.executions[0].id },
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
async tryToFindExecution(executionId: string, attemptCount = 0): Promise<void> {
|
||||
// First check if executions exists in the DB at all
|
||||
if (attemptCount === 0) {
|
||||
const existingExecution = await this.workflowsStore.fetchExecutionDataById(executionId);
|
||||
if (!existingExecution) {
|
||||
this.workflowsStore.activeWorkflowExecution = null;
|
||||
this.showError(
|
||||
new Error(
|
||||
this.$locale.baseText('executionView.notFound.message', {
|
||||
interpolate: { executionId },
|
||||
}),
|
||||
),
|
||||
this.$locale.baseText('nodeView.showError.openExecution.title'),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
this.temporaryExecution = existingExecution as ExecutionSummary;
|
||||
}
|
||||
}
|
||||
// stop if the execution wasn't found in the first 1000 lookups
|
||||
if (attemptCount >= MAX_LOADING_ATTEMPTS) {
|
||||
if (this.temporaryExecution) {
|
||||
this.workflowsStore.activeWorkflowExecution = this.temporaryExecution;
|
||||
return;
|
||||
}
|
||||
this.workflowsStore.activeWorkflowExecution = null;
|
||||
return;
|
||||
}
|
||||
// Fetch next batch of executions
|
||||
await this.loadMore(LOAD_MORE_PAGE_SIZE);
|
||||
const execution = this.workflowsStore.getExecutionDataById(executionId);
|
||||
if (!execution) {
|
||||
// If it's not there load next until found
|
||||
await this.$nextTick();
|
||||
// But skip fetching execution data since we at this point know it exists
|
||||
await this.tryToFindExecution(executionId, attemptCount + 1);
|
||||
} else {
|
||||
// When found set execution as active
|
||||
this.workflowsStore.activeWorkflowExecution = execution;
|
||||
this.temporaryExecution = null;
|
||||
return;
|
||||
}
|
||||
},
|
||||
async openWorkflow(workflowId: string): Promise<void> {
|
||||
await this.loadActiveWorkflows();
|
||||
|
||||
let data: IWorkflowDb | undefined;
|
||||
try {
|
||||
data = await this.workflowsStore.fetchWorkflow(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.workflowsStore.setActive(data.active || false);
|
||||
this.workflowsStore.setWorkflowId(workflowId);
|
||||
this.workflowsStore.setWorkflowName({ newName: data.name, setStateDirty: false });
|
||||
this.workflowsStore.setWorkflowSettings(data.settings || {});
|
||||
this.workflowsStore.setWorkflowPinData(data.pinData || {});
|
||||
const tags = (data.tags || []) as ITag[];
|
||||
const tagIds = tags.map((tag) => tag.id);
|
||||
this.workflowsStore.setWorkflowTagIds(tagIds || []);
|
||||
this.workflowsStore.setWorkflowVersionId(data.versionId);
|
||||
|
||||
this.tagsStore.upsertTags(tags);
|
||||
|
||||
void this.externalHooks.run('workflow.open', { workflowId, workflowName: data.name });
|
||||
this.uiStore.stateIsDirty = false;
|
||||
},
|
||||
async addNodes(nodes: INodeUi[], connections?: IConnections) {
|
||||
if (!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.nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
||||
|
||||
// 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}"`,
|
||||
);
|
||||
console.error(e);
|
||||
}
|
||||
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.workflowsStore.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.workflowsStore.addConnection({
|
||||
connection: connectionData,
|
||||
setStateDirty: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
|
||||
const allNodes: INodeTypeDescription[] = this.nodeTypesStore.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.nodeTypesStore.getNodesInformation(nodesToBeFetched);
|
||||
}
|
||||
},
|
||||
async loadActiveWorkflows(): Promise<void> {
|
||||
await this.workflowsStore.fetchActiveWorkflows();
|
||||
},
|
||||
async onRetryExecution(payload: { execution: ExecutionSummary; 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);
|
||||
await this.loadAutoRefresh();
|
||||
|
||||
this.$telemetry.track('User clicked retry execution button', {
|
||||
workflow_id: this.workflowsStore.workflowId,
|
||||
execution_id: payload.execution.id,
|
||||
retry_type: loadWorkflow ? 'current' : 'original',
|
||||
});
|
||||
},
|
||||
async retryExecution(execution: ExecutionSummary, loadWorkflow?: boolean) {
|
||||
try {
|
||||
const retrySuccessful = await this.workflowsStore.retryExecution(
|
||||
execution.id,
|
||||
loadWorkflow,
|
||||
);
|
||||
|
||||
if (retrySuccessful) {
|
||||
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%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -18,7 +18,6 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import type { Route, RouteLocationRaw } from 'vue-router';
|
||||
import { mapStores } from 'pinia';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { pushConnection } from '@/mixins/pushConnection';
|
||||
import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue';
|
||||
import TabBar from '@/components/MainHeader/TabBar.vue';
|
||||
@@ -32,6 +31,8 @@ import type { INodeUi, ITabBarItem } from '@/Interface';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MainHeader',
|
||||
@@ -50,11 +51,18 @@ export default defineComponent({
|
||||
return {
|
||||
activeHeaderTab: MAIN_HEADER_TABS.WORKFLOW,
|
||||
workflowToReturnTo: '',
|
||||
executionToReturnTo: '',
|
||||
dirtyState: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useNDVStore, useUIStore, useSourceControlStore),
|
||||
...mapStores(
|
||||
useNDVStore,
|
||||
useUIStore,
|
||||
useSourceControlStore,
|
||||
useWorkflowsStore,
|
||||
useExecutionsStore,
|
||||
),
|
||||
tabBarItems(): ITabBarItem[] {
|
||||
return [
|
||||
{ value: MAIN_HEADER_TABS.WORKFLOW, label: this.$locale.baseText('generic.editor') },
|
||||
@@ -79,16 +87,13 @@ export default defineComponent({
|
||||
(this.$route.meta.nodeView || this.$route.meta.keepWorkflowAlive === true)
|
||||
);
|
||||
},
|
||||
activeExecution(): ExecutionSummary {
|
||||
return this.workflowsStore.activeWorkflowExecution as ExecutionSummary;
|
||||
},
|
||||
readOnly(): boolean {
|
||||
return this.sourceControlStore.preferences.branchReadOnly;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
this.syncTabsWithRoute(to);
|
||||
this.syncTabsWithRoute(to, from);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
@@ -96,23 +101,27 @@ export default defineComponent({
|
||||
this.syncTabsWithRoute(this.$route);
|
||||
},
|
||||
methods: {
|
||||
syncTabsWithRoute(route: Route): void {
|
||||
syncTabsWithRoute(to: Route, from?: Route): void {
|
||||
if (
|
||||
route.name === VIEWS.EXECUTION_HOME ||
|
||||
route.name === VIEWS.WORKFLOW_EXECUTIONS ||
|
||||
route.name === VIEWS.EXECUTION_PREVIEW
|
||||
to.name === VIEWS.EXECUTION_HOME ||
|
||||
to.name === VIEWS.WORKFLOW_EXECUTIONS ||
|
||||
to.name === VIEWS.EXECUTION_PREVIEW
|
||||
) {
|
||||
this.activeHeaderTab = MAIN_HEADER_TABS.EXECUTIONS;
|
||||
} else if (
|
||||
route.name === VIEWS.WORKFLOW ||
|
||||
route.name === VIEWS.NEW_WORKFLOW ||
|
||||
route.name === VIEWS.EXECUTION_DEBUG
|
||||
to.name === VIEWS.WORKFLOW ||
|
||||
to.name === VIEWS.NEW_WORKFLOW ||
|
||||
to.name === VIEWS.EXECUTION_DEBUG
|
||||
) {
|
||||
this.activeHeaderTab = MAIN_HEADER_TABS.WORKFLOW;
|
||||
}
|
||||
const workflowName = route.params.name;
|
||||
if (workflowName !== 'new') {
|
||||
this.workflowToReturnTo = workflowName;
|
||||
|
||||
if (to.params.name !== 'new') {
|
||||
this.workflowToReturnTo = to.params.name;
|
||||
}
|
||||
|
||||
if (from?.name === VIEWS.EXECUTION_PREVIEW && to.params.name === from.params.name) {
|
||||
this.executionToReturnTo = from.params.executionId;
|
||||
}
|
||||
},
|
||||
onTabSelected(tab: MAIN_HEADER_TABS, event: MouseEvent) {
|
||||
@@ -158,10 +167,12 @@ export default defineComponent({
|
||||
async navigateToExecutionsView(openInNewTab: boolean) {
|
||||
const routeWorkflowId =
|
||||
this.currentWorkflow === PLACEHOLDER_EMPTY_WORKFLOW_ID ? 'new' : this.currentWorkflow;
|
||||
const routeToNavigateTo: RouteLocationRaw = this.activeExecution
|
||||
const executionToReturnTo =
|
||||
this.executionsStore.activeExecution?.id || this.executionToReturnTo;
|
||||
const routeToNavigateTo: RouteLocationRaw = executionToReturnTo
|
||||
? {
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: routeWorkflowId, executionId: this.activeExecution.id },
|
||||
params: { name: routeWorkflowId, executionId: executionToReturnTo },
|
||||
}
|
||||
: {
|
||||
name: VIEWS.EXECUTION_HOME,
|
||||
|
||||
@@ -119,7 +119,7 @@ import { useUsersStore } from '@/stores/users.store';
|
||||
import { useVersionsStore } from '@/stores/versions.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import ExecutionsUsage from '@/components/ExecutionsUsage.vue';
|
||||
import ExecutionsUsage from '@/components/executions/ExecutionsUsage.vue';
|
||||
import BecomeTemplateCreatorCta from '@/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue';
|
||||
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
|
||||
import { hasPermission } from '@/rbac/permissions';
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
|
||||
import { executionHelpers } from '@/mixins/executionsHelpers';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import type { IPushDataWorkerStatusPayload } from '@/Interface';
|
||||
@@ -38,7 +37,7 @@ export default defineComponent({
|
||||
name: 'WorkerList',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/naming-convention
|
||||
components: { PushConnectionTracker, WorkerCard },
|
||||
mixins: [pushConnection, executionHelpers],
|
||||
mixins: [pushConnection],
|
||||
props: {
|
||||
autoRefreshEnabled: {
|
||||
type: Boolean,
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useI18n } from '@/composables/useI18n';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -56,7 +56,7 @@ const emit = defineEmits<{
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const rootStore = useRootStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null);
|
||||
const nodeViewDetailsOpened = ref(false);
|
||||
@@ -115,11 +115,11 @@ const loadExecution = () => {
|
||||
'*',
|
||||
);
|
||||
|
||||
if (workflowsStore.activeWorkflowExecution) {
|
||||
if (executionsStore.activeExecution) {
|
||||
iframeRef.value?.contentWindow?.postMessage?.(
|
||||
JSON.stringify({
|
||||
command: 'setActiveExecution',
|
||||
execution: workflowsStore.activeWorkflowExecution,
|
||||
execution: executionsStore.activeExecution,
|
||||
}),
|
||||
'*',
|
||||
);
|
||||
|
||||
@@ -5,12 +5,12 @@ import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import type { INodeUi, IWorkflowDb } from '@/Interface';
|
||||
import WorkflowPreview from '@/components/WorkflowPreview.vue';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
|
||||
const renderComponent = createComponentRenderer(WorkflowPreview);
|
||||
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||
let executionsStore: ReturnType<typeof useExecutionsStore>;
|
||||
let postMessageSpy: vi.SpyInstance;
|
||||
let consoleErrorSpy: vi.SpyInstance;
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('WorkflowPreview', () => {
|
||||
beforeEach(() => {
|
||||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
workflowsStore = useWorkflowsStore();
|
||||
executionsStore = useExecutionsStore();
|
||||
|
||||
consoleErrorSpy = vi.spyOn(console, 'error');
|
||||
postMessageSpy = vi.fn();
|
||||
@@ -150,7 +150,7 @@ describe('WorkflowPreview', () => {
|
||||
});
|
||||
|
||||
it('should call also iframe postMessage with "setActiveExecution" if active execution is set', async () => {
|
||||
vi.spyOn(workflowsStore, 'activeWorkflowExecution', 'get').mockReturnValue({
|
||||
vi.spyOn(executionsStore, 'activeExecution', 'get').mockReturnValue({
|
||||
id: 'abc',
|
||||
} as ExecutionSummary);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, test, expect } from 'vitest';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import ExecutionFilter from '@/components/ExecutionFilter.vue';
|
||||
import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue';
|
||||
import { STORES } from '@/constants';
|
||||
import type { IWorkflowShortResponse, ExecutionFilterType } from '@/Interface';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
@@ -50,13 +50,13 @@ const initialState = {
|
||||
},
|
||||
};
|
||||
|
||||
const renderComponent = createComponentRenderer(ExecutionFilter, {
|
||||
const renderComponent = createComponentRenderer(ExecutionsFilter, {
|
||||
props: {
|
||||
teleported: false,
|
||||
},
|
||||
});
|
||||
|
||||
describe('ExecutionFilter', () => {
|
||||
describe('ExecutionsFilter', () => {
|
||||
afterAll(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -134,13 +134,11 @@ describe('ExecutionFilter', () => {
|
||||
);
|
||||
|
||||
test('state change', async () => {
|
||||
const { html, getByTestId, queryByTestId, emitted } = renderComponent({
|
||||
const { getByTestId, queryByTestId, emitted } = renderComponent({
|
||||
pinia: createTestingPinia({ initialState }),
|
||||
});
|
||||
|
||||
const filterChangedEvent = emitted().filterChanged;
|
||||
expect(filterChangedEvent).toHaveLength(1);
|
||||
expect(filterChangedEvent[0]).toEqual([defaultFilterState]);
|
||||
let filterChangedEvent = emitted().filterChanged;
|
||||
|
||||
expect(getByTestId('execution-filter-form')).not.toBeVisible();
|
||||
expect(queryByTestId('executions-filter-reset-button')).not.toBeInTheDocument();
|
||||
@@ -152,15 +150,18 @@ describe('ExecutionFilter', () => {
|
||||
await userEvent.click(getByTestId('executions-filter-status-select'));
|
||||
|
||||
await userEvent.click(getByTestId('executions-filter-status-select').querySelectorAll('li')[1]);
|
||||
filterChangedEvent = emitted().filterChanged;
|
||||
|
||||
expect(emitted().filterChanged).toHaveLength(2);
|
||||
expect(filterChangedEvent[1]).toEqual([{ ...defaultFilterState, status: 'error' }]);
|
||||
expect(filterChangedEvent).toHaveLength(1);
|
||||
expect(filterChangedEvent[0]).toEqual([{ ...defaultFilterState, status: 'error' }]);
|
||||
expect(getByTestId('executions-filter-reset-button')).toBeInTheDocument();
|
||||
expect(getByTestId('execution-filter-badge')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(getByTestId('executions-filter-reset-button'));
|
||||
expect(emitted().filterChanged).toHaveLength(3);
|
||||
expect(filterChangedEvent[2]).toEqual([defaultFilterState]);
|
||||
filterChangedEvent = emitted().filterChanged;
|
||||
|
||||
expect(filterChangedEvent).toHaveLength(2);
|
||||
expect(filterChangedEvent[1]).toEqual([defaultFilterState]);
|
||||
expect(queryByTestId('executions-filter-reset-button')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('execution-filter-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ExecutionFilterType,
|
||||
ExecutionFilterMetadata,
|
||||
IWorkflowShortResponse,
|
||||
IWorkflowDb,
|
||||
} from '@/Interface';
|
||||
import { i18n as locale } from '@/plugins/i18n';
|
||||
import TagsDropdown from '@/components/TagsDropdown.vue';
|
||||
@@ -16,7 +17,7 @@ import type { Placement } from '@floating-ui/core';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
|
||||
export type ExecutionFilterProps = {
|
||||
workflows?: IWorkflowShortResponse[];
|
||||
workflows?: Array<IWorkflowDb | IWorkflowShortResponse>;
|
||||
popoverPlacement?: Placement;
|
||||
teleported?: boolean;
|
||||
};
|
||||
@@ -30,6 +31,7 @@ const { debounce } = useDebounce();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const props = withDefaults(defineProps<ExecutionFilterProps>(), {
|
||||
workflows: [] as Array<IWorkflowDb | IWorkflowShortResponse>,
|
||||
popoverPlacement: 'bottom' as Placement,
|
||||
teleported: true,
|
||||
});
|
||||
@@ -92,7 +94,7 @@ const countSelectedFilterProps = computed(() => {
|
||||
if (filter.status !== 'all') {
|
||||
count++;
|
||||
}
|
||||
if (filter.workflowId !== 'all') {
|
||||
if (filter.workflowId !== 'all' && props.workflows.length) {
|
||||
count++;
|
||||
}
|
||||
if (!isEmpty(filter.tags)) {
|
||||
@@ -147,7 +149,6 @@ const goToUpgrade = () => {
|
||||
|
||||
onBeforeMount(() => {
|
||||
isCustomDataFilterTracked.value = false;
|
||||
emit('filterChanged', filter);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
@@ -8,7 +8,7 @@
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionTime',
|
||||
name: 'ExecutionsTime',
|
||||
props: ['startTime'],
|
||||
data() {
|
||||
return {
|
||||
@@ -4,18 +4,18 @@ import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { STORES, VIEWS } from '@/constants';
|
||||
import ExecutionsList from '@/components/ExecutionsList.vue';
|
||||
import ExecutionsList from '@/components/executions/global/GlobalExecutionsList.vue';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { retry, SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import type { RenderOptions } from '@/__tests__/render';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: vi.fn().mockReturnValue({
|
||||
name: VIEWS.WORKFLOW_EXECUTIONS,
|
||||
}),
|
||||
useRouter: vi.fn(),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -71,22 +71,21 @@ const generateExecutionsData = () =>
|
||||
estimated: false,
|
||||
}));
|
||||
|
||||
const defaultRenderOptions: RenderOptions = {
|
||||
const renderComponent = createComponentRenderer(ExecutionsList, {
|
||||
props: {
|
||||
autoRefreshEnabled: false,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
stubs: ['font-awesome-icon'],
|
||||
mocks: {
|
||||
$route: {
|
||||
params: {},
|
||||
},
|
||||
},
|
||||
stubs: ['font-awesome-icon'],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(ExecutionsList, defaultRenderOptions);
|
||||
|
||||
describe('ExecutionsList.vue', () => {
|
||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||
let workflowsData: IWorkflowDb[];
|
||||
describe('GlobalExecutionsList', () => {
|
||||
let executionsData: Array<{
|
||||
count: number;
|
||||
results: ExecutionSummary[];
|
||||
@@ -94,11 +93,13 @@ describe('ExecutionsList.vue', () => {
|
||||
}>;
|
||||
|
||||
beforeEach(() => {
|
||||
workflowsData = generateWorkflowsData();
|
||||
executionsData = generateExecutionsData();
|
||||
|
||||
pinia = createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.EXECUTIONS]: {
|
||||
executions: [],
|
||||
},
|
||||
[STORES.SETTINGS]: {
|
||||
settings: merge(SETTINGS_STORE_DEFAULT_STATE.settings, {
|
||||
enterprise: {
|
||||
@@ -108,22 +109,14 @@ describe('ExecutionsList.vue', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
workflowsStore = useWorkflowsStore();
|
||||
|
||||
vi.spyOn(workflowsStore, 'fetchAllWorkflows').mockResolvedValue(workflowsData);
|
||||
vi.spyOn(workflowsStore, 'getActiveExecutions').mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('should render empty list', async () => {
|
||||
vi.spyOn(workflowsStore, 'getPastExecutions').mockResolvedValueOnce({
|
||||
count: 0,
|
||||
results: [],
|
||||
estimated: false,
|
||||
});
|
||||
const { queryAllByTestId, queryByTestId, getByTestId } = renderComponent({
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
props: {
|
||||
executions: [],
|
||||
},
|
||||
pinia,
|
||||
});
|
||||
await waitAllPromises();
|
||||
|
||||
@@ -138,20 +131,16 @@ describe('ExecutionsList.vue', () => {
|
||||
it(
|
||||
'should handle selection flow when loading more items',
|
||||
async () => {
|
||||
const storeSpy = vi
|
||||
.spyOn(workflowsStore, 'getPastExecutions')
|
||||
.mockResolvedValueOnce(executionsData[0])
|
||||
.mockResolvedValueOnce(executionsData[1]);
|
||||
|
||||
const { getByTestId, getAllByTestId, queryByTestId } = renderComponent({
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
const { getByTestId, getAllByTestId, queryByTestId, rerender } = renderComponent({
|
||||
props: {
|
||||
executions: executionsData[0].results,
|
||||
total: executionsData[0].count,
|
||||
filteredExecutions: executionsData[0].results,
|
||||
},
|
||||
pinia,
|
||||
});
|
||||
await waitAllPromises();
|
||||
|
||||
expect(storeSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await userEvent.click(getByTestId('select-visible-executions-checkbox'));
|
||||
|
||||
await retry(() =>
|
||||
@@ -165,9 +154,12 @@ describe('ExecutionsList.vue', () => {
|
||||
expect(getByTestId('selected-executions-info').textContent).toContain(10);
|
||||
|
||||
await userEvent.click(getByTestId('load-more-button'));
|
||||
await rerender({
|
||||
executions: executionsData[0].results.concat(executionsData[1].results),
|
||||
filteredExecutions: executionsData[0].results.concat(executionsData[1].results),
|
||||
});
|
||||
|
||||
expect(storeSpy).toHaveBeenCalledTimes(2);
|
||||
expect(getAllByTestId('select-execution-checkbox').length).toBe(20);
|
||||
await waitFor(() => expect(getAllByTestId('select-execution-checkbox').length).toBe(20));
|
||||
expect(
|
||||
getAllByTestId('select-execution-checkbox').filter((el) =>
|
||||
el.contains(el.querySelector(':checked')),
|
||||
@@ -198,16 +190,18 @@ describe('ExecutionsList.vue', () => {
|
||||
);
|
||||
|
||||
it('should show "retry" data when appropriate', async () => {
|
||||
vi.spyOn(workflowsStore, 'getPastExecutions').mockResolvedValue(executionsData[0]);
|
||||
const retryOf = executionsData[0].results.filter((execution) => execution.retryOf);
|
||||
const retrySuccessId = executionsData[0].results.filter(
|
||||
(execution) => !execution.retryOf && execution.retrySuccessId,
|
||||
);
|
||||
|
||||
const { queryAllByText } = renderComponent({
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
props: {
|
||||
executions: executionsData[0].results,
|
||||
total: executionsData[0].count,
|
||||
filteredExecutions: executionsData[0].results,
|
||||
},
|
||||
pinia,
|
||||
});
|
||||
await waitAllPromises();
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
import { watch, computed, ref, onMounted } from 'vue';
|
||||
import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue';
|
||||
import GlobalExecutionsListItem from '@/components/executions/global/GlobalExecutionsListItem.vue';
|
||||
import { MODAL_CONFIRM } from '@/constants';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
|
||||
const props = defineProps({
|
||||
executions: {
|
||||
type: Array as PropType<ExecutionSummary[]>,
|
||||
default: () => [],
|
||||
},
|
||||
filters: {
|
||||
type: Object as PropType<ExecutionFilterType>,
|
||||
default: () => ({}),
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
estimated: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['closeModal', 'execution:stop', 'update:autoRefresh', 'update:filters']);
|
||||
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
|
||||
const isMounted = ref(false);
|
||||
const allVisibleSelected = ref(false);
|
||||
const allExistingSelected = ref(false);
|
||||
const selectedItems = ref<Record<string, boolean>>({});
|
||||
|
||||
const message = useMessage();
|
||||
const toast = useToast();
|
||||
|
||||
const selectedCount = computed(() => {
|
||||
if (allExistingSelected.value) {
|
||||
return props.total;
|
||||
}
|
||||
|
||||
return Object.keys(selectedItems.value).length;
|
||||
});
|
||||
|
||||
const workflows = computed<IWorkflowDb[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
name: i18n.baseText('executionsList.allWorkflows'),
|
||||
} as IWorkflowDb,
|
||||
...workflowsStore.allWorkflows,
|
||||
];
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.executions,
|
||||
() => {
|
||||
if (props.executions.length === 0) {
|
||||
handleClearSelection();
|
||||
}
|
||||
adjustSelectionAfterMoreItemsLoaded();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
});
|
||||
|
||||
function handleCheckAllExistingChange() {
|
||||
allExistingSelected.value = !allExistingSelected.value;
|
||||
allVisibleSelected.value = !allExistingSelected.value;
|
||||
handleCheckAllVisibleChange();
|
||||
}
|
||||
|
||||
function handleCheckAllVisibleChange() {
|
||||
allVisibleSelected.value = !allVisibleSelected.value;
|
||||
if (!allVisibleSelected.value) {
|
||||
allExistingSelected.value = false;
|
||||
selectedItems.value = {};
|
||||
} else {
|
||||
selectAllVisibleExecutions();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectExecution(execution: ExecutionSummary) {
|
||||
const executionId = execution.id;
|
||||
if (selectedItems.value[executionId]) {
|
||||
const { [executionId]: removedSelectedItem, ...rest } = selectedItems.value;
|
||||
selectedItems.value = rest;
|
||||
} else {
|
||||
selectedItems.value = {
|
||||
...selectedItems.value,
|
||||
[executionId]: true,
|
||||
};
|
||||
}
|
||||
allVisibleSelected.value = Object.keys(selectedItems.value).length === props.executions.length;
|
||||
allExistingSelected.value = Object.keys(selectedItems.value).length === props.total;
|
||||
}
|
||||
|
||||
async function handleDeleteSelected() {
|
||||
const deleteExecutions = await message.confirm(
|
||||
i18n.baseText('executionsList.confirmMessage.message', {
|
||||
interpolate: { count: selectedCount.value.toString() },
|
||||
}),
|
||||
i18n.baseText('executionsList.confirmMessage.headline'),
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: i18n.baseText('executionsList.confirmMessage.confirmButtonText'),
|
||||
cancelButtonText: i18n.baseText('executionsList.confirmMessage.cancelButtonText'),
|
||||
},
|
||||
);
|
||||
|
||||
if (deleteExecutions !== MODAL_CONFIRM) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await executionsStore.deleteExecutions({
|
||||
filters: executionsStore.executionsFilters,
|
||||
...(allExistingSelected.value
|
||||
? { deleteBefore: props.executions[0].startedAt }
|
||||
: {
|
||||
ids: Object.keys(selectedItems.value),
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.handleDeleteSelected.title'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('executionsList.showMessage.handleDeleteSelected.title'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
handleClearSelection();
|
||||
}
|
||||
|
||||
function handleClearSelection() {
|
||||
allVisibleSelected.value = false;
|
||||
allExistingSelected.value = false;
|
||||
selectedItems.value = {};
|
||||
}
|
||||
|
||||
async function onFilterChanged(filters: ExecutionFilterType) {
|
||||
emit('update:filters', filters);
|
||||
handleClearSelection();
|
||||
}
|
||||
|
||||
function getExecutionWorkflowName(execution: ExecutionSummary): string {
|
||||
return (
|
||||
getWorkflowName(execution.workflowId ?? '') ?? i18n.baseText('executionsList.unsavedWorkflow')
|
||||
);
|
||||
}
|
||||
|
||||
function getWorkflowName(workflowId: string): string | undefined {
|
||||
return workflows.value.find((data: IWorkflowDb) => data.id === workflowId)?.name;
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (executionsStore.filters.status === 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
let lastId: string | undefined;
|
||||
if (props.executions.length !== 0) {
|
||||
const lastItem = props.executions.slice(-1)[0];
|
||||
lastId = lastItem.id;
|
||||
}
|
||||
|
||||
try {
|
||||
await executionsStore.fetchExecutions(executionsStore.executionsFilters, lastId);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.loadMore.title'));
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllVisibleExecutions() {
|
||||
props.executions.forEach((execution: ExecutionSummary) => {
|
||||
selectedItems.value[execution.id] = true;
|
||||
});
|
||||
}
|
||||
|
||||
function adjustSelectionAfterMoreItemsLoaded() {
|
||||
if (allExistingSelected.value) {
|
||||
allVisibleSelected.value = true;
|
||||
selectAllVisibleExecutions();
|
||||
}
|
||||
}
|
||||
|
||||
async function retrySavedExecution(execution: ExecutionSummary) {
|
||||
await retryExecution(execution, true);
|
||||
}
|
||||
|
||||
async function retryOriginalExecution(execution: ExecutionSummary) {
|
||||
await retryExecution(execution, false);
|
||||
}
|
||||
|
||||
async function retryExecution(execution: ExecutionSummary, loadWorkflow?: boolean) {
|
||||
try {
|
||||
const retrySuccessful = await executionsStore.retryExecution(execution.id, loadWorkflow);
|
||||
|
||||
if (retrySuccessful) {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('executionsList.showMessage.retrySuccessfulTrue.title'),
|
||||
type: 'success',
|
||||
});
|
||||
} else {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('executionsList.showMessage.retrySuccessfulFalse.title'),
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.retryExecution.title'));
|
||||
}
|
||||
|
||||
telemetry.track('User clicked retry execution button', {
|
||||
workflow_id: workflowsStore.workflowId,
|
||||
execution_id: execution.id,
|
||||
retry_type: loadWorkflow ? 'current' : 'original',
|
||||
});
|
||||
}
|
||||
|
||||
async function stopExecution(execution: ExecutionSummary) {
|
||||
try {
|
||||
await executionsStore.stopCurrentExecution(execution.id);
|
||||
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('executionsList.showMessage.stopExecution.title'),
|
||||
message: i18n.baseText('executionsList.showMessage.stopExecution.message', {
|
||||
interpolate: { activeExecutionId: execution.id },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
emit('execution:stop');
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.stopExecution.title'));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteExecution(execution: ExecutionSummary) {
|
||||
try {
|
||||
await executionsStore.deleteExecutions({ ids: [execution.id] });
|
||||
|
||||
if (allVisibleSelected.value) {
|
||||
const { [execution.id]: _, ...rest } = selectedItems.value;
|
||||
selectedItems.value = rest;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.handleDeleteSelected.title'));
|
||||
}
|
||||
}
|
||||
|
||||
async function onAutoRefreshToggle(value: boolean) {
|
||||
if (value) {
|
||||
await executionsStore.startAutoRefreshInterval();
|
||||
} else {
|
||||
executionsStore.stopAutoRefreshInterval();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.execListWrapper">
|
||||
<div :class="$style.execList">
|
||||
<div :class="$style.execListHeader">
|
||||
<N8nHeading tag="h1" size="2xlarge">
|
||||
{{ i18n.baseText('executionsList.workflowExecutions') }}
|
||||
</N8nHeading>
|
||||
<div :class="$style.execListHeaderControls">
|
||||
<N8nLoading v-if="!isMounted" :class="$style.filterLoader" variant="custom" />
|
||||
<ElCheckbox
|
||||
v-else
|
||||
v-model="executionsStore.autoRefresh"
|
||||
class="mr-xl"
|
||||
data-test-id="execution-auto-refresh-checkbox"
|
||||
@update:model-value="onAutoRefreshToggle($event)"
|
||||
>
|
||||
{{ i18n.baseText('executionsList.autoRefresh') }}
|
||||
</ElCheckbox>
|
||||
<ExecutionsFilter
|
||||
v-show="isMounted"
|
||||
:workflows="workflows"
|
||||
@filter-changed="onFilterChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElCheckbox
|
||||
v-if="allVisibleSelected && total > 0"
|
||||
:class="$style.selectAll"
|
||||
:label="
|
||||
i18n.baseText('executionsList.selectAll', {
|
||||
adjustToNumber: total,
|
||||
interpolate: { executionNum: `${total}` },
|
||||
})
|
||||
"
|
||||
:model-value="allExistingSelected"
|
||||
data-test-id="select-all-executions-checkbox"
|
||||
@update:model-value="handleCheckAllExistingChange"
|
||||
/>
|
||||
|
||||
<div v-if="!isMounted">
|
||||
<N8nLoading :class="$style.tableLoader" variant="custom" />
|
||||
<N8nLoading :class="$style.tableLoader" variant="custom" />
|
||||
<N8nLoading :class="$style.tableLoader" variant="custom" />
|
||||
</div>
|
||||
<table v-else :class="$style.execTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<el-checkbox
|
||||
:model-value="allVisibleSelected"
|
||||
:disabled="total < 1"
|
||||
label=""
|
||||
data-test-id="select-visible-executions-checkbox"
|
||||
@update:model-value="handleCheckAllVisibleChange"
|
||||
/>
|
||||
</th>
|
||||
<th>{{ i18n.baseText('executionsList.name') }}</th>
|
||||
<th>{{ i18n.baseText('executionsList.startedAt') }}</th>
|
||||
<th>{{ i18n.baseText('executionsList.status') }}</th>
|
||||
<th>{{ i18n.baseText('executionsList.id') }}</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<TransitionGroup tag="tbody" name="executions-list">
|
||||
<GlobalExecutionsListItem
|
||||
v-for="execution in executions"
|
||||
:key="execution.id"
|
||||
:execution="execution"
|
||||
:workflow-name="getExecutionWorkflowName(execution)"
|
||||
:selected="selectedItems[execution.id] || allExistingSelected"
|
||||
@stop="stopExecution"
|
||||
@delete="deleteExecution"
|
||||
@select="toggleSelectExecution"
|
||||
@retry-saved="retrySavedExecution"
|
||||
@retry-original="retryOriginalExecution"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</table>
|
||||
|
||||
<div
|
||||
v-if="!executions.length && isMounted && !executionsStore.loading"
|
||||
:class="$style.loadedAll"
|
||||
data-test-id="execution-list-empty"
|
||||
>
|
||||
{{ i18n.baseText('executionsList.empty') }}
|
||||
</div>
|
||||
<div v-else-if="total > executions.length || estimated" :class="$style.loadMore">
|
||||
<N8nButton
|
||||
icon="sync"
|
||||
:title="i18n.baseText('executionsList.loadMore')"
|
||||
:label="i18n.baseText('executionsList.loadMore')"
|
||||
:loading="executionsStore.loading"
|
||||
data-test-id="load-more-button"
|
||||
@click="loadMore()"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isMounted && !executionsStore.loading"
|
||||
:class="$style.loadedAll"
|
||||
data-test-id="execution-all-loaded"
|
||||
>
|
||||
{{ i18n.baseText('executionsList.loadedAll') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedCount > 0"
|
||||
:class="$style.selectionOptions"
|
||||
data-test-id="selected-executions-info"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
i18n.baseText('executionsList.selected', {
|
||||
adjustToNumber: selectedCount,
|
||||
interpolate: { count: `${selectedCount}` },
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<N8nButton
|
||||
:label="i18n.baseText('generic.delete')"
|
||||
type="tertiary"
|
||||
data-test-id="delete-selected-button"
|
||||
@click="handleDeleteSelected"
|
||||
/>
|
||||
<N8nButton
|
||||
:label="i18n.baseText('executionsList.clearSelection')"
|
||||
type="tertiary"
|
||||
data-test-id="clear-selection-button"
|
||||
@click="handleClearSelection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.execListWrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 0;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.execList {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: var(--spacing-l) var(--spacing-l) 0;
|
||||
@media (min-width: 1200px) {
|
||||
padding: var(--spacing-2xl) var(--spacing-2xl) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.execListHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.execListHeaderControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.selectionOptions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
padding: var(--spacing-2xs);
|
||||
z-index: 2;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: var(--spacing-3xl);
|
||||
background: var(--color-background-dark);
|
||||
border-radius: var(--border-radius-base);
|
||||
color: var(--color-text-xlight);
|
||||
font-size: var(--font-size-2xs);
|
||||
|
||||
button {
|
||||
margin-left: var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.execTable {
|
||||
/*
|
||||
Table height needs to be set to 0 in order to use height 100% for elements in table cells
|
||||
*/
|
||||
height: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: var(--font-size-s);
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: calc(var(--spacing-3xl) * -1);
|
||||
z-index: 2;
|
||||
padding: var(--spacing-s) var(--spacing-s) var(--spacing-s) 0;
|
||||
background: var(--color-table-header-background);
|
||||
|
||||
&:first-child {
|
||||
padding-left: var(--spacing-s);
|
||||
}
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
height: 100%;
|
||||
padding: var(--spacing-s) var(--spacing-s) var(--spacing-s) 0;
|
||||
|
||||
&:not(:first-child, :nth-last-child(-n + 3)) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:nth-last-child(-n + 2) {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
@media (min-width: $breakpoint-sm) {
|
||||
&:not(:nth-child(2)) {
|
||||
&,
|
||||
div,
|
||||
span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loadMore {
|
||||
margin: var(--spacing-m) 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loadedAll {
|
||||
text-align: center;
|
||||
font-size: var(--font-size-s);
|
||||
color: var(--color-text-light);
|
||||
margin: var(--spacing-l) 0;
|
||||
}
|
||||
|
||||
.actions.deleteOnly {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.retryAction + .deleteAction {
|
||||
border-top: 1px solid var(--color-foreground-light);
|
||||
}
|
||||
|
||||
.selectAll {
|
||||
display: inline-block;
|
||||
margin: 0 0 var(--spacing-s) var(--spacing-s);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.filterLoader {
|
||||
width: 220px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.tableLoader {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
margin-bottom: var(--spacing-2xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import GlobalExecutionsListItem from './GlobalExecutionsListItem.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
|
||||
vi.mock('vue-router', async () => {
|
||||
const actual = await vi.importActual('vue-router');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useRouter: vi.fn(() => ({
|
||||
resolve: vi.fn(() => ({ href: 'mockedRoute' })),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(GlobalExecutionsListItem, {
|
||||
global: {
|
||||
stubs: ['font-awesome-icon', 'n8n-tooltip', 'n8n-button', 'i18n-t'],
|
||||
},
|
||||
});
|
||||
|
||||
describe('GlobalExecutionsListItem', () => {
|
||||
it('should render the status text for an execution', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { execution: { status: 'running', id: 123, workflowName: 'Test Workflow' } },
|
||||
});
|
||||
|
||||
expect(getByTestId('execution-status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should emit stop event on stop button click for a running execution', async () => {
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: { execution: { status: 'running', id: 123, stoppedAt: undefined, waitTill: true } },
|
||||
});
|
||||
|
||||
const stopButton = getByTestId('stop-execution-button');
|
||||
|
||||
expect(stopButton).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(stopButton);
|
||||
expect(emitted().stop).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should emit retry events on retry original and retry saved dropdown items click', async () => {
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: {
|
||||
execution: {
|
||||
status: 'error',
|
||||
id: 123,
|
||||
stoppedAt: '01-01-2024',
|
||||
finished: false,
|
||||
retryOf: undefined,
|
||||
retrySuccessfulId: undefined,
|
||||
waitTill: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('execution-retry-saved-dropdown-item'));
|
||||
expect(emitted().retrySaved).toBeTruthy();
|
||||
|
||||
await fireEvent.click(getByTestId('execution-retry-original-dropdown-item'));
|
||||
expect(emitted().retryOriginal).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should emit delete event on delete dropdown item click', async () => {
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: {
|
||||
execution: {
|
||||
status: 'error',
|
||||
id: 123,
|
||||
stoppedAt: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('execution-delete-dropdown-item'));
|
||||
expect(emitted().delete).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should open a new window on execution click', async () => {
|
||||
global.window.open = vi.fn();
|
||||
|
||||
const { getByText } = renderComponent({
|
||||
props: { execution: { status: 'success', id: 123, workflowName: 'TestWorkflow' } },
|
||||
});
|
||||
|
||||
await fireEvent.click(getByText('TestWorkflow'));
|
||||
expect(window.open).toHaveBeenCalledWith('mockedRoute', '_blank');
|
||||
});
|
||||
|
||||
it('should show formatted start date', () => {
|
||||
const testDate = '2022-01-01T12:00:00Z';
|
||||
const { getByText } = renderComponent({
|
||||
props: { execution: { status: 'success', id: 123, startedAt: testDate } },
|
||||
});
|
||||
|
||||
expect(getByText(`1 Jan, 2022 at ${new Date(testDate).getHours()}:00:00`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,404 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
import { ref, computed, useCssModule } from 'vue';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { VIEWS, WAIT_TIME_UNLIMITED } from '@/constants';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
||||
import { i18n as locale } from '@/plugins/i18n';
|
||||
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
|
||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||
|
||||
const emit = defineEmits(['stop', 'select', 'retrySaved', 'retryOriginal', 'delete']);
|
||||
|
||||
const props = defineProps({
|
||||
execution: {
|
||||
type: Object as PropType<ExecutionSummary>,
|
||||
required: true,
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
workflowName: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const style = useCssModule();
|
||||
const i18n = useI18n();
|
||||
const router = useRouter();
|
||||
const executionHelpers = useExecutionHelpers();
|
||||
|
||||
const isStopping = ref(false);
|
||||
|
||||
const isRunning = computed(() => {
|
||||
return props.execution.status === 'running';
|
||||
});
|
||||
|
||||
const isWaitTillIndefinite = computed(() => {
|
||||
if (!props.execution.waitTill) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Date(props.execution.waitTill).toISOString() === WAIT_TIME_UNLIMITED;
|
||||
});
|
||||
|
||||
const isRetriable = computed(() => executionHelpers.isExecutionRetriable(props.execution));
|
||||
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
[style.executionListItem]: true,
|
||||
[style[props.execution.status ?? '']]: !!props.execution.status,
|
||||
};
|
||||
});
|
||||
|
||||
const formattedStartedAtDate = computed(() => {
|
||||
return props.execution.startedAt ? formatDate(props.execution.startedAt) : '';
|
||||
});
|
||||
|
||||
const formattedWaitTillDate = computed(() => {
|
||||
return props.execution.waitTill ? formatDate(props.execution.waitTill) : '';
|
||||
});
|
||||
|
||||
const formattedStoppedAtDate = computed(() => {
|
||||
return props.execution.stoppedAt
|
||||
? i18n.displayTimer(
|
||||
new Date(props.execution.stoppedAt).getTime() -
|
||||
new Date(props.execution.startedAt).getTime(),
|
||||
true,
|
||||
)
|
||||
: '';
|
||||
});
|
||||
|
||||
const statusTooltipText = computed(() => {
|
||||
if (props.execution.status === 'waiting' && isWaitTillIndefinite.value) {
|
||||
return i18n.baseText('executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.execution.status) {
|
||||
case 'waiting':
|
||||
return i18n.baseText('executionsList.waiting');
|
||||
case 'canceled':
|
||||
return i18n.baseText('executionsList.canceled');
|
||||
case 'crashed':
|
||||
return i18n.baseText('executionsList.error');
|
||||
case 'new':
|
||||
return i18n.baseText('executionsList.running');
|
||||
case 'running':
|
||||
return i18n.baseText('executionsList.running');
|
||||
case 'success':
|
||||
return i18n.baseText('executionsList.succeeded');
|
||||
case 'error':
|
||||
return i18n.baseText('executionsList.error');
|
||||
default:
|
||||
return i18n.baseText('executionsList.unknown');
|
||||
}
|
||||
});
|
||||
|
||||
const statusTextTranslationPath = computed(() => {
|
||||
switch (props.execution.status) {
|
||||
case 'waiting':
|
||||
return 'executionsList.statusWaiting';
|
||||
case 'canceled':
|
||||
return 'executionsList.statusCanceled';
|
||||
case 'crashed':
|
||||
case 'error':
|
||||
case 'success':
|
||||
if (!props.execution.stoppedAt) {
|
||||
return 'executionsList.statusTextWithoutTime';
|
||||
} else {
|
||||
return 'executionsList.statusText';
|
||||
}
|
||||
case 'new':
|
||||
return 'executionsList.statusRunning';
|
||||
case 'running':
|
||||
return 'executionsList.statusRunning';
|
||||
default:
|
||||
return 'executionsList.statusUnknown';
|
||||
}
|
||||
});
|
||||
|
||||
function formatDate(fullDate: Date | string | number) {
|
||||
const { date, time } = convertToDisplayDate(fullDate);
|
||||
return locale.baseText('executionsList.started', { interpolate: { time, date } });
|
||||
}
|
||||
|
||||
function displayExecution() {
|
||||
const route = router.resolve({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: props.execution.workflowId, executionId: props.execution.id },
|
||||
});
|
||||
window.open(route.href, '_blank');
|
||||
}
|
||||
|
||||
function onStopExecution() {
|
||||
isStopping.value = true;
|
||||
emit('stop', props.execution);
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
emit('select', props.execution);
|
||||
}
|
||||
|
||||
async function handleActionItemClick(commandData: 'retrySaved' | 'retryOriginal' | 'delete') {
|
||||
emit(commandData, props.execution);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<tr :class="classes">
|
||||
<td>
|
||||
<ElCheckbox
|
||||
v-if="!!execution.stoppedAt && execution.id"
|
||||
:model-value="selected"
|
||||
label=""
|
||||
data-test-id="select-execution-checkbox"
|
||||
@update:model-value="onSelect"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="$style.link" @click.stop="displayExecution">
|
||||
{{ execution.workflowName || workflowName }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ formattedStartedAtDate }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div :class="$style.statusColumn">
|
||||
<span v-if="isRunning" :class="$style.spinner">
|
||||
<FontAwesomeIcon icon="spinner" spin />
|
||||
</span>
|
||||
<i18n-t
|
||||
v-if="!isWaitTillIndefinite"
|
||||
data-test-id="execution-status"
|
||||
tag="span"
|
||||
:keypath="statusTextTranslationPath"
|
||||
>
|
||||
<template #status>
|
||||
<span :class="$style.status">{{ statusText }}</span>
|
||||
</template>
|
||||
<template #time>
|
||||
<span v-if="execution.waitTill">{{ formattedWaitTillDate }}</span>
|
||||
<span v-else-if="!!execution.stoppedAt">
|
||||
{{ formattedStoppedAtDate }}
|
||||
</span>
|
||||
<ExecutionsTime v-else :start-time="execution.startedAt" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
<N8nTooltip v-else placement="top">
|
||||
<template #content>
|
||||
<span>{{ statusTooltipText }}</span>
|
||||
</template>
|
||||
<span :class="$style.status">{{ statusText }}</span>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="execution.id">#{{ execution.id }}</span>
|
||||
<span v-if="execution.retryOf">
|
||||
<br />
|
||||
<small> ({{ i18n.baseText('executionsList.retryOf') }} #{{ execution.retryOf }}) </small>
|
||||
</span>
|
||||
<span v-else-if="execution.retrySuccessId">
|
||||
<br />
|
||||
<small>
|
||||
({{ i18n.baseText('executionsList.successRetry') }} #{{ execution.retrySuccessId }})
|
||||
</small>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<N8nTooltip v-if="execution.mode === 'manual'" placement="top">
|
||||
<template #content>
|
||||
<span>{{ i18n.baseText('executionsList.test') }}</span>
|
||||
</template>
|
||||
<FontAwesomeIcon icon="flask" />
|
||||
</N8nTooltip>
|
||||
</td>
|
||||
<td>
|
||||
<div :class="$style.buttonCell">
|
||||
<N8nButton
|
||||
v-if="!!execution.stoppedAt && execution.id"
|
||||
size="small"
|
||||
outline
|
||||
:label="i18n.baseText('executionsList.view')"
|
||||
@click.stop="displayExecution"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div :class="$style.buttonCell">
|
||||
<N8nButton
|
||||
v-if="!execution.stoppedAt || execution.waitTill"
|
||||
data-test-id="stop-execution-button"
|
||||
size="small"
|
||||
outline
|
||||
:label="i18n.baseText('executionsList.stop')"
|
||||
:loading="isStopping"
|
||||
@click.stop="onStopExecution"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<ElDropdown v-if="!isRunning" trigger="click" @command="handleActionItemClick">
|
||||
<N8nIconButton text type="tertiary" size="mini" icon="ellipsis-v" />
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu
|
||||
:class="{
|
||||
[$style.actions]: true,
|
||||
[$style.deleteOnly]: !isRetriable,
|
||||
}"
|
||||
>
|
||||
<ElDropdownItem
|
||||
v-if="isRetriable"
|
||||
data-test-id="execution-retry-saved-dropdown-item"
|
||||
:class="$style.retryAction"
|
||||
command="retrySaved"
|
||||
>
|
||||
{{ i18n.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="isRetriable"
|
||||
data-test-id="execution-retry-original-dropdown-item"
|
||||
:class="$style.retryAction"
|
||||
command="retryOriginal"
|
||||
>
|
||||
{{ i18n.baseText('executionsList.retryWithOriginalWorkflow') }}
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
data-test-id="execution-delete-dropdown-item"
|
||||
:class="$style.deleteAction"
|
||||
command="delete"
|
||||
>
|
||||
{{ i18n.baseText('generic.delete') }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@import '@/styles/variables';
|
||||
|
||||
.executionListItem {
|
||||
--execution-list-item-background: var(--color-table-row-background);
|
||||
--execution-list-item-highlight-background: var(--color-table-row-highlight-background);
|
||||
color: var(--color-text-base);
|
||||
|
||||
td {
|
||||
background: var(--execution-list-item-background);
|
||||
}
|
||||
|
||||
&:nth-child(even) td {
|
||||
--execution-list-item-background: var(--color-table-row-even-background);
|
||||
}
|
||||
|
||||
&:hover td {
|
||||
background: var(--color-table-row-hover-background);
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
width: 30px;
|
||||
padding: 0 var(--spacing-s) 0 0;
|
||||
|
||||
/*
|
||||
This is needed instead of table cell border because they are overlapping the sticky header
|
||||
*/
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: var(--spacing-4xs);
|
||||
height: 100%;
|
||||
vertical-align: middle;
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
|
||||
&.crashed td:first-child::before,
|
||||
&.error td:first-child::before {
|
||||
background: var(--execution-card-border-error);
|
||||
}
|
||||
|
||||
&.success td:first-child::before {
|
||||
background: var(--execution-card-border-success);
|
||||
}
|
||||
|
||||
&.new td:first-child::before,
|
||||
&.running td:first-child::before {
|
||||
background: var(--execution-card-border-running);
|
||||
}
|
||||
|
||||
&.waiting td:first-child::before {
|
||||
background: var(--execution-card-border-waiting);
|
||||
}
|
||||
|
||||
&.unknown td:first-child::before {
|
||||
background: var(--execution-card-border-unknown);
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-text-base);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.statusColumn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
margin-right: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.status {
|
||||
line-height: 22.6px;
|
||||
text-align: center;
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: var(--font-weight-bold);
|
||||
|
||||
.crashed &,
|
||||
.error & {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.waiting & {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.success & {
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.new &,
|
||||
.running & {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.unknown & {
|
||||
color: var(--color-background-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.buttonCell {
|
||||
overflow: hidden;
|
||||
|
||||
button {
|
||||
transform: translateX(1000%);
|
||||
transition: transform 0s;
|
||||
|
||||
&:focus-visible,
|
||||
.executionListItem:hover & {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import ExecutionCard from '@/components/ExecutionsView/ExecutionCard.vue';
|
||||
import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
const renderComponent = createComponentRenderer(ExecutionCard, {
|
||||
const renderComponent = createComponentRenderer(WorkflowExecutionsCard, {
|
||||
global: {
|
||||
stubs: {
|
||||
'router-link': {
|
||||
@@ -17,7 +17,7 @@ const renderComponent = createComponentRenderer(ExecutionCard, {
|
||||
},
|
||||
});
|
||||
|
||||
describe('ExecutionCard', () => {
|
||||
describe('WorkflowExecutionsCard', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
:class="{
|
||||
['execution-card']: true,
|
||||
[$style.executionCard]: true,
|
||||
[$style.WorkflowExecutionsCard]: true,
|
||||
[$style.active]: isActive,
|
||||
[$style[executionUIDetails.name]]: true,
|
||||
[$style.highlight]: highlight,
|
||||
@@ -37,7 +37,7 @@
|
||||
size="small"
|
||||
>
|
||||
{{ $locale.baseText('executionDetails.runningTimeRunning') }}
|
||||
<ExecutionTime :start-time="execution.startedAt" />
|
||||
<ExecutionsTime :start-time="execution.startedAt" />
|
||||
</n8n-text>
|
||||
<n8n-text
|
||||
v-else-if="executionUIDetails.runningTime !== ''"
|
||||
@@ -83,18 +83,19 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import type { IExecutionUIData } from '@/mixins/executionsHelpers';
|
||||
import { executionHelpers } from '@/mixins/executionsHelpers';
|
||||
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
|
||||
import { VIEWS } from '@/constants';
|
||||
import ExecutionTime from '@/components/ExecutionTime.vue';
|
||||
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
|
||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionCard',
|
||||
name: 'WorkflowExecutionsCard',
|
||||
components: {
|
||||
ExecutionTime,
|
||||
ExecutionsTime,
|
||||
},
|
||||
mixins: [executionHelpers],
|
||||
props: {
|
||||
execution: {
|
||||
type: Object as () => ExecutionSummary,
|
||||
@@ -109,12 +110,19 @@ export default defineComponent({
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
setup() {
|
||||
const executionHelpers = useExecutionHelpers();
|
||||
|
||||
return {
|
||||
executionHelpers,
|
||||
VIEWS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useWorkflowsStore),
|
||||
currentWorkflow(): string {
|
||||
return (this.$route.params.name as string) || this.workflowsStore.workflowId;
|
||||
},
|
||||
retryExecutionActions(): object[] {
|
||||
return [
|
||||
{
|
||||
@@ -128,13 +136,13 @@ export default defineComponent({
|
||||
];
|
||||
},
|
||||
executionUIDetails(): IExecutionUIData {
|
||||
return this.getExecutionUIDetails(this.execution);
|
||||
return this.executionHelpers.getUIDetails(this.execution);
|
||||
},
|
||||
isActive(): boolean {
|
||||
return this.execution.id === this.$route.params.executionId;
|
||||
},
|
||||
isRetriable(): boolean {
|
||||
return this.isExecutionRetriable(this.execution);
|
||||
return this.executionHelpers.isExecutionRetriable(this.execution);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
@@ -146,7 +154,12 @@ export default defineComponent({
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.executionCard {
|
||||
@import '@/styles/variables';
|
||||
|
||||
.WorkflowExecutionsCard {
|
||||
--execution-list-item-background: var(--color-foreground-xlight);
|
||||
--execution-list-item-highlight-background: var(--color-warning-tint-1);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-right: var(--spacing-m);
|
||||
@@ -162,10 +175,11 @@ export default defineComponent({
|
||||
&:hover,
|
||||
&.active {
|
||||
.executionLink {
|
||||
background-color: var(--execution-card-background-hover);
|
||||
--execution-list-item-background: var(--color-foreground-light);
|
||||
}
|
||||
}
|
||||
|
||||
&.new,
|
||||
&.running {
|
||||
.spinner {
|
||||
position: relative;
|
||||
@@ -217,6 +231,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.executionLink {
|
||||
background: var(--execution-list-item-background);
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
@@ -38,6 +38,7 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
@@ -46,7 +47,6 @@ import { PLACEHOLDER_EMPTY_WORKFLOW_ID, WORKFLOW_SETTINGS_MODAL_KEY } from '@/co
|
||||
import type { IWorkflowSettings } from 'n8n-workflow';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
interface IWorkflowSaveSettings {
|
||||
saveFailedExecutions: boolean;
|
||||
@@ -55,7 +55,7 @@ interface IWorkflowSaveSettings {
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionsInfoAccordion',
|
||||
name: 'WorkflowExecutionsInfoAccordion',
|
||||
props: {
|
||||
initiallyExpanded: {
|
||||
type: Boolean,
|
||||
@@ -16,7 +16,7 @@
|
||||
<n8n-heading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
|
||||
{{ $locale.baseText('executionsLandingPage.emptyState.heading') }}
|
||||
</n8n-heading>
|
||||
<ExecutionsInfoAccordion />
|
||||
<WorkflowExecutionsInfoAccordion />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,12 +28,12 @@ import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { mapStores } from 'pinia';
|
||||
import { defineComponent } from 'vue';
|
||||
import ExecutionsInfoAccordion from './ExecutionsInfoAccordion.vue';
|
||||
import WorkflowExecutionsInfoAccordion from './WorkflowExecutionsInfoAccordion.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionsLandingPage',
|
||||
components: {
|
||||
ExecutionsInfoAccordion,
|
||||
WorkflowExecutionsInfoAccordion,
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUIStore, useWorkflowsStore),
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<WorkflowExecutionsSidebar
|
||||
:executions="executions"
|
||||
:loading="loading && !executions.length"
|
||||
:loading-more="loadingMore"
|
||||
:temporary-execution="temporaryExecution"
|
||||
@update:auto-refresh="$emit('update:auto-refresh', $event)"
|
||||
@reload-executions="$emit('reload')"
|
||||
@filter-updated="$emit('update:filters', $event)"
|
||||
@load-more="$emit('load-more')"
|
||||
@retry-execution="onRetryExecution"
|
||||
/>
|
||||
<div v-if="!hidePreview" :class="$style.content">
|
||||
<router-view
|
||||
name="executionPreview"
|
||||
:execution="execution"
|
||||
@delete-current-execution="onDeleteCurrentExecution"
|
||||
@retry-execution="onRetryExecution"
|
||||
@stop-execution="onStopExecution"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
import { defineComponent } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useRouter } from 'vue-router';
|
||||
import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue';
|
||||
import {
|
||||
MAIN_HEADER_TABS,
|
||||
MODAL_CANCEL,
|
||||
MODAL_CONFIRM,
|
||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
VIEWS,
|
||||
} from '@/constants';
|
||||
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
|
||||
import type { ExecutionSummary, IDataObject } from 'n8n-workflow';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { getNodeViewTab } from '@/utils/canvasUtils';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import { executionFilterToQueryFilter } from '@/utils/executionUtils';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'WorkflowExecutionsList',
|
||||
components: {
|
||||
WorkflowExecutionsSidebar,
|
||||
},
|
||||
async beforeRouteLeave(to, _, next) {
|
||||
if (getNodeViewTab(to) === MAIN_HEADER_TABS.WORKFLOW) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (this.uiStore.stateIsDirty) {
|
||||
const confirmModal = await this.confirm(
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
|
||||
{
|
||||
title: this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
|
||||
type: 'warning',
|
||||
confirmButtonText: this.$locale.baseText(
|
||||
'generic.unsavedWork.confirmMessage.confirmButtonText',
|
||||
),
|
||||
cancelButtonText: this.$locale.baseText(
|
||||
'generic.unsavedWork.confirmMessage.cancelButtonText',
|
||||
),
|
||||
showClose: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmModal === MODAL_CONFIRM) {
|
||||
const saved = await this.workflowHelpers.saveCurrentWorkflow({}, false);
|
||||
if (saved) {
|
||||
await this.settingsStore.fetchPromptsData();
|
||||
}
|
||||
this.uiStore.stateIsDirty = false;
|
||||
next();
|
||||
} else if (confirmModal === MODAL_CANCEL) {
|
||||
this.uiStore.stateIsDirty = false;
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
workflow: {
|
||||
type: Object as PropType<IWorkflowDb>,
|
||||
required: true,
|
||||
},
|
||||
executions: {
|
||||
type: Array as PropType<ExecutionSummary[]>,
|
||||
default: () => [],
|
||||
},
|
||||
filters: {
|
||||
type: Object as PropType<ExecutionFilterType>,
|
||||
default: () => ({}),
|
||||
},
|
||||
execution: {
|
||||
type: Object as PropType<ExecutionSummary>,
|
||||
default: null,
|
||||
},
|
||||
loadingMore: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'execution:delete',
|
||||
'execution:stop',
|
||||
'execution:retry',
|
||||
'update:auto-refresh',
|
||||
'update:filters',
|
||||
'load-more',
|
||||
'reload',
|
||||
],
|
||||
setup() {
|
||||
const externalHooks = useExternalHooks();
|
||||
const router = useRouter();
|
||||
const workflowHelpers = useWorkflowHelpers(router);
|
||||
const { callDebounced } = useDebounce();
|
||||
|
||||
return {
|
||||
externalHooks,
|
||||
workflowHelpers,
|
||||
callDebounced,
|
||||
...useToast(),
|
||||
...useMessage(),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useTagsStore, useNodeTypesStore, useSettingsStore, useUIStore),
|
||||
temporaryExecution(): ExecutionSummary | undefined {
|
||||
const isTemporary = !this.executions.find((execution) => execution.id === this.execution?.id);
|
||||
return isTemporary ? this.execution : undefined;
|
||||
},
|
||||
hidePreview(): boolean {
|
||||
return this.loading || (!this.execution && this.executions.length);
|
||||
},
|
||||
filterApplied(): boolean {
|
||||
return this.filters.status !== 'all';
|
||||
},
|
||||
workflowDataNotLoaded(): boolean {
|
||||
return this.workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID && this.workflow.name === '';
|
||||
},
|
||||
requestFilter(): IDataObject {
|
||||
return executionFilterToQueryFilter({
|
||||
...this.filters,
|
||||
workflowId: this.workflow.id,
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
execution(value: ExecutionSummary) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$router
|
||||
.push({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: this.workflow.id, executionId: value.id },
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onDeleteCurrentExecution(): Promise<void> {
|
||||
this.$emit('execution:delete', this.execution.id);
|
||||
},
|
||||
async onStopExecution(): Promise<void> {
|
||||
this.$emit('execution:stop', this.execution.id);
|
||||
},
|
||||
async onRetryExecution(payload: { execution: ExecutionSummary; command: string }) {
|
||||
const loadWorkflow = payload.command === 'current-workflow';
|
||||
|
||||
this.$emit('execution:retry', {
|
||||
id: payload.execution.id,
|
||||
loadWorkflow,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -6,8 +6,7 @@ import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { createPinia, PiniaVuePlugin, setActivePinia } from 'pinia';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import ExecutionPreview from '@/components/ExecutionsView/ExecutionPreview.vue';
|
||||
import WorkflowExecutionsPreview from '@/components/executions/workflow/WorkflowExecutionsPreview.vue';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { i18nInstance, I18nPlugin } from '@/plugins/i18n';
|
||||
import { FontAwesomePlugin } from '@/plugins/icons';
|
||||
@@ -62,8 +61,7 @@ const executionDataFactory = (): ExecutionSummary => ({
|
||||
retrySuccessId: generateUndefinedNullOrString(),
|
||||
});
|
||||
|
||||
describe('ExecutionPreview.vue', () => {
|
||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||
describe('WorkflowExecutionsPreview.vue', () => {
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
const executionData: ExecutionSummary = executionDataFactory();
|
||||
|
||||
@@ -71,10 +69,7 @@ describe('ExecutionPreview.vue', () => {
|
||||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
workflowsStore = useWorkflowsStore();
|
||||
settingsStore = useSettingsStore();
|
||||
|
||||
vi.spyOn(workflowsStore, 'activeWorkflowExecution', 'get').mockReturnValue(executionData);
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -88,7 +83,10 @@ describe('ExecutionPreview.vue', () => {
|
||||
);
|
||||
|
||||
// Not using createComponentRenderer helper here because this component should not stub `router-link`
|
||||
const { getByTestId } = render(ExecutionPreview, {
|
||||
const { getByTestId } = render(WorkflowExecutionsPreview, {
|
||||
props: {
|
||||
execution: executionData,
|
||||
},
|
||||
global: {
|
||||
plugins: [
|
||||
I18nPlugin,
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
<div v-else :class="$style.previewContainer">
|
||||
<div
|
||||
v-if="activeExecution"
|
||||
v-if="execution"
|
||||
:class="$style.executionDetails"
|
||||
:data-test-id="`execution-preview-details-${executionId}`"
|
||||
>
|
||||
@@ -40,7 +40,7 @@
|
||||
interpolate: { time: executionUIDetails?.runningTime },
|
||||
})
|
||||
}}
|
||||
| ID#{{ activeExecution.id }}
|
||||
| ID#{{ execution.id }}
|
||||
</n8n-text>
|
||||
<n8n-text
|
||||
v-else-if="executionUIDetails.name !== 'waiting'"
|
||||
@@ -53,28 +53,28 @@
|
||||
interpolate: { time: executionUIDetails?.runningTime ?? 'unknown' },
|
||||
})
|
||||
}}
|
||||
| ID#{{ activeExecution.id }}
|
||||
| ID#{{ execution.id }}
|
||||
</n8n-text>
|
||||
<n8n-text
|
||||
v-else-if="executionUIDetails?.name === 'waiting'"
|
||||
color="text-base"
|
||||
size="medium"
|
||||
>
|
||||
| ID#{{ activeExecution.id }}
|
||||
| ID#{{ execution.id }}
|
||||
</n8n-text>
|
||||
<br /><n8n-text v-if="activeExecution.mode === 'retry'" color="text-base" size="medium">
|
||||
<br /><n8n-text v-if="execution.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,
|
||||
workflowId: execution.workflowId,
|
||||
executionId: execution.retryOf,
|
||||
},
|
||||
}"
|
||||
>
|
||||
#{{ activeExecution.retryOf }}
|
||||
#{{ execution.retryOf }}
|
||||
</router-link>
|
||||
</n8n-text>
|
||||
</div>
|
||||
@@ -84,8 +84,8 @@
|
||||
:to="{
|
||||
name: VIEWS.EXECUTION_DEBUG,
|
||||
params: {
|
||||
name: activeExecution.workflowId,
|
||||
executionId: activeExecution.id,
|
||||
name: execution.workflowId,
|
||||
executionId: execution.id,
|
||||
},
|
||||
}"
|
||||
>
|
||||
@@ -148,39 +148,50 @@ import { ElDropdown } from 'element-plus';
|
||||
import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import WorkflowPreview from '@/components/WorkflowPreview.vue';
|
||||
import type { IExecutionUIData } from '@/mixins/executionsHelpers';
|
||||
import { executionHelpers } from '@/mixins/executionsHelpers';
|
||||
import { MODAL_CONFIRM, VIEWS } from '@/constants';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
|
||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { mapStores } from 'pinia';
|
||||
|
||||
type RetryDropdownRef = InstanceType<typeof ElDropdown> & { hide: () => void };
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionPreview',
|
||||
name: 'WorkflowExecutionsPreview',
|
||||
components: {
|
||||
ElDropdown,
|
||||
WorkflowPreview,
|
||||
},
|
||||
mixins: [executionHelpers],
|
||||
props: {
|
||||
execution: {
|
||||
type: Object as () => ExecutionSummary | null,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const executionHelpers = useExecutionHelpers();
|
||||
|
||||
return {
|
||||
VIEWS,
|
||||
executionHelpers,
|
||||
...useMessage(),
|
||||
...useExecutionDebugging(),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
VIEWS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useWorkflowsStore),
|
||||
executionId(): string {
|
||||
return this.$route.params.executionId as string;
|
||||
},
|
||||
executionUIDetails(): IExecutionUIData | null {
|
||||
return this.activeExecution ? this.getExecutionUIDetails(this.activeExecution) : null;
|
||||
return this.execution ? this.executionHelpers.getUIDetails(this.execution) : null;
|
||||
},
|
||||
executionMode(): string {
|
||||
return this.activeExecution?.mode || '';
|
||||
return this.execution?.mode || '';
|
||||
},
|
||||
debugButtonData(): Record<string, string> {
|
||||
return this.activeExecution?.status === 'success'
|
||||
return this.execution?.status === 'success'
|
||||
? {
|
||||
text: this.$locale.baseText('executionsList.debug.button.copyToEditor'),
|
||||
type: 'secondary',
|
||||
@@ -191,7 +202,7 @@ export default defineComponent({
|
||||
};
|
||||
},
|
||||
isRetriable(): boolean {
|
||||
return !!this.activeExecution && this.isExecutionRetriable(this.activeExecution);
|
||||
return !!this.execution && this.executionHelpers.isExecutionRetriable(this.execution);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
@@ -213,7 +224,7 @@ export default defineComponent({
|
||||
this.$emit('deleteCurrentExecution');
|
||||
},
|
||||
handleRetryClick(command: string): void {
|
||||
this.$emit('retryExecution', { execution: this.activeExecution, command });
|
||||
this.$emit('retryExecution', { execution: this.execution, command });
|
||||
},
|
||||
handleStopClick(): void {
|
||||
this.$emit('stopExecution');
|
||||
@@ -11,13 +11,13 @@
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<el-checkbox
|
||||
:model-value="autoRefresh"
|
||||
v-model="executionsStore.autoRefresh"
|
||||
data-test-id="auto-refresh-checkbox"
|
||||
@update:model-value="$emit('update:autoRefresh', $event)"
|
||||
>
|
||||
{{ $locale.baseText('executionsList.autoRefresh') }}
|
||||
</el-checkbox>
|
||||
<ExecutionFilter popover-placement="left-start" @filter-changed="onFilterChanged" />
|
||||
<ExecutionsFilter popover-placement="left-start" @filter-changed="onFilterChanged" />
|
||||
</div>
|
||||
<div
|
||||
ref="executionList"
|
||||
@@ -28,12 +28,16 @@
|
||||
<div v-if="loading" class="mr-l">
|
||||
<n8n-loading variant="rect" />
|
||||
</div>
|
||||
<div v-if="!loading && executions.length === 0" :class="$style.noResultsContainer">
|
||||
<div
|
||||
v-if="!loading && executions.length === 0"
|
||||
:class="$style.noResultsContainer"
|
||||
data-test-id="execution-list-empty"
|
||||
>
|
||||
<n8n-text color="text-base" size="medium" align="center">
|
||||
{{ $locale.baseText('executionsLandingPage.noResults') }}
|
||||
</n8n-text>
|
||||
</div>
|
||||
<ExecutionCard
|
||||
<WorkflowExecutionsCard
|
||||
v-else-if="temporaryExecution"
|
||||
:ref="`execution-${temporaryExecution.id}`"
|
||||
:execution="temporaryExecution"
|
||||
@@ -41,52 +45,50 @@
|
||||
:show-gap="true"
|
||||
@retry-execution="onRetryExecution"
|
||||
/>
|
||||
<ExecutionCard
|
||||
v-for="execution in executions"
|
||||
:key="execution.id"
|
||||
:ref="`execution-${execution.id}`"
|
||||
:execution="execution"
|
||||
:data-test-id="`execution-details-${execution.id}`"
|
||||
@retry-execution="onRetryExecution"
|
||||
/>
|
||||
<TransitionGroup name="executions-list">
|
||||
<WorkflowExecutionsCard
|
||||
v-for="execution in executions"
|
||||
:key="execution.id"
|
||||
:ref="`execution-${execution.id}`"
|
||||
:execution="execution"
|
||||
:data-test-id="`execution-details-${execution.id}`"
|
||||
@retry-execution="onRetryExecution"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-if="loadingMore" class="mr-m">
|
||||
<n8n-loading variant="p" :rows="1" />
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.infoAccordion">
|
||||
<ExecutionsInfoAccordion :initially-expanded="false" />
|
||||
<WorkflowExecutionsInfoAccordion :initially-expanded="false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import ExecutionCard from '@/components/ExecutionsView/ExecutionCard.vue';
|
||||
import ExecutionsInfoAccordion from '@/components/ExecutionsView/ExecutionsInfoAccordion.vue';
|
||||
import ExecutionFilter from '@/components/ExecutionFilter.vue';
|
||||
import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue';
|
||||
import WorkflowExecutionsInfoAccordion from '@/components/executions/workflow/WorkflowExecutionsInfoAccordion.vue';
|
||||
import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue';
|
||||
import { VIEWS } from '@/constants';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import type { Route } from 'vue-router';
|
||||
import { defineComponent } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import type { ExecutionFilterType } from '@/Interface';
|
||||
|
||||
type ExecutionCardRef = InstanceType<typeof ExecutionCard>;
|
||||
type WorkflowExecutionsCardRef = InstanceType<typeof WorkflowExecutionsCard>;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionsSidebar',
|
||||
name: 'WorkflowExecutionsSidebar',
|
||||
components: {
|
||||
ExecutionCard,
|
||||
ExecutionsInfoAccordion,
|
||||
ExecutionFilter,
|
||||
WorkflowExecutionsCard,
|
||||
WorkflowExecutionsInfoAccordion,
|
||||
ExecutionsFilter,
|
||||
},
|
||||
props: {
|
||||
autoRefresh: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
executions: {
|
||||
type: Array as PropType<ExecutionSummary[]>,
|
||||
required: true,
|
||||
@@ -111,7 +113,7 @@ export default defineComponent({
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUIStore, useWorkflowsStore),
|
||||
...mapStores(useExecutionsStore, useWorkflowsStore),
|
||||
},
|
||||
watch: {
|
||||
$route(to: Route, from: Route) {
|
||||
@@ -125,7 +127,9 @@ export default defineComponent({
|
||||
// On larger screens, we need to load more then first page of executions
|
||||
// for the scroll bar to appear and infinite scrolling is enabled
|
||||
this.checkListSize();
|
||||
this.scrollToActiveCard();
|
||||
setTimeout(() => {
|
||||
this.scrollToActiveCard();
|
||||
}, 1000);
|
||||
},
|
||||
methods: {
|
||||
loadMore(limit = 20): void {
|
||||
@@ -155,14 +159,14 @@ export default defineComponent({
|
||||
},
|
||||
checkListSize(): void {
|
||||
const sidebarContainerRef = this.$refs.container as HTMLElement | undefined;
|
||||
const currentExecutionCardRefs = this.$refs[
|
||||
`execution-${this.workflowsStore.activeWorkflowExecution?.id}`
|
||||
] as ExecutionCardRef[] | undefined;
|
||||
const currentWorkflowExecutionsCardRefs = this.$refs[
|
||||
`execution-${this.executionsStore.activeExecution?.id}`
|
||||
] as WorkflowExecutionsCardRef[] | undefined;
|
||||
|
||||
// Find out how many execution card can fit into list
|
||||
// and load more if needed
|
||||
if (sidebarContainerRef && currentExecutionCardRefs?.length) {
|
||||
const cardElement = currentExecutionCardRefs[0].$el as HTMLElement;
|
||||
if (sidebarContainerRef && currentWorkflowExecutionsCardRefs?.length) {
|
||||
const cardElement = currentWorkflowExecutionsCardRefs[0].$el as HTMLElement;
|
||||
const listCapacity = Math.ceil(sidebarContainerRef.clientHeight / cardElement.clientHeight);
|
||||
|
||||
if (listCapacity > this.executions.length) {
|
||||
@@ -172,16 +176,16 @@ export default defineComponent({
|
||||
},
|
||||
scrollToActiveCard(): void {
|
||||
const executionsListRef = this.$refs.executionList as HTMLElement | undefined;
|
||||
const currentExecutionCardRefs = this.$refs[
|
||||
`execution-${this.workflowsStore.activeWorkflowExecution?.id}`
|
||||
] as ExecutionCardRef[] | undefined;
|
||||
const currentWorkflowExecutionsCardRefs = this.$refs[
|
||||
`execution-${this.executionsStore.activeExecution?.id}`
|
||||
] as WorkflowExecutionsCardRef[] | undefined;
|
||||
|
||||
if (
|
||||
executionsListRef &&
|
||||
currentExecutionCardRefs?.length &&
|
||||
this.workflowsStore.activeWorkflowExecution
|
||||
currentWorkflowExecutionsCardRefs?.length &&
|
||||
this.executionsStore.activeExecution
|
||||
) {
|
||||
const cardElement = currentExecutionCardRefs[0].$el as HTMLElement;
|
||||
const cardElement = currentWorkflowExecutionsCardRefs[0].$el as HTMLElement;
|
||||
const cardRect = cardElement.getBoundingClientRect();
|
||||
const LIST_HEADER_OFFSET = 200;
|
||||
if (cardRect.top > executionsListRef.offsetHeight) {
|
||||
Reference in New Issue
Block a user