From ae089173a71b3417ca07ae4bf49d4b0b3d31bf09 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Fri, 25 Jul 2025 14:06:43 +0200 Subject: [PATCH] fix(editor): Improve filter change handling with debounced updates for date fields (#17618) --- .../executions/ExecutionsFilter.test.ts | 190 +++++++++++++----- .../executions/ExecutionsFilter.vue | 47 ++--- 2 files changed, 159 insertions(+), 78 deletions(-) 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 { }}