mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
fix(editor): Improve filter change handling with debounced updates for date fields (#17618)
This commit is contained in:
@@ -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',
|
||||
let renderComponent: ReturnType<typeof createComponentRenderer>;
|
||||
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
||||
|
||||
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', '#');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<keyof ExecutionFilterType, ReturnType<typeof computed>>,
|
||||
),
|
||||
// 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(() => {
|
||||
<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"
|
||||
|
||||
Reference in New Issue
Block a user