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 { 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', '#');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user