fix: spurious unsaved-changes warning when navigating between test cases in a test run (#406)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kimatata <117462761+kimatata@users.noreply.github.com>
This commit is contained in:
103
frontend/utils/formGuard.test.ts
Normal file
103
frontend/utils/formGuard.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// mock cleanup
|
||||
let cleanupFn: (() => void) | undefined;
|
||||
vi.mock('react', () => ({
|
||||
useEffect: (fn: () => void) => {
|
||||
cleanupFn = fn() as (() => void) | undefined;
|
||||
},
|
||||
}));
|
||||
|
||||
import { useFormGuard } from './formGuard';
|
||||
|
||||
describe('useFormGuard', () => {
|
||||
let confirmSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error - we will mock confirm
|
||||
confirmSpy = vi.spyOn(window, 'confirm');
|
||||
cleanupFn = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupFn?.();
|
||||
cleanupFn = undefined;
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
// helper: simulate clicking an anchor
|
||||
const clickAnchor = (href: string, target?: string): MouseEvent => {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.setAttribute('href', href);
|
||||
if (target) anchor.setAttribute('target', target);
|
||||
document.body.appendChild(anchor);
|
||||
|
||||
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||
anchor.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
it('does not show confirm when form is clean', () => {
|
||||
useFormGuard(false, 'Leave?');
|
||||
clickAnchor('/some-path');
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows confirm with the given text when dirty', () => {
|
||||
confirmSpy.mockReturnValue(true);
|
||||
useFormGuard(true, 'Are you sure?');
|
||||
clickAnchor('/other-page');
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Are you sure?');
|
||||
});
|
||||
|
||||
it('prevents navigation when dirty and user cancels confirm', () => {
|
||||
confirmSpy.mockReturnValue(false);
|
||||
useFormGuard(true, 'Leave?');
|
||||
|
||||
const event = clickAnchor('/other-page');
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('allows navigation when dirty and user confirms', () => {
|
||||
confirmSpy.mockReturnValue(true);
|
||||
useFormGuard(true, 'Leave?');
|
||||
|
||||
const event = clickAnchor('/other-page');
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('external links', () => {
|
||||
it('does not show confirm for anchor with target="_blank"', () => {
|
||||
useFormGuard(true, 'Leave?');
|
||||
clickAnchor('https://example.com', '_blank');
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UnitTCMS use case', () => {
|
||||
it('does not show confirm when navigating to case detail page of test run', () => {
|
||||
const projectId = '1';
|
||||
const runId = '2';
|
||||
useFormGuard(true, 'Leave?', [`/projects/${projectId}/runs/${runId}/cases/\\d+`]);
|
||||
clickAnchor(`/projects/${projectId}/runs/${runId}/cases/123`);
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows confirm when navigating to test cases page', () => {
|
||||
const projectId = '1';
|
||||
const runId = '2';
|
||||
useFormGuard(true, 'Leave?', [`/projects/${projectId}/runs/${runId}/cases/\\d+`]);
|
||||
clickAnchor(`/projects/${projectId}/runs/${runId}/cases`);
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,19 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?: string[]) => {
|
||||
const isIgnoredPath = (href: string, compiledPatterns: RegExp[]): boolean => {
|
||||
return compiledPatterns.some((regex) => regex.test(href));
|
||||
};
|
||||
|
||||
export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePathPatterns?: string[]) => {
|
||||
useEffect(() => {
|
||||
const compiledPatterns: RegExp[] = (ignorePathPatterns ?? []).flatMap((pattern) => {
|
||||
try {
|
||||
return [new RegExp(pattern)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!isDirty) return;
|
||||
|
||||
@@ -13,7 +25,7 @@ export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?
|
||||
if (!href) return;
|
||||
|
||||
// do not show confirm for ignored paths
|
||||
if (ignorePaths && ignorePaths.some((path) => href.includes(path))) {
|
||||
if (isIgnoredPath(href, compiledPatterns)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,5 +50,5 @@ export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
window.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}, [confirmText, isDirty, ignorePaths]);
|
||||
}, [confirmText, isDirty, ignorePathPatterns]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user