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:
@@ -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 {
|
||||
|
||||
418
packages/editor-ui/src/components/ExecutionFilter.vue
Normal file
418
packages/editor-ui/src/components/ExecutionFilter.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user