fix(editor): Keep execution filters when navigating away from executions (#39781)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nour Alhadi Mahmoud 2026-09-30 07:24:19 +00:00 committed by GitHub
parent dafc10852f
commit fefb2da2b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 180 additions and 7 deletions

View File

@ -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();

View File

@ -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<IWorkflowDb | IWorkflowShortResponse>;
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<ExecutionVersion[]>([]);

View File

@ -370,6 +370,7 @@ const goToUpgrade = () => {
<div :class="$style.execHeaderRight">
<ExecutionStopAllText :executions="props.executions" />
<ExecutionsFilter
:initial-filters="props.filters"
:workflows="workflows"
class="execFilter"
@filter-changed="onFilterChanged"

View File

@ -195,6 +195,7 @@ const goToUpgrade = () => {
@update:model-value="onAutoRefreshChange"
/>
<ExecutionsFilter
:initial-filters="executionsStore.filters"
popover-side="right"
popover-align="start"
:workflow-id="props.workflow?.id"

View File

@ -1,5 +1,6 @@
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import cloneDeep from 'lodash/cloneDeep';
import type {
IDataObject,
ExecutionSummary,
@ -41,6 +42,7 @@ export const useExecutionsStore = defineStore('executions', () => {
const activeExecution = ref<ExecutionSummary | null>(null);
const filters = ref<ExecutionFilterType>(getDefaultExecutionFilters());
const savedFilters: Record<string, ExecutionFilterType> = {};
const executionsFilters = computed<ExecutionsQueryFilter>(() => {
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,

View File

@ -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<string, string>,
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: '<div data-test-id="project-header-stub" />' },
GlobalExecutionsList: {
emits: ['execution:stop'],
template:
'<div data-test-id="global-executions-list-stub"><button data-test-id="stop-stub" @click="$emit(\'execution:stop\')" /><slot /></div>',
props: ['filters'],
emits: ['execution:stop', 'update:filters'],
template: `<div data-test-id="global-executions-list-stub">
<button data-test-id="stop-stub" @click="$emit('execution:stop')" />
<button data-test-id="filter-error-stub" @click="$emit('update:filters', { ...filters, status: 'error' })" />
<span data-test-id="filter-status-stub">{{ filters.status }}</span>
<slot />
</div>`,
},
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');
});
});
});

View File

@ -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() {
</PageViewLayout>
<GlobalExecutionsList
v-else
:key="filtersKey"
:executions="allExecutions"
:filters="filters"
:total="executionsCount"

View File

@ -0,0 +1,62 @@
import { computed, defineComponent } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { fireEvent } from '@testing-library/vue';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
import { WorkflowIdKey } from '@/app/constants/injectionKeys';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import type { IWorkflowDb } from '@/Interface';
import { useExecutionsStore } from '../executions.store';
import { getDefaultExecutionFilters } from '../executions.utils';
import WorkflowExecutionsView from './WorkflowExecutionsView.vue';
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { workflowId: 'w1' }, query: {}, name: '' }),
useRouter: () => ({ replace: vi.fn() }),
RouterLink: { template: '<a><slot /></a>' },
}));
const WorkflowExecutionsListStub = defineComponent({
emits: ['update:filters'],
setup(_, { emit }) {
return {
selectError: () =>
emit('update:filters', { ...getDefaultExecutionFilters(), status: 'error' }),
};
},
template: '<button data-test-id="filter-error-stub" @click="selectError" />',
});
const renderComponent = createComponentRenderer(WorkflowExecutionsView, {
global: {
provide: { [WorkflowIdKey as unknown as string]: computed(() => 'w1') },
stubs: { WorkflowExecutionsList: WorkflowExecutionsListStub },
},
});
describe('WorkflowExecutionsView', () => {
let executionsStore: ReturnType<typeof mockedStore<typeof useExecutionsStore>>;
beforeEach(() => {
// Use the real store actions, so the filters go through reset, save, and restore.
setActivePinia(createTestingPinia({ stubActions: false }));
mockedStore(useWorkflowsListStore).workflowsById = { w1: { id: 'w1' } as IWorkflowDb };
executionsStore = mockedStore(useExecutionsStore);
executionsStore.initialize.mockResolvedValue();
});
it('keeps the filters after leaving the executions tab and coming back', async () => {
const { getByTestId, unmount } = renderComponent();
await waitAllPromises();
await fireEvent.click(getByTestId('filter-error-stub'));
unmount();
expect(executionsStore.filters.status).toBe('all');
renderComponent();
await waitAllPromises();
expect(executionsStore.filters.status).toBe('error');
});
});

View File

@ -33,6 +33,9 @@ const loadingMore = ref(false);
const workflow = ref<IWorkflowDb | undefined>();
const workflowId = useInjectWorkflowId();
const filtersKey = computed(() => `workflow:${workflowId.value}`);
// Restore before the filter component is set up, so it shows the same filters.
executionsStore.restoreFilters(filtersKey.value);
const executionId = computed(() => {
const id = route.params.executionId;
@ -197,6 +200,7 @@ async function onRefreshData() {
async function onUpdateFilters(newFilters: ExecutionFilterType) {
executionsStore.reset();
executionsStore.setFilters(newFilters);
executionsStore.saveFilters(filtersKey.value);
await executionsStore.initialize(workflowId.value);
}