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'; 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(() => { useEffect(() => {
const compiledPatterns: RegExp[] = (ignorePathPatterns ?? []).flatMap((pattern) => {
try {
return [new RegExp(pattern)];
} catch {
return [];
}
});
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
if (!isDirty) return; if (!isDirty) return;
@@ -13,7 +25,7 @@ export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?
if (!href) return; if (!href) return;
// do not show confirm for ignored paths // do not show confirm for ignored paths
if (ignorePaths && ignorePaths.some((path) => href.includes(path))) { if (isIgnoredPath(href, compiledPatterns)) {
return; return;
} }
@@ -38,5 +50,5 @@ export const useFormGuard = (isDirty: boolean, confirmText: string, ignorePaths?
window.removeEventListener('beforeunload', handleBeforeUnload); window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('click', handleClick, true); window.removeEventListener('click', handleClick, true);
}; };
}, [confirmText, isDirty, ignorePaths]); }, [confirmText, isDirty, ignorePathPatterns]);
}; };

106
package-lock.json generated
View File

@@ -19,7 +19,9 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"express": "^4.21.0", "express": "^4.21.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"happy-dom": "^20.8.3",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"react": "^19.2.4",
"sequelize": "^6.37.4", "sequelize": "^6.37.4",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"supertest": "^7.1.4", "supertest": "^7.1.4",
@@ -50,10 +52,11 @@
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.25.9", "version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -1255,6 +1258,23 @@
"integrity": "sha512-2ipwZ2NydGQJImne+FhNdhgRM37e9lCev99KnqkbFHd94Xn/mErARWI1RSLem1QA19ch5kOhzIZd7e8CA2FI8g==", "integrity": "sha512-2ipwZ2NydGQJImne+FhNdhgRM37e9lCev99KnqkbFHd94Xn/mErARWI1RSLem1QA19ch5kOhzIZd7e8CA2FI8g==",
"dev": true "dev": true
}, },
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.29.1", "version": "8.29.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.1.tgz",
@@ -1289,6 +1309,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.1.tgz",
"integrity": "sha512-zczrHVEqEaTwh12gWBIJWj8nx+ayDcCJs06yoNMY0kwjMWDM6+kppljY+BxWI06d2Ja+h4+WdufDcwMnnMEWmg==", "integrity": "sha512-zczrHVEqEaTwh12gWBIJWj8nx+ayDcCJs06yoNMY0kwjMWDM6+kppljY+BxWI06d2Ja+h4+WdufDcwMnnMEWmg==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.29.1", "@typescript-eslint/scope-manager": "8.29.1",
"@typescript-eslint/types": "8.29.1", "@typescript-eslint/types": "8.29.1",
@@ -1838,6 +1859,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true, "dev": true,
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2938,6 +2960,19 @@
"once": "^1.4.0" "once": "^1.4.0"
} }
}, },
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-paths": { "node_modules/env-paths": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -3182,6 +3217,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.24.0.tgz",
"integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==", "integrity": "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -3361,6 +3397,7 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.8", "array-includes": "^3.1.8",
@@ -4220,6 +4257,25 @@
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"dev": true "dev": true
}, },
"node_modules/happy-dom": {
"version": "20.8.3",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.3.tgz",
"integrity": "sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.18.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/has-bigints": { "node_modules/has-bigints": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -6349,6 +6405,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -7498,6 +7564,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true, "dev": true,
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -7974,6 +8041,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz",
"integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
"dev": true, "dev": true,
"peer": true,
"dependencies": { "dependencies": {
"@vitest/expect": "1.6.1", "@vitest/expect": "1.6.1",
"@vitest/runner": "1.6.1", "@vitest/runner": "1.6.1",
@@ -8034,6 +8102,16 @@
} }
} }
}, },
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8184,6 +8262,28 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true "dev": true
}, },
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View File

@@ -25,7 +25,9 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"express": "^4.21.0", "express": "^4.21.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"happy-dom": "^20.8.3",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"react": "^19.2.4",
"sequelize": "^6.37.4", "sequelize": "^6.37.4",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"supertest": "^7.1.4", "supertest": "^7.1.4",