mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
dafc10852f
commit
fefb2da2b1
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]>([]);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user