fix(editor): Improve filter change handling with debounced updates for date fields (#17618)

This commit is contained in:
Csaba Tuncsik
2025-07-25 14:06:43 +02:00
committed by GitHub
parent 4de3759a59
commit ae089173a7
2 changed files with 159 additions and 78 deletions

View File

@@ -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: '<div data-test-id="executions-filter-annotation-tags-select"></div>',
},
}));
vi.mock('@/components/WorkflowTagsDropdown.vue', () => ({
default: {
name: 'WorkflowTagsDropdown',
template: '<div data-test-id="executions-filter-tags-select"></div>',
},
}));
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',
},
enterprise: {
advancedExecutionFilters: true,
},
},
},
};
let renderComponent: ReturnType<typeof createComponentRenderer>;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
const renderComponent = createComponentRenderer(ExecutionsFilter, {
describe('ExecutionsFilter', () => {
beforeEach(() => {
renderComponent = createComponentRenderer(ExecutionsFilter, {
props: {
teleported: false,
},
pinia: createTestingPinia(),
});
describe('ExecutionsFilter', () => {
afterAll(() => {
settingsStore = mockedStore(useSettingsStore);
settingsStore.settings = {
enterprise: {
advancedExecutionFilters: true,
},
} as FrontendSettings;
});
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', '#');
});
});

View File

@@ -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];
// 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);
}
},
set(value) {
// TODO: find out what exactly is typechecker complaining about
// @ts-ignore
filter[key] = value;
emit('filterChanged', filter);
},
});
return acc;
},
{} as Record<keyof ExecutionFilterType, ReturnType<typeof computed>>,
),
{ 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(() => {
<label for="execution-filter-workflows">{{ locale.baseText('workflows.heading') }}</label>
<n8n-select
id="execution-filter-workflows"
v-model="vModel.workflowId"
v-model="filter.workflowId"
:placeholder="locale.baseText('executionsFilter.selectWorkflow')"
filterable
data-test-id="executions-filter-workflows-select"
@@ -228,7 +219,7 @@ onBeforeMount(() => {
<label for="execution-filter-status">{{ locale.baseText('executionsList.status') }}</label>
<n8n-select
id="execution-filter-status"
v-model="vModel.status"
v-model="filter.status"
:placeholder="locale.baseText('executionsFilter.selectStatus')"
filterable
data-test-id="executions-filter-status-select"
@@ -249,7 +240,7 @@ onBeforeMount(() => {
<div :class="$style.dates">
<el-date-picker
id="execution-filter-start-date"
v-model="vModel.startDate"
v-model="filter.startDate"
type="datetime"
:teleported="false"
:format="DATE_TIME_MASK"
@@ -259,7 +250,7 @@ onBeforeMount(() => {
<span :class="$style.divider">to</span>
<el-date-picker
id="execution-filter-end-date"
v-model="vModel.endDate"
v-model="filter.endDate"
type="datetime"
:teleported="false"
:format="DATE_TIME_MASK"
@@ -287,7 +278,7 @@ onBeforeMount(() => {
}}</label>
<n8n-select
id="execution-filter-annotation-vote"
v-model="vModel.vote"
v-model="filter.vote"
:placeholder="locale.baseText('executionsFilter.annotation.selectVoteFilter')"
filterable
data-test-id="executions-filter-annotation-vote-select"