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:
Copilot
2026-03-08 14:13:18 +09:00
committed by GitHub
parent 93e66d0122
commit 4cafcaeedc
4 changed files with 223 additions and 6 deletions

View 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();
});
});
});

View File

@@ -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]);
};