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 { 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 userEvent from '@testing-library/user-event';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue'; import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue';
import { STORES } from '@n8n/stores';
import type { IWorkflowShortResponse, ExecutionFilterType } from '@/Interface'; import type { IWorkflowShortResponse, ExecutionFilterType } from '@/Interface';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import * as telemetryModule from '@/composables/useTelemetry'; import * as telemetryModule from '@/composables/useTelemetry';
import type { Telemetry } from '@/plugins/telemetry'; 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 = { const defaultFilterState: ExecutionFilterType = {
status: 'all', status: 'all',
@@ -32,34 +61,28 @@ const workflowDataFactory = (): IWorkflowShortResponse => ({
const workflowsData = Array.from({ length: 10 }, workflowDataFactory); const workflowsData = Array.from({ length: 10 }, workflowDataFactory);
const initialState = { let renderComponent: ReturnType<typeof createComponentRenderer>;
[STORES.SETTINGS]: { let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
settings: {
templates: {
enabled: true,
host: 'https://api.n8n.io/api/',
},
license: {
environment: 'development',
},
deployment: {
type: 'default',
},
enterprise: {
advancedExecutionFilters: true,
},
},
},
};
const renderComponent = createComponentRenderer(ExecutionsFilter, { describe('ExecutionsFilter', () => {
beforeEach(() => {
renderComponent = createComponentRenderer(ExecutionsFilter, {
props: { props: {
teleported: false, teleported: false,
}, },
pinia: createTestingPinia(),
}); });
describe('ExecutionsFilter', () => { settingsStore = mockedStore(useSettingsStore);
afterAll(() => {
settingsStore.settings = {
enterprise: {
advancedExecutionFilters: true,
},
} as FrontendSettings;
});
afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -73,9 +96,9 @@ describe('ExecutionsFilter', () => {
}) as unknown as Telemetry, }) as unknown as Telemetry,
); );
const { getByTestId } = renderComponent({ const { getByTestId } = renderComponent();
pinia: createTestingPinia({ initialState }), await userEvent.click(getByTestId('executions-filter-button'));
});
const customDataKeyInput = getByTestId('execution-filter-saved-data-key-input'); const customDataKeyInput = getByTestId('execution-filter-saved-data-key-input');
await userEvent.type(customDataKeyInput, 'test'); await userEvent.type(customDataKeyInput, 'test');
@@ -95,26 +118,11 @@ describe('ExecutionsFilter', () => {
['production', 'default', true, workflowsData], ['production', 'default', true, workflowsData],
])( ])(
'renders in %s environment on %s deployment with advancedExecutionFilters %s', '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({ const { getByTestId, queryByTestId } = renderComponent({
props: { workflows }, props: { workflows },
pinia: createTestingPinia({
initialState: merge(initialState, {
[STORES.SETTINGS]: {
settings: {
license: {
environment,
},
deployment: {
type: deployment,
},
enterprise: {
advancedExecutionFilters,
},
},
},
}),
}),
}); });
await userEvent.click(getByTestId('executions-filter-button')); await userEvent.click(getByTestId('executions-filter-button'));
@@ -136,9 +144,7 @@ describe('ExecutionsFilter', () => {
); );
test('state change', async () => { test('state change', async () => {
const { getByTestId, queryByTestId, emitted } = renderComponent({ const { getByTestId, queryByTestId, emitted } = renderComponent();
pinia: createTestingPinia({ initialState }),
});
let filterChangedEvent = emitted().filterChanged; let filterChangedEvent = emitted().filterChanged;
@@ -167,4 +173,88 @@ describe('ExecutionsFilter', () => {
expect(queryByTestId('executions-filter-reset-button')).not.toBeInTheDocument(); expect(queryByTestId('executions-filter-reset-button')).not.toBeInTheDocument();
expect(queryByTestId('execution-filter-badge')).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'; } from '@/Interface';
import { i18n as locale } from '@n8n/i18n'; import { i18n as locale } from '@n8n/i18n';
import { useSettingsStore } from '@/stores/settings.store'; 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 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'; import { I18nT } from 'vue-i18n';
export type ExecutionFilterProps = { export type ExecutionFilterProps = {
@@ -62,26 +62,18 @@ const getDefaultFilter = (): ExecutionFilterType => ({
}); });
const filter = reactive(getDefaultFilter()); const filter = reactive(getDefaultFilter());
// Automatically set up v-models based on filter properties // Deep watcher to emit filterChanged events with debouncing for date fields only
const vModel = reactive( watch(
getObjectKeys(filter).reduce( filter,
(acc, key) => { (newFilter) => {
acc[key] = computed({ // Use debounced emit if filter contains date changes to prevent rapid API calls
get() { if (newFilter.startDate || newFilter.endDate) {
return filter[key]; debouncedEmit('filterChanged', newFilter);
} else {
emit('filterChanged', newFilter);
}
}, },
set(value) { { deep: true, immediate: false },
// 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>>,
),
); );
const statuses = computed(() => [ const statuses = computed(() => [
@@ -151,7 +143,6 @@ const onAnnotationTagsChange = () => {
const onFilterReset = () => { const onFilterReset = () => {
Object.assign(filter, getDefaultFilter()); Object.assign(filter, getDefaultFilter());
emit('filterChanged', filter);
}; };
const goToUpgrade = () => { const goToUpgrade = () => {
@@ -197,7 +188,7 @@ onBeforeMount(() => {
<label for="execution-filter-workflows">{{ locale.baseText('workflows.heading') }}</label> <label for="execution-filter-workflows">{{ locale.baseText('workflows.heading') }}</label>
<n8n-select <n8n-select
id="execution-filter-workflows" id="execution-filter-workflows"
v-model="vModel.workflowId" v-model="filter.workflowId"
:placeholder="locale.baseText('executionsFilter.selectWorkflow')" :placeholder="locale.baseText('executionsFilter.selectWorkflow')"
filterable filterable
data-test-id="executions-filter-workflows-select" data-test-id="executions-filter-workflows-select"
@@ -228,7 +219,7 @@ onBeforeMount(() => {
<label for="execution-filter-status">{{ locale.baseText('executionsList.status') }}</label> <label for="execution-filter-status">{{ locale.baseText('executionsList.status') }}</label>
<n8n-select <n8n-select
id="execution-filter-status" id="execution-filter-status"
v-model="vModel.status" v-model="filter.status"
:placeholder="locale.baseText('executionsFilter.selectStatus')" :placeholder="locale.baseText('executionsFilter.selectStatus')"
filterable filterable
data-test-id="executions-filter-status-select" data-test-id="executions-filter-status-select"
@@ -249,7 +240,7 @@ onBeforeMount(() => {
<div :class="$style.dates"> <div :class="$style.dates">
<el-date-picker <el-date-picker
id="execution-filter-start-date" id="execution-filter-start-date"
v-model="vModel.startDate" v-model="filter.startDate"
type="datetime" type="datetime"
:teleported="false" :teleported="false"
:format="DATE_TIME_MASK" :format="DATE_TIME_MASK"
@@ -259,7 +250,7 @@ onBeforeMount(() => {
<span :class="$style.divider">to</span> <span :class="$style.divider">to</span>
<el-date-picker <el-date-picker
id="execution-filter-end-date" id="execution-filter-end-date"
v-model="vModel.endDate" v-model="filter.endDate"
type="datetime" type="datetime"
:teleported="false" :teleported="false"
:format="DATE_TIME_MASK" :format="DATE_TIME_MASK"
@@ -287,7 +278,7 @@ onBeforeMount(() => {
}}</label> }}</label>
<n8n-select <n8n-select
id="execution-filter-annotation-vote" id="execution-filter-annotation-vote"
v-model="vModel.vote" v-model="filter.vote"
:placeholder="locale.baseText('executionsFilter.annotation.selectVoteFilter')" :placeholder="locale.baseText('executionsFilter.annotation.selectVoteFilter')"
filterable filterable
data-test-id="executions-filter-annotation-vote-select" data-test-id="executions-filter-annotation-vote-select"