diff --git a/packages/frontend/editor-ui/src/features/execution/executions/components/ExecutionsFilter.test.ts b/packages/frontend/editor-ui/src/features/execution/executions/components/ExecutionsFilter.test.ts index 4e0580678a5..6e3a5d40618 100644 --- a/packages/frontend/editor-ui/src/features/execution/executions/components/ExecutionsFilter.test.ts +++ b/packages/frontend/editor-ui/src/features/execution/executions/components/ExecutionsFilter.test.ts @@ -176,6 +176,25 @@ describe('ExecutionsFilter', () => { expect(queryByTestId('execution-filter-badge')).not.toBeInTheDocument(); }); + test('starts from the initial filters without emitting a change', async () => { + const { getByTestId, emitted } = renderComponent({ + props: { initialFilters: { ...defaultFilterState, status: 'error' } }, + }); + + expect(getByTestId('execution-filter-badge')).toHaveTextContent('1'); + expect(emitted().filterChanged).toBeUndefined(); + + await userEvent.click(getByTestId('executions-filter-button')); + await userEvent.click(getByTestId('executions-filter-annotation-vote-select')); + await userEvent.click( + getByTestId('executions-filter-annotation-vote-select').querySelectorAll('li')[1], + ); + + expect(emitted().filterChanged).toEqual([ + [{ ...defaultFilterState, status: 'error', vote: 'up' }], + ]); + }); + test('shows annotation filters when advanced filters are enabled', async () => { const { getByTestId, queryByTestId } = renderComponent(); diff --git a/packages/frontend/editor-ui/src/features/execution/executions/components/ExecutionsFilter.vue b/packages/frontend/editor-ui/src/features/execution/executions/components/ExecutionsFilter.vue index 37c772f37c8..7987ee46b70 100644 --- a/packages/frontend/editor-ui/src/features/execution/executions/components/ExecutionsFilter.vue +++ b/packages/frontend/editor-ui/src/features/execution/executions/components/ExecutionsFilter.vue @@ -14,6 +14,7 @@ import { convertToDisplayDate } from '@/app/utils/formatters/dateFormatter'; import { isEmpty } from '@/app/utils/typesUtils'; import { computed, onBeforeMount, reactive, ref, watch } from 'vue'; import { I18nT } from 'vue-i18n'; +import cloneDeep from 'lodash/cloneDeep'; import { ElDatePicker } from 'element-plus'; import { @@ -29,6 +30,7 @@ import { } from '@n8n/design-system'; export type ExecutionFilterProps = { + initialFilters?: ExecutionFilterType; workflows?: Array; workflowId?: string; popoverSide?: 'top' | 'right' | 'bottom' | 'left'; @@ -74,7 +76,8 @@ const getDefaultFilter = (): ExecutionFilterType => ({ vote: 'all', workflowVersionId: 'all', }); -const filter = reactive(getDefaultFilter()); +// Copy, so nested edits do not change the caller's filters before this component emits them. +const filter = reactive({ ...getDefaultFilter(), ...cloneDeep(props.initialFilters) }); type ExecutionVersion = { versionId: string; name: string | null; createdAt: string }; const workflowVersions = ref([]); diff --git a/packages/frontend/editor-ui/src/features/execution/executions/components/global/GlobalExecutionsList.vue b/packages/frontend/editor-ui/src/features/execution/executions/components/global/GlobalExecutionsList.vue index a64fe5f7f97..bbb2bc65ca1 100644 --- a/packages/frontend/editor-ui/src/features/execution/executions/components/global/GlobalExecutionsList.vue +++ b/packages/frontend/editor-ui/src/features/execution/executions/components/global/GlobalExecutionsList.vue @@ -370,6 +370,7 @@ const goToUpgrade = () => {
{ @update:model-value="onAutoRefreshChange" /> { const activeExecution = ref(null); const filters = ref(getDefaultExecutionFilters()); + const savedFilters: Record = {}; const executionsFilters = computed(() => { const filter = executionFilterToQueryFilter(filters.value); @@ -149,6 +151,14 @@ export const useExecutionsStore = defineStore('executions', () => { filters.value = value; } + function saveFilters(key: string) { + savedFilters[key] = cloneDeep(filters.value); + } + + function restoreFilters(key: string) { + filters.value = cloneDeep(savedFilters[key] ?? getDefaultExecutionFilters()); + } + async function initialize(workflowId?: string) { if (workflowId) { filters.value.workflowId = workflowId; @@ -428,6 +438,8 @@ export const useExecutionsStore = defineStore('executions', () => { initialize, filters, setFilters, + saveFilters, + restoreFilters, executionsFilters, currentExecutionsFilters, allExecutions, diff --git a/packages/frontend/editor-ui/src/features/execution/executions/views/ExecutionsView.test.ts b/packages/frontend/editor-ui/src/features/execution/executions/views/ExecutionsView.test.ts index 7ee7cbbec41..bef9c85f338 100644 --- a/packages/frontend/editor-ui/src/features/execution/executions/views/ExecutionsView.test.ts +++ b/packages/frontend/editor-ui/src/features/execution/executions/views/ExecutionsView.test.ts @@ -1,3 +1,4 @@ +import { reactive } from 'vue'; import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import { fireEvent, within } from '@testing-library/vue'; @@ -12,11 +13,13 @@ import type { Project } from '@/features/collaboration/projects/projects.types'; import type { IWorkflowDb } from '@/Interface'; const push = vi.fn(); -const route = vi.hoisted(() => ({ +const route = reactive({ params: {} as Record, query: {}, name: '', -})); +}); + +vi.mock('@/features/collaboration/projects/projects.api'); vi.mock('vue-router', () => ({ useRoute: () => route, @@ -29,9 +32,14 @@ const renderComponent = createComponentRenderer(ExecutionsView, { stubs: { ProjectHeader: { template: '
' }, GlobalExecutionsList: { - emits: ['execution:stop'], - template: - '
', + props: ['filters'], + emits: ['execution:stop', 'update:filters'], + template: `
+
`, }, InsightsSummary: true, }, @@ -143,4 +151,59 @@ describe('ExecutionsView', () => { expect(workflowsListStore.hasFetchedAllWorkflows).toHaveBeenCalledWith('project-1'); expect(getByTestId('empty-resources-list')).toBeInTheDocument(); }); + + describe('filters', () => { + beforeEach(() => { + // Use the real store actions, so the filters go through reset, save, and restore. + setActivePinia(createTestingPinia({ stubActions: false })); + workflowsListStore = mockedStore(useWorkflowsListStore); + workflowsListStore.allWorkflows = [{ id: 'w1' } as IWorkflowDb]; + workflowsListStore.fetchAllWorkflows.mockResolvedValue([]); + workflowsListStore.hasFetchedAllWorkflows.mockReturnValue(true); + mockedStore(useExecutionsStore).initialize.mockResolvedValue(); + }); + + it('keeps separate filters for Overview and projects after navigating away and back', async () => { + const overview = renderComponent(); + await waitAllPromises(); + await fireEvent.click(overview.getByTestId('filter-error-stub')); + overview.unmount(); + + route.params.projectId = 'project-1'; + const project = renderComponent(); + await waitAllPromises(); + expect(project.getByTestId('filter-status-stub')).toHaveTextContent('all'); + await fireEvent.click(project.getByTestId('filter-error-stub')); + project.unmount(); + + route.params = {}; + const overviewAgain = renderComponent(); + await waitAllPromises(); + expect(overviewAgain.getByTestId('filter-status-stub')).toHaveTextContent('error'); + overviewAgain.unmount(); + + route.params.projectId = 'project-1'; + const projectAgain = renderComponent(); + await waitAllPromises(); + expect(projectAgain.getByTestId('filter-status-stub')).toHaveTextContent('error'); + }); + + it('loads the filters of the new project when only the project changes', async () => { + const executionsStore = mockedStore(useExecutionsStore); + route.params.projectId = 'project-1'; + const { getByTestId } = renderComponent(); + await waitAllPromises(); + await fireEvent.click(getByTestId('filter-error-stub')); + executionsStore.initialize.mockClear(); + + route.params.projectId = 'project-2'; + await waitAllPromises(); + expect(getByTestId('filter-status-stub')).toHaveTextContent('all'); + expect(executionsStore.initialize).toHaveBeenCalled(); + + route.params.projectId = 'project-1'; + await waitAllPromises(); + expect(getByTestId('filter-status-stub')).toHaveTextContent('error'); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/execution/executions/views/ExecutionsView.vue b/packages/frontend/editor-ui/src/features/execution/executions/views/ExecutionsView.vue index db88fa35884..6000c3bebed 100644 --- a/packages/frontend/editor-ui/src/features/execution/executions/views/ExecutionsView.vue +++ b/packages/frontend/editor-ui/src/features/execution/executions/views/ExecutionsView.vue @@ -47,6 +47,10 @@ const projectId = computed(() => { return typeof value === 'string' ? value : undefined; }); +const filtersKey = computed(() => `project:${projectId.value ?? 'overview'}`); +// Restore before the filter component is set up, so it shows the same filters. +executionsStore.restoreFilters(filtersKey.value); + const workflowCount = computed(() => workflowsListStore.allWorkflows.length); const hasFetchedWorkflowsForProject = computed(() => workflowsListStore.hasFetchedAllWorkflows(projectId.value), @@ -85,7 +89,9 @@ onBeforeMount(async () => { }); watch(projectId, async () => { - await loadWorkflowsForCurrentProject(); + executionsStore.reset(); + executionsStore.restoreFilters(filtersKey.value); + await Promise.all([executionsStore.initialize(), loadWorkflowsForCurrentProject()]); }); onMounted(async () => { @@ -127,6 +133,7 @@ async function onRefreshData() { async function onUpdateFilters(newFilters: ExecutionFilterType) { executionsStore.reset(); executionsStore.setFilters(newFilters); + executionsStore.saveFilters(filtersKey.value); await executionsStore.initialize(); } @@ -163,6 +170,7 @@ async function onExecutionStop() { ({ + useRoute: () => ({ params: { workflowId: 'w1' }, query: {}, name: '' }), + useRouter: () => ({ replace: vi.fn() }), + RouterLink: { template: '' }, +})); + +const WorkflowExecutionsListStub = defineComponent({ + emits: ['update:filters'], + setup(_, { emit }) { + return { + selectError: () => + emit('update:filters', { ...getDefaultExecutionFilters(), status: 'error' }), + }; + }, + template: '