feat: Execution custom data saving and filtering (#5496)

* wip: workflow execution filtering

* fix: import type failing to build

* fix: remove console.logs

* feat: execution metadata migrations

* fix(editor): Move global executions filter to its own component

* fix(editor): Using the same filter component in workflow level

* fix(editor): a small housekeeping

* checking workflowId in filter applied

* fix(editor): update filter after resolving merge conflicts

* fix(editor): unify empy filter status

* feat(editor): add datetime picker to filter

* feat(editor): add meta fields

* fix: fix button override in datepicker panel

* feat(editor): add filter metadata

* feat(core): add 'startedBefore' execution filter prop

* feat(core): add 'tags' execution query filter

* Revert "feat(core): add 'tags' execution query filter"

This reverts commit a7b968081c91290b0c94df18c6a73d29950222d9.

* feat(editor): add translations and tooltip and counting selected filter props

* fix(editor): fix label layouts

* fix(editor): update custom data docs link

* fix(editor): update custom data tooltip position

* fix(editor): update tooltip text

* refactor: Ignore metadata if not enabled by license

* fix(editor): Add paywall states to advanced execution filter

* refactor: Save custom data also for worker mode

* fix: Remove duplicate migration name from list

* fix(editor): Reducing filter complexity and add debounce to text inputs

* fix(editor): Remove unused import, add comment

* fix(editor): simplify event listener

* fix: Prevent error when there are running executions

* test(editor): Add advanced execution filter basic unit test

* test(editor): Add advanced execution filter state change unit test

* fix: Small lint issue

* feat: Add indices to speed up queries

* feat: add customData limits

* refactor: put metadata save in transaction

* chore: remove unneed comment

* test: add tests for execution metadata

* fix(editor): Fixes after merge conflict

* fix(editor): Remove unused import

* wordings and ui fixes

* fix(editor): type fixes

* feat: add code node autocompletions for customData

* fix: Prevent transaction issues and ambiguous ID in sql clauses

* fix(editor): Suppress requesting current executions if metadata is used in filter (#5739)

* fix(editor): Suppress requesting current executions if metadata is used in filter

* fix(editor): Fix arrows for select in popover

* refactor: Improve performance by correcting database indices

* fix: Lint issue

* test: Fix broken test

* fix: Broken test

* test: add call data check for saveExecutionMetadata test

---------

Co-authored-by: Valya Bullions <valya@n8n.io>
Co-authored-by: Alex Grozav <alex@grozav.com>
Co-authored-by: Omar Ajoue <krynble@gmail.com>
Co-authored-by: Romain Minaud <romain.minaud@gmail.com>
This commit is contained in:
Csaba Tuncsik
2023-03-23 18:07:46 +01:00
committed by GitHub
parent 4c583e2be4
commit d78a41db54
30 changed files with 1430 additions and 269 deletions

View File

@@ -18,6 +18,15 @@ export const executionCompletions = (Vue as CodeNodeEditorMixin).extend({
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const buildLinkNode = (text: string) => {
const wrapper = document.createElement('span');
// This is being loaded from the locales file. This could
// cause an XSS of some kind but multiple other locales strings
// do the same thing.
wrapper.innerHTML = text;
return () => wrapper;
};
const options: Completion[] = [
{
label: `${matcher}.id`,
@@ -31,6 +40,30 @@ export const executionCompletions = (Vue as CodeNodeEditorMixin).extend({
label: `${matcher}.resumeUrl`,
info: this.$locale.baseText('codeNodeEditor.completer.$execution.resumeUrl'),
},
{
label: `${matcher}.customData.set("key", "value")`,
info: buildLinkNode(
this.$locale.baseText('codeNodeEditor.completer.$execution.customData.set()'),
),
},
{
label: `${matcher}.customData.get("key")`,
info: buildLinkNode(
this.$locale.baseText('codeNodeEditor.completer.$execution.customData.get()'),
),
},
{
label: `${matcher}.customData.setAll({})`,
info: buildLinkNode(
this.$locale.baseText('codeNodeEditor.completer.$execution.customData.setAll()'),
),
},
{
label: `${matcher}.customData.getAll()`,
info: buildLinkNode(
this.$locale.baseText('codeNodeEditor.completer.$execution.customData.getAll()'),
),
},
];
return {

View File

@@ -0,0 +1,418 @@
<script lang="ts" setup>
import { computed, reactive, onBeforeMount } from 'vue';
import debounce from 'lodash/debounce';
import type { PopoverPlacement } from 'element-ui/types/popover';
import type {
ExecutionFilterType,
ExecutionFilterMetadata,
IWorkflowShortResponse,
} from '@/Interface';
import { i18n as locale } from '@/plugins/i18n';
import TagsDropdown from '@/components/TagsDropdown.vue';
import { getObjectKeys, isEmpty } from '@/utils';
import { EnterpriseEditionFeature } from '@/constants';
import { useSettingsStore } from '@/stores/settings';
import { useUsageStore } from '@/stores/usage';
export type ExecutionFilterProps = {
workflows?: IWorkflowShortResponse[];
popoverPlacement?: PopoverPlacement;
};
const DATE_TIME_MASK = 'yyyy-MM-dd HH:mm';
const CLOUD_UPGRADE_LINK = 'https://app.n8n.cloud/manage?edition=cloud';
const settingsStore = useSettingsStore();
const usageStore = useUsageStore();
const props = withDefaults(defineProps<ExecutionFilterProps>(), {
popoverPlacement: 'bottom',
});
const emit = defineEmits<{
(event: 'filterChanged', value: ExecutionFilterType): void;
}>();
const debouncedEmit = debounce(emit, 500);
const viewPlansLink = computed(() =>
settingsStore.isCloudDeployment
? CLOUD_UPGRADE_LINK
: `${usageStore.viewPlansUrl}&source=custom-data-filter`,
);
const isAdvancedExecutionFilterEnabled = computed(() =>
settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.AdvancedExecutionFilters),
);
const showTags = computed(() => false);
const getDefaultFilter = (): ExecutionFilterType => ({
status: 'all',
workflowId: 'all',
tags: [],
startDate: '',
endDate: '',
metadata: [{ key: '', value: '' }],
});
const filter = reactive(getDefaultFilter());
// Automatically set up v-models based on filter properties
const vModel = reactive(
getObjectKeys(filter).reduce((acc, key) => {
acc[key] = computed({
get() {
return filter[key];
},
set(value) {
// TODO: find out what exactly is typechecker complaining about
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
filter[key] = value;
emit('filterChanged', filter);
},
});
return acc;
}, {} as Record<keyof ExecutionFilterType, ReturnType<typeof computed>>),
);
const statuses = computed(() => [
{ id: 'all', name: locale.baseText('executionsList.anyStatus') },
{ id: 'error', name: locale.baseText('executionsList.error') },
{ id: 'running', name: locale.baseText('executionsList.running') },
{ id: 'success', name: locale.baseText('executionsList.success') },
{ id: 'waiting', name: locale.baseText('executionsList.waiting') },
]);
const countSelectedFilterProps = computed(() => {
let count = 0;
if (filter.status !== 'all') {
count++;
}
if (filter.workflowId !== 'all') {
count++;
}
if (!isEmpty(filter.tags)) {
count++;
}
if (!isEmpty(filter.metadata)) {
count++;
}
if (!!filter.startDate) {
count++;
}
if (!!filter.endDate) {
count++;
}
return count;
});
// vModel.metadata is a text input and needs a debounced emit to avoid too many requests
// We use the :value and @input combo instead of v-model with this event listener
const onFilterMetaChange = (index: number, prop: keyof ExecutionFilterMetadata, value: string) => {
if (!filter.metadata[index]) {
filter.metadata[index] = {
key: '',
value: '',
};
}
filter.metadata[index][prop] = value;
debouncedEmit('filterChanged', filter);
};
// Can't use v-model on TagsDropdown component and thus vModel.tags is useless
// We just emit the updated filter
const onTagsChange = (tags: string[]) => {
filter.tags = tags;
emit('filterChanged', filter);
};
const onFilterReset = () => {
Object.assign(filter, getDefaultFilter());
emit('filterChanged', filter);
};
onBeforeMount(() => {
emit('filterChanged', filter);
});
</script>
<template>
<div :class="$style.filter">
<n8n-popover trigger="click" :placement="popoverPlacement">
<template #reference>
<n8n-button
icon="filter"
type="tertiary"
size="medium"
:active="!!countSelectedFilterProps"
data-testid="executions-filter-button"
>
<n8n-badge
v-if="!!countSelectedFilterProps"
theme="primary"
class="mr-4xs"
data-testid="execution-filter-badge"
>{{ countSelectedFilterProps }}</n8n-badge
>
{{ $locale.baseText('executionsList.filters') }}
</n8n-button>
</template>
<div data-testid="execution-filter-form">
<div v-if="workflows?.length" :class="$style.group">
<label for="execution-filter-workflows">{{
$locale.baseText('workflows.heading')
}}</label>
<n8n-select
id="execution-filter-workflows"
v-model="vModel.workflowId"
:placeholder="$locale.baseText('executionsFilter.selectWorkflow')"
size="medium"
filterable
data-testid="executions-filter-workflows-select"
>
<div class="ph-no-capture">
<n8n-option
v-for="item in workflows"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</div>
</n8n-select>
</div>
<div v-if="showTags" :class="$style.group">
<label for="execution-filter-tags">{{
$locale.baseText('workflows.filters.tags')
}}</label>
<TagsDropdown
id="execution-filter-tags"
:placeholder="$locale.baseText('workflowOpen.filterWorkflows')"
:currentTagIds="filter.tags"
:createEnabled="false"
@update="onTagsChange"
data-testid="executions-filter-tags-select"
/>
</div>
<div :class="$style.group">
<label for="execution-filter-status">{{
$locale.baseText('executionsList.status')
}}</label>
<n8n-select
id="execution-filter-status"
v-model="vModel.status"
:placeholder="$locale.baseText('executionsFilter.selectStatus')"
size="medium"
filterable
data-testid="executions-filter-status-select"
>
<n8n-option
v-for="item in statuses"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</n8n-select>
</div>
<div :class="$style.group">
<label for="execution-filter-start-date">{{
$locale.baseText('executionsFilter.start')
}}</label>
<div :class="$style.dates">
<el-date-picker
id="execution-filter-start-date"
type="datetime"
v-model="vModel.startDate"
:format="DATE_TIME_MASK"
:placeholder="$locale.baseText('executionsFilter.startDate')"
data-testid="executions-filter-start-date-picker"
/>
<span :class="$style.divider">to</span>
<el-date-picker
id="execution-filter-end-date"
type="datetime"
v-model="vModel.endDate"
:format="DATE_TIME_MASK"
:placeholder="$locale.baseText('executionsFilter.endDate')"
data-testid="executions-filter-end-date-picker"
/>
</div>
</div>
<div :class="$style.group">
<n8n-tooltip placement="right">
<template #content>
<i18n tag="span" path="executionsFilter.customData.docsTooltip">
<template #link>
<a
target="_blank"
href="https://docs.n8n.io/workflows/executions/custom-executions-data/"
>
{{ $locale.baseText('executionsFilter.customData.docsTooltip.link') }}
</a>
</template>
</i18n>
</template>
<span :class="$style.label">
{{ $locale.baseText('executionsFilter.savedData') }}
<n8n-icon :class="$style.tooltipIcon" icon="question-circle" size="small" />
</span>
</n8n-tooltip>
<div :class="$style.subGroup">
<label for="execution-filter-saved-data-key">{{
$locale.baseText('executionsFilter.savedDataKey')
}}</label>
<n8n-tooltip :disabled="isAdvancedExecutionFilterEnabled" placement="top">
<template #content>
<i18n tag="span" path="executionsFilter.customData.inputTooltip">
<template #link>
<a
target="_blank"
:href="viewPlansLink"
data-testid="executions-filter-view-plans-link"
>{{ $locale.baseText('executionsFilter.customData.inputTooltip.link') }}</a
>
</template>
</i18n>
</template>
<n8n-input
id="execution-filter-saved-data-key"
name="execution-filter-saved-data-key"
type="text"
size="medium"
:disabled="!isAdvancedExecutionFilterEnabled"
:placeholder="$locale.baseText('executionsFilter.savedDataKeyPlaceholder')"
:value="filter.metadata[0]?.key"
@input="onFilterMetaChange(0, 'key', $event)"
data-testid="execution-filter-saved-data-key-input"
/>
</n8n-tooltip>
<label for="execution-filter-saved-data-value">{{
$locale.baseText('executionsFilter.savedDataValue')
}}</label>
<n8n-tooltip :disabled="isAdvancedExecutionFilterEnabled" placement="top">
<template #content>
<i18n tag="span" path="executionsFilter.customData.inputTooltip">
<template #link>
<a target="_blank" :href="viewPlansLink">{{
$locale.baseText('executionsFilter.customData.inputTooltip.link')
}}</a>
</template>
</i18n>
</template>
<n8n-input
id="execution-filter-saved-data-value"
name="execution-filter-saved-data-value"
type="text"
size="medium"
:disabled="!isAdvancedExecutionFilterEnabled"
:placeholder="$locale.baseText('executionsFilter.savedDataValuePlaceholder')"
:value="filter.metadata[0]?.value"
@input="onFilterMetaChange(0, 'value', $event)"
data-testid="execution-filter-saved-data-value-input"
/>
</n8n-tooltip>
</div>
</div>
<n8n-button
v-if="!!countSelectedFilterProps"
:class="$style.resetBtn"
@click="onFilterReset"
size="large"
text
data-testid="executions-filter-reset-button"
>
{{ $locale.baseText('executionsFilter.reset') }}
</n8n-button>
</div>
</n8n-popover>
</div>
</template>
<style lang="scss" module>
.filter {
display: inline-block;
}
.group {
label,
.label {
display: inline-block;
font-size: var(--font-size-2xs);
margin: var(--spacing-s) 0 var(--spacing-3xs);
}
}
.subGroup {
padding: 0 0 var(--spacing-xs) var(--spacing-s);
label,
.label {
font-size: var(--font-size-3xs);
margin: var(--spacing-4xs) 0 var(--spacing-4xs);
}
}
.dates {
display: flex;
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-base);
white-space: nowrap;
align-items: center;
}
.divider {
padding: 0 var(--spacing-m);
line-height: 100%;
}
.resetBtn {
padding: 0;
margin: var(--spacing-xs) 0 0;
}
.tooltipIcon {
color: var(--color-text-light);
}
</style>
<style lang="scss" scoped>
:deep(.el-date-editor) {
input {
height: 36px;
border: 0;
padding-right: 0;
}
.el-input__prefix {
color: var(--color-foreground-dark);
}
&:last-of-type {
input {
padding-left: 0;
}
.el-input__prefix {
display: none;
}
}
}
:deep(.el-select-dropdown.el-popper[x-placement^='bottom']) {
> .popper__arrow {
top: -6px;
left: 50%;
right: unset;
margin-bottom: 0;
margin-right: 3px;
border-left-width: 6px;
border-top-width: 0;
border-bottom-color: var(--border-color-light);
border-right-color: transparent;
&::after {
top: 1px;
left: unset;
bottom: unset;
margin-left: -6px;
border-left-width: 6px;
border-top-width: 0;
border-bottom-color: var(--color-foreground-xlight);
border-right-color: transparent;
}
}
}
</style>

View File

@@ -1,43 +1,19 @@
<template>
<div :class="$style.execListWrapper">
<div :class="$style.execList">
<n8n-heading tag="h1" size="2xlarge">{{ this.pageTitle }}</n8n-heading>
<div :class="$style.filters">
<span :class="$style.filterItem">{{ $locale.baseText('executionsList.filters') }}:</span>
<n8n-select
:class="$style.filterItem"
v-model="filter.workflowId"
:placeholder="$locale.baseText('executionsList.selectWorkflow')"
size="medium"
filterable
@change="handleFilterChanged"
>
<div class="ph-no-capture">
<n8n-option
v-for="item in workflows"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</div>
</n8n-select>
<n8n-select
:class="$style.filterItem"
v-model="filter.status"
:placeholder="$locale.baseText('executionsList.selectStatus')"
size="medium"
filterable
@change="handleFilterChanged"
>
<n8n-option v-for="item in statuses" :key="item.id" :label="item.name" :value="item.id" />
</n8n-select>
<el-checkbox
v-model="autoRefresh"
@change="handleAutoRefreshToggle"
data-testid="execution-auto-refresh-checkbox"
>
{{ $locale.baseText('executionsList.autoRefresh') }}
</el-checkbox>
<div :class="$style.execListHeader">
<n8n-heading tag="h1" size="2xlarge">{{ this.pageTitle }}</n8n-heading>
<div :class="$style.execListHeaderControls">
<el-checkbox
class="mr-xl"
v-model="autoRefresh"
@change="handleAutoRefreshToggle"
data-testid="execution-auto-refresh-checkbox"
>
{{ $locale.baseText('executionsList.autoRefresh') }}
</el-checkbox>
<execution-filter :workflows="workflows" @filterChanged="onFilterChanged" />
</div>
</div>
<el-checkbox
@@ -292,6 +268,7 @@
import Vue from 'vue';
import ExecutionTime from '@/components/ExecutionTime.vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import ExecutionFilter from '@/components/ExecutionFilter.vue';
import { externalHooks } from '@/mixins/externalHooks';
import { VIEWS, WAIT_TIME_UNLIMITED } from '@/constants';
import { restApi } from '@/mixins/restApi';
@@ -303,14 +280,17 @@ import {
IExecutionDeleteFilter,
IExecutionsListResponse,
IWorkflowShortResponse,
ExecutionFilterType,
ExecutionsQueryFilter,
} from '@/Interface';
import type { IExecutionsSummary, ExecutionStatus, IDataObject } from 'n8n-workflow';
import type { IExecutionsSummary, ExecutionStatus } from 'n8n-workflow';
import { range as _range } from 'lodash-es';
import mixins from 'vue-typed-mixins';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows';
import { setPageTitle } from '@/utils';
import { isEmpty, setPageTitle } from '@/utils';
import { executionFilterToQueryFilter } from '@/utils/executionUtils';
export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, showMessage).extend(
{
@@ -318,6 +298,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
components: {
ExecutionTime,
WorkflowActivator,
ExecutionFilter,
},
data() {
return {
@@ -330,10 +311,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
autoRefresh: true,
autoRefreshInterval: undefined as undefined | NodeJS.Timer,
filter: {
status: 'ALL',
workflowId: 'ALL',
},
filter: {} as ExecutionFilterType,
isDataLoading: false,
@@ -350,7 +328,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
},
async created() {
await this.loadWorkflows();
await this.refreshData();
//await this.refreshData();
this.handleAutoRefreshToggle();
this.$externalHooks().run('executionsList.openDialog');
@@ -366,47 +344,22 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
},
computed: {
...mapStores(useUIStore, useWorkflowsStore),
statuses() {
return [
{
id: 'ALL',
name: this.$locale.baseText('executionsList.anyStatus'),
},
{
id: 'error',
name: this.$locale.baseText('executionsList.error'),
},
{
id: 'running',
name: this.$locale.baseText('executionsList.running'),
},
{
id: 'success',
name: this.$locale.baseText('executionsList.success'),
},
{
id: 'waiting',
name: this.$locale.baseText('executionsList.waiting'),
},
];
},
activeExecutions(): IExecutionsCurrentSummaryExtended[] {
return this.workflowsStore.activeExecutions;
},
combinedExecutions(): IExecutionsSummary[] {
const returnData = [];
const returnData: IExecutionsSummary[] = [];
if (['ALL', 'running'].includes(this.filter.status)) {
returnData.push(...(this.activeExecutions as IExecutionsSummary[]));
if (['all', 'running'].includes(this.filter.status)) {
returnData.push(...this.activeExecutions);
}
if (['ALL', 'error', 'success', 'waiting'].includes(this.filter.status)) {
if (['all', 'error', 'success', 'waiting'].includes(this.filter.status)) {
returnData.push(...this.finishedExecutions);
}
return returnData.filter(
(execution) =>
this.filter.workflowId === 'ALL' || execution.workflowId === this.filter.workflowId,
this.filter.workflowId === 'all' || execution.workflowId === this.filter.workflowId,
);
},
numSelected(): number {
@@ -416,33 +369,15 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
return Object.keys(this.selectedItems).length;
},
workflowFilterCurrent(): IDataObject {
const filter: IDataObject = {};
if (this.filter.workflowId !== 'ALL') {
workflowFilterCurrent(): ExecutionsQueryFilter {
const filter: ExecutionsQueryFilter = {};
if (this.filter.workflowId !== 'all') {
filter.workflowId = this.filter.workflowId;
}
return filter;
},
workflowFilterPast(): IDataObject {
const queryFilter: IDataObject = {};
if (this.filter.workflowId !== 'ALL') {
queryFilter.workflowId = this.filter.workflowId;
}
switch (this.filter.status as ExecutionStatus) {
case 'waiting':
queryFilter.status = ['waiting'];
break;
case 'error':
queryFilter.status = ['failed', 'crashed'];
break;
case 'success':
queryFilter.status = ['success'];
break;
case 'running':
queryFilter.status = ['running'];
break;
}
return queryFilter;
workflowFilterPast(): ExecutionsQueryFilter {
return executionFilterToQueryFilter(this.filter);
},
pageTitle() {
return this.$locale.baseText('executionsList.workflowExecutions');
@@ -547,8 +482,10 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
this.allExistingSelected = false;
Vue.set(this, 'selectedItems', {});
},
handleFilterChanged(): void {
onFilterChanged(filter: ExecutionFilterType) {
this.filter = filter;
this.refreshData();
this.handleClearSelection();
},
handleActionItemClick(commandData: { command: string; execution: IExecutionsSummary }) {
if (['currentlySaved', 'original'].includes(commandData.command)) {
@@ -573,14 +510,11 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
return this.workflows.find((data) => data.id === workflowId)?.name;
},
async loadActiveExecutions(): Promise<void> {
const activeExecutions = await this.restApi().getCurrentExecutions(
this.workflowFilterCurrent,
);
const activeExecutions = isEmpty(this.workflowFilterCurrent.metadata)
? await this.restApi().getCurrentExecutions(this.workflowFilterCurrent)
: [];
for (const activeExecution of activeExecutions) {
if (
activeExecution.workflowId !== undefined &&
activeExecution.workflowName === undefined
) {
if (activeExecution.workflowId && !activeExecution.workflowName) {
activeExecution.workflowName = this.getWorkflowName(activeExecution.workflowId);
}
}
@@ -589,7 +523,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
this.workflowsStore.addToCurrentExecutions(activeExecutions);
},
async loadAutoRefresh(): Promise<void> {
const filter = this.workflowFilterPast;
const filter: ExecutionsQueryFilter = this.workflowFilterPast;
// We cannot use firstId here as some executions finish out of order. Let's say
// You have execution ids 500 to 505 running.
// Suppose 504 finishes before 500, 501, 502 and 503.
@@ -597,8 +531,11 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
// ever get ids 500, 501, 502 and 503 when they finish
const pastExecutionsPromise: Promise<IExecutionsListResponse> =
this.restApi().getPastExecutions(filter, this.requestItemsPerRequest);
const currentExecutionsPromise: Promise<IExecutionsCurrentSummaryExtended[]> =
this.restApi().getCurrentExecutions({});
const currentExecutionsPromise: Promise<IExecutionsCurrentSummaryExtended[]> = isEmpty(
filter.metadata,
)
? this.restApi().getCurrentExecutions({})
: Promise.resolve([]);
const results = await Promise.all([pastExecutionsPromise, currentExecutionsPromise]);
@@ -759,7 +696,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
// @ts-ignore
workflows.unshift({
id: 'ALL',
id: 'all',
name: this.$locale.baseText('executionsList.allWorkflows'),
});
@@ -803,9 +740,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
this.isDataLoading = true;
try {
const activeExecutionsPromise = this.loadActiveExecutions();
const finishedExecutionsPromise = this.loadFinishedExecutions();
await Promise.all([activeExecutionsPromise, finishedExecutionsPromise]);
await Promise.all([this.loadActiveExecutions(), this.loadFinishedExecutions()]);
} catch (error) {
this.$showError(
error,
@@ -994,6 +929,19 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
}
}
.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;
@@ -1013,16 +961,6 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
}
}
.filters {
display: flex;
line-height: 2em;
margin: var(--spacing-l) 0;
}
.filterItem {
margin: 0 var(--spacing-3xl) 0 0;
}
.statusColumn {
display: flex;
align-items: center;

View File

@@ -31,9 +31,14 @@ import {
VIEWS,
WEBHOOK_NODE_TYPE,
} from '@/constants';
import { IExecutionsListResponse, INodeUi, ITag, IWorkflowDb } from '@/Interface';
import {
ExecutionStatus,
ExecutionFilterType,
IExecutionsListResponse,
INodeUi,
ITag,
IWorkflowDb,
} from '@/Interface';
import {
IExecutionsSummary,
IConnection,
IConnections,
@@ -50,7 +55,7 @@ import { Route } from 'vue-router';
import { executionHelpers } from '@/mixins/executionsHelpers';
import { range as _range } from 'lodash-es';
import { debounceHelper } from '@/mixins/debounce';
import { getNodeViewTab, NO_NETWORK_ERROR_CODE } from '@/utils';
import { getNodeViewTab, isEmpty, NO_NETWORK_ERROR_CODE } from '@/utils';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { mapStores } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows';
@@ -58,6 +63,7 @@ import { useUIStore } from '@/stores/ui';
import { useSettingsStore } from '@/stores/settings';
import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useTagsStore } from '@/stores/tags';
import { executionFilterToQueryFilter } from '@/utils/executionUtils';
export default mixins(
restApi,
@@ -74,7 +80,7 @@ export default mixins(
return {
loading: false,
loadingMore: false,
filter: { finished: true, status: '' },
filter: {} as ExecutionFilterType,
};
},
computed: {
@@ -86,7 +92,7 @@ export default mixins(
return this.loading || !this.executions.length || activeNotPresent;
},
filterApplied(): boolean {
return this.filter.status !== '';
return this.filter.status !== 'all';
},
workflowDataNotLoaded(): boolean {
return (
@@ -101,29 +107,10 @@ export default mixins(
return this.workflowsStore.getTotalFinishedExecutionsCount;
},
requestFilter(): IDataObject {
const rFilter: IDataObject = { workflowId: this.currentWorkflow };
if (this.filter.status === 'waiting') {
rFilter.waitTill = true;
} else if (this.filter.status !== '') {
rFilter.finished = this.filter.status === 'success';
}
switch (this.filter.status as ExecutionStatus) {
case 'waiting':
rFilter.status = ['waiting'];
break;
case 'error':
rFilter.status = ['failed', 'crashed'];
break;
case 'success':
rFilter.status = ['success'];
break;
case 'running':
rFilter.status = ['running'];
break;
}
return rFilter;
return executionFilterToQueryFilter({
...this.filter,
workflowId: this.currentWorkflow,
});
},
},
watch: {
@@ -317,8 +304,8 @@ export default mixins(
);
}
},
onFilterUpdated(newFilter: { finished: boolean; status: string }): void {
this.filter = newFilter;
onFilterUpdated(filter: ExecutionFilterType): void {
this.filter = filter;
this.setExecutions();
},
async setExecutions(): Promise<void> {

View File

@@ -17,64 +17,7 @@
>
{{ $locale.baseText('executionsList.autoRefresh') }}
</el-checkbox>
<n8n-popover trigger="click">
<template #reference>
<div :class="$style.filterButton">
<n8n-button
icon="filter"
type="tertiary"
size="medium"
:active="statusFilterApplied"
data-test-id="executions-filter-button"
>
<n8n-badge v-if="statusFilterApplied" theme="primary" class="mr-4xs">1</n8n-badge>
{{ $locale.baseText('executionsList.filters') }}
</n8n-button>
</div>
</template>
<div :class="$style['filters-dropdown']">
<div class="mb-s">
<n8n-input-label
:label="$locale.baseText('executions.ExecutionStatus')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
/>
<n8n-select
v-model="filter.status"
size="small"
ref="typeInput"
:class="$style['type-input']"
:placeholder="$locale.baseText('generic.any')"
data-test-id="execution-status-select"
@change="onFilterChange"
>
<n8n-option
v-for="item in executionStatuses"
:key="item.id"
:label="item.name"
:value="item.id"
:data-test-id="`execution-status-${item.id}`"
>
</n8n-option>
</n8n-select>
</div>
<div :class="[$style.filterMessage, 'mt-s']" v-if="statusFilterApplied">
<n8n-link @click="resetFilters">
{{ $locale.baseText('generic.reset') }}
</n8n-link>
</div>
</div>
</n8n-popover>
</div>
<div v-show="statusFilterApplied" class="mb-xs">
<n8n-info-tip :bold="false">
{{ $locale.baseText('generic.filtersApplied') }}
<n8n-link @click="resetFilters" size="small">
{{ $locale.baseText('generic.resetAllFilters') }}
</n8n-link>
</n8n-info-tip>
<execution-filter popover-placement="left-start" @filterChanged="onFilterChanged" />
</div>
<div
:class="$style.executionList"
@@ -87,7 +30,7 @@
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
</div>
<div v-if="executions.length === 0 && statusFilterApplied" :class="$style.noResultsContainer">
<div v-if="executions.length === 0" :class="$style.noResultsContainer">
<n8n-text color="text-base" size="medium" align="center">
{{ $locale.baseText('executionsLandingPage.noResults') }}
</n8n-text>
@@ -115,20 +58,23 @@
<script lang="ts">
import ExecutionCard from '@/components/ExecutionsView/ExecutionCard.vue';
import ExecutionsInfoAccordion from '@/components/ExecutionsView/ExecutionsInfoAccordion.vue';
import ExecutionFilter from '@/components/ExecutionFilter.vue';
import { VIEWS } from '@/constants';
import { IExecutionsSummary } from '@/Interface';
import type { IExecutionsSummary } from 'n8n-workflow';
import { Route } from 'vue-router';
import Vue from 'vue';
import { PropType } from 'vue';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows';
import { ExecutionFilterType } from '@/Interface';
export default Vue.extend({
name: 'executions-sidebar',
components: {
ExecutionCard,
ExecutionsInfoAccordion,
ExecutionFilter,
},
props: {
executions: {
@@ -147,26 +93,13 @@ export default Vue.extend({
data() {
return {
VIEWS,
filter: {
status: '',
},
filter: {} as ExecutionFilterType,
autoRefresh: false,
autoRefreshInterval: undefined as undefined | NodeJS.Timer,
};
},
computed: {
...mapStores(useUIStore, useWorkflowsStore),
statusFilterApplied(): boolean {
return this.filter.status !== '';
},
executionStatuses(): Array<{ id: string; name: string }> {
return [
{ id: 'error', name: this.$locale.baseText('executionsList.error') },
{ id: 'running', name: this.$locale.baseText('executionsList.running') },
{ id: 'success', name: this.$locale.baseText('executionsList.success') },
{ id: 'waiting', name: this.$locale.baseText('executionsList.waiting') },
];
},
},
watch: {
$route(to: Route, from: Route) {
@@ -215,8 +148,8 @@ export default Vue.extend({
onRefresh(): void {
this.$emit('refresh');
},
onFilterChange(): void {
this.$emit('filterUpdated', this.prepareFilter());
onFilterChanged(filter: ExecutionFilterType) {
this.$emit('filterUpdated', filter);
},
reloadExecutions(): void {
this.$emit('reloadExecutions');
@@ -232,16 +165,6 @@ export default Vue.extend({
this.autoRefreshInterval = setInterval(() => this.onRefresh(), 4 * 1000); // refresh data every 4 secs
}
},
async resetFilters(): Promise<void> {
this.filter.status = '';
this.$emit('filterUpdated', this.prepareFilter());
},
prepareFilter(): object {
return {
finished: this.filter.status !== 'running',
status: this.filter.status,
};
},
checkListSize(): void {
const sidebarContainer = this.$refs.container as HTMLElement;
const currentExecutionCard = this.$refs[
@@ -304,7 +227,7 @@ export default Vue.extend({
display: flex;
align-items: center;
justify-content: space-between;
padding-right: var(--spacing-l);
padding-right: var(--spacing-m);
button {
display: flex;

View File

@@ -0,0 +1,130 @@
import { describe, test, expect } from 'vitest';
import Vue from 'vue';
import { PiniaVuePlugin } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { render, RenderOptions } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { faker } from '@faker-js/faker';
import ExecutionFilter from '@/components/ExecutionFilter.vue';
import { STORES } from '@/constants';
import { i18nInstance } from '@/plugins/i18n';
import type { IWorkflowShortResponse, ExecutionFilterType } from '@/Interface';
Vue.use(PiniaVuePlugin);
const CLOUD_HOST = 'https://app.n8n.cloud';
const PRODUCTION_SUBSCRIPTION_HOST = 'https://subscription.n8n.io';
const DEVELOPMENT_SUBSCRIPTION_HOST = 'https://staging-subscription.n8n.io';
const defaultFilterState: ExecutionFilterType = {
status: 'all',
workflowId: 'all',
tags: [],
startDate: '',
endDate: '',
metadata: [{ key: '', value: '' }],
};
const workflowDataFactory = (): IWorkflowShortResponse => ({
createdAt: faker.date.past().toDateString(),
updatedAt: faker.date.past().toDateString(),
id: faker.datatype.uuid(),
name: faker.datatype.string(),
active: faker.datatype.boolean(),
tags: [],
});
const workflowsData = Array.from({ length: 10 }, workflowDataFactory);
const initialState = {
[STORES.SETTINGS]: {
settings: {
templates: {
enabled: true,
host: 'https://api.n8n.io/api/',
},
license: {
environment: 'development',
},
deployment: {
type: 'default',
},
enterprise: {
advancedExecutionFilters: true,
},
},
},
};
const renderOptions: RenderOptions<ExecutionFilter> = {
i18n: i18nInstance,
};
describe('ExecutionFilter', () => {
test.each([
['development', 'default', DEVELOPMENT_SUBSCRIPTION_HOST, false, workflowsData],
['development', 'default', '', true, workflowsData],
['development', 'cloud', CLOUD_HOST, false, undefined],
['development', 'cloud', '', true, undefined],
['production', 'cloud', CLOUD_HOST, false, workflowsData],
['production', 'cloud', '', true, undefined],
['production', 'default', PRODUCTION_SUBSCRIPTION_HOST, false, undefined],
['production', 'default', '', true, workflowsData],
])(
'renders in %s environment on %s deployment with advancedExecutionFilters %s and workflows %s',
async (environment, deployment, plansLinkUrlBase, advancedExecutionFilters, workflows) => {
initialState[STORES.SETTINGS].settings.license.environment = environment;
initialState[STORES.SETTINGS].settings.deployment.type = deployment;
initialState[STORES.SETTINGS].settings.enterprise.advancedExecutionFilters =
advancedExecutionFilters;
renderOptions.pinia = createTestingPinia({ initialState });
renderOptions.props = { workflows };
const { getByTestId, queryByTestId } = render(ExecutionFilter, renderOptions);
await userEvent.click(getByTestId('executions-filter-button'));
await userEvent.hover(getByTestId('execution-filter-saved-data-key-input'));
if (!advancedExecutionFilters) {
expect(getByTestId('executions-filter-view-plans-link').getAttribute('href')).contains(
plansLinkUrlBase,
);
} else {
expect(queryByTestId('executions-filter-view-plans-link')).not.toBeInTheDocument();
}
expect(queryByTestId('executions-filter-reset-button')).not.toBeInTheDocument();
expect(!!queryByTestId('executions-filter-workflows-select')).toBe(!!workflows?.length);
},
);
test('state change', async () => {
const { getByTestId, queryByTestId, emitted } = render(ExecutionFilter, renderOptions);
const filterChangedEvent = emitted().filterChanged;
expect(filterChangedEvent).toHaveLength(1);
expect(filterChangedEvent[0]).toEqual([defaultFilterState]);
expect(getByTestId('execution-filter-form')).not.toBeVisible();
expect(queryByTestId('executions-filter-reset-button')).not.toBeInTheDocument();
expect(queryByTestId('execution-filter-badge')).not.toBeInTheDocument();
await userEvent.click(getByTestId('executions-filter-button'));
expect(getByTestId('execution-filter-form')).toBeVisible();
await userEvent.click(getByTestId('executions-filter-status-select'));
await userEvent.click(getByTestId('executions-filter-status-select').querySelectorAll('li')[1]);
expect(emitted().filterChanged).toHaveLength(2);
expect(filterChangedEvent[1]).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]);
expect(queryByTestId('executions-filter-reset-button')).not.toBeInTheDocument();
expect(queryByTestId('execution-filter-badge')).not.toBeInTheDocument();
});
});

View File

@@ -69,6 +69,15 @@ const renderOptions = {
enabled: true,
host: 'https://api.n8n.io/api/',
},
license: {
environment: 'development',
},
deployment: {
type: 'default',
},
enterprise: {
advancedExecutionFilters: true,
},
},
},
},
@@ -137,6 +146,7 @@ describe('ExecutionsList.vue', () => {
await userEvent.click(getByTestId('load-more-button'));
expect(getPastExecutionsSpy).toHaveBeenCalledTimes(2);
expect(getAllByTestId('select-execution-checkbox').length).toBe(20);
expect(
getAllByTestId('select-execution-checkbox').filter((el) =>