diff --git a/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.test.ts b/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.test.ts
index 292ec7c46e..c4618a6c38 100644
--- a/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.test.ts
+++ b/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.test.ts
@@ -1,14 +1,43 @@
-import { describe, test, expect } from 'vitest';
+import { reactive } from 'vue';
import { createTestingPinia } from '@pinia/testing';
+import { mockedStore } from '@/__tests__/utils';
+import { useSettingsStore } from '@/stores/settings.store';
+import type { FrontendSettings } from '@n8n/api-types';
import userEvent from '@testing-library/user-event';
import { faker } from '@faker-js/faker';
import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue';
-import { STORES } from '@n8n/stores';
import type { IWorkflowShortResponse, ExecutionFilterType } from '@/Interface';
import { createComponentRenderer } from '@/__tests__/render';
import * as telemetryModule from '@/composables/useTelemetry';
import type { Telemetry } from '@/plugins/telemetry';
-import merge from 'lodash/merge';
+
+vi.mock('vue-router', () => ({
+ useRoute: () =>
+ reactive({
+ location: {},
+ }),
+ RouterLink: vi.fn(),
+}));
+
+vi.mock('@/composables/usePageRedirectionHelper', () => ({
+ usePageRedirectionHelper: () => ({
+ goToUpgrade: vi.fn(),
+ }),
+}));
+
+vi.mock('@/components/AnnotationTagsDropdown.ee.vue', () => ({
+ default: {
+ name: 'AnnotationTagsDropdown',
+ template: '
',
+ },
+}));
+
+vi.mock('@/components/WorkflowTagsDropdown.vue', () => ({
+ default: {
+ name: 'WorkflowTagsDropdown',
+ template: '',
+ },
+}));
const defaultFilterState: ExecutionFilterType = {
status: 'all',
@@ -32,34 +61,28 @@ const workflowDataFactory = (): IWorkflowShortResponse => ({
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',
+let renderComponent: ReturnType;
+let settingsStore: ReturnType>;
+
+describe('ExecutionsFilter', () => {
+ beforeEach(() => {
+ renderComponent = createComponentRenderer(ExecutionsFilter, {
+ props: {
+ teleported: false,
},
+ pinia: createTestingPinia(),
+ });
+
+ settingsStore = mockedStore(useSettingsStore);
+
+ settingsStore.settings = {
enterprise: {
advancedExecutionFilters: true,
},
- },
- },
-};
+ } as FrontendSettings;
+ });
-const renderComponent = createComponentRenderer(ExecutionsFilter, {
- props: {
- teleported: false,
- },
-});
-
-describe('ExecutionsFilter', () => {
- afterAll(() => {
+ afterEach(() => {
vi.clearAllMocks();
});
@@ -73,9 +96,9 @@ describe('ExecutionsFilter', () => {
}) as unknown as Telemetry,
);
- const { getByTestId } = renderComponent({
- pinia: createTestingPinia({ initialState }),
- });
+ const { getByTestId } = renderComponent();
+ await userEvent.click(getByTestId('executions-filter-button'));
+
const customDataKeyInput = getByTestId('execution-filter-saved-data-key-input');
await userEvent.type(customDataKeyInput, 'test');
@@ -95,26 +118,11 @@ describe('ExecutionsFilter', () => {
['production', 'default', true, workflowsData],
])(
'renders in %s environment on %s deployment with advancedExecutionFilters %s',
- async (environment, deployment, advancedExecutionFilters, workflows) => {
+ async (_, __, advancedExecutionFilters, workflows) => {
+ settingsStore.settings.enterprise.advancedExecutionFilters = advancedExecutionFilters;
+
const { getByTestId, queryByTestId } = renderComponent({
props: { workflows },
- pinia: createTestingPinia({
- initialState: merge(initialState, {
- [STORES.SETTINGS]: {
- settings: {
- license: {
- environment,
- },
- deployment: {
- type: deployment,
- },
- enterprise: {
- advancedExecutionFilters,
- },
- },
- },
- }),
- }),
});
await userEvent.click(getByTestId('executions-filter-button'));
@@ -136,9 +144,7 @@ describe('ExecutionsFilter', () => {
);
test('state change', async () => {
- const { getByTestId, queryByTestId, emitted } = renderComponent({
- pinia: createTestingPinia({ initialState }),
- });
+ const { getByTestId, queryByTestId, emitted } = renderComponent();
let filterChangedEvent = emitted().filterChanged;
@@ -167,4 +173,88 @@ describe('ExecutionsFilter', () => {
expect(queryByTestId('executions-filter-reset-button')).not.toBeInTheDocument();
expect(queryByTestId('execution-filter-badge')).not.toBeInTheDocument();
});
+
+ test('shows annotation filters when advanced filters are enabled', async () => {
+ const { getByTestId, queryByTestId } = renderComponent();
+
+ await userEvent.click(getByTestId('executions-filter-button'));
+
+ expect(queryByTestId('executions-filter-annotation-tags-select')).toBeInTheDocument();
+ expect(queryByTestId('executions-filter-annotation-vote-select')).toBeInTheDocument();
+ });
+
+ test('hides annotation filters when advanced filters are disabled', async () => {
+ settingsStore.settings.enterprise.advancedExecutionFilters = false;
+
+ const { getByTestId, queryByTestId } = renderComponent();
+
+ await userEvent.click(getByTestId('executions-filter-button'));
+
+ expect(queryByTestId('executions-filter-annotation-tags-select')).not.toBeInTheDocument();
+ expect(queryByTestId('executions-filter-annotation-vote-select')).not.toBeInTheDocument();
+ });
+
+ test('tracks telemetry for custom data filter usage', async () => {
+ const track = vi.fn();
+ const spy = vi.spyOn(telemetryModule, 'useTelemetry');
+ spy.mockImplementation(
+ () =>
+ ({
+ track,
+ }) as unknown as Telemetry,
+ );
+
+ const { getByTestId } = renderComponent();
+
+ await userEvent.click(getByTestId('executions-filter-button'));
+
+ const keyInput = getByTestId('execution-filter-saved-data-key-input');
+
+ // Verify the input is not disabled
+ expect(keyInput).not.toBeDisabled();
+
+ await userEvent.type(keyInput, 'custom-key');
+
+ expect(track).toHaveBeenCalledWith('User filtered executions with custom data');
+ expect(track).toHaveBeenCalledTimes(1);
+ });
+
+ test('handles metadata value input changes', async () => {
+ const { getByTestId, emitted } = renderComponent();
+
+ await userEvent.click(getByTestId('executions-filter-button'));
+
+ const valueInput = getByTestId('execution-filter-saved-data-value-input');
+ await userEvent.type(valueInput, 'test-value');
+
+ const filterChangedEvents = emitted().filterChanged;
+ expect(filterChangedEvents).toBeDefined();
+ expect(filterChangedEvents.length).toBeGreaterThan(0);
+ });
+
+ test('handles exact match checkbox changes', async () => {
+ const { getByTestId, emitted } = renderComponent();
+
+ await userEvent.click(getByTestId('executions-filter-button'));
+
+ const checkbox = getByTestId('execution-filter-saved-data-exact-match-checkbox');
+ await userEvent.click(checkbox);
+
+ const filterChangedEvents = emitted().filterChanged;
+ expect(filterChangedEvents).toBeDefined();
+ expect(filterChangedEvents.length).toBeGreaterThan(0);
+ });
+
+ test('shows upgrade link when advanced filters disabled', async () => {
+ settingsStore.settings.enterprise.advancedExecutionFilters = false;
+
+ const { getByTestId } = renderComponent();
+
+ await userEvent.click(getByTestId('executions-filter-button'));
+ await userEvent.hover(getByTestId('execution-filter-saved-data-key-input'));
+
+ const upgradeLink = getByTestId('executions-filter-view-plans-link');
+ expect(upgradeLink).toBeInTheDocument();
+ expect(upgradeLink).toHaveAttribute('href', '#');
+ });
});
diff --git a/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.vue b/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.vue
index 598c410a48..055ea9329e 100644
--- a/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.vue
+++ b/packages/frontend/editor-ui/src/components/executions/ExecutionsFilter.vue
@@ -12,9 +12,9 @@ import type {
} from '@/Interface';
import { i18n as locale } from '@n8n/i18n';
import { useSettingsStore } from '@/stores/settings.store';
-import { getObjectKeys, isEmpty } from '@/utils/typesUtils';
+import { isEmpty } from '@/utils/typesUtils';
import type { Placement } from '@floating-ui/core';
-import { computed, onBeforeMount, reactive, ref } from 'vue';
+import { computed, onBeforeMount, reactive, ref, watch } from 'vue';
import { I18nT } from 'vue-i18n';
export type ExecutionFilterProps = {
@@ -62,26 +62,18 @@ const getDefaultFilter = (): ExecutionFilterType => ({
});
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
-
- // @ts-ignore
- filter[key] = value;
- emit('filterChanged', filter);
- },
- });
- return acc;
- },
- {} as Record>,
- ),
+// Deep watcher to emit filterChanged events with debouncing for date fields only
+watch(
+ filter,
+ (newFilter) => {
+ // Use debounced emit if filter contains date changes to prevent rapid API calls
+ if (newFilter.startDate || newFilter.endDate) {
+ debouncedEmit('filterChanged', newFilter);
+ } else {
+ emit('filterChanged', newFilter);
+ }
+ },
+ { deep: true, immediate: false },
);
const statuses = computed(() => [
@@ -151,7 +143,6 @@ const onAnnotationTagsChange = () => {
const onFilterReset = () => {
Object.assign(filter, getDefaultFilter());
- emit('filterChanged', filter);
};
const goToUpgrade = () => {
@@ -197,7 +188,7 @@ onBeforeMount(() => {
{
{
{
to
{
}}