diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a7f00c76..078ce8b9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import ErrorBoundary from "./components/ErrorBoundary"; +import { CareerUnsavedGuard } from "./components/CareerUnsavedGuard"; import { LoginPromptProvider } from "./components/auth/LoginPromptProvider"; import { useAuthSession } from "./hooks/useAuthSession"; import { useTheme } from "./hooks/useTheme"; @@ -16,6 +17,8 @@ export default function App() { return ( + {/* 職務経歴書の未保存を全ページ横断で監視し、× 閉じ/リロード時の離脱確認を出す。 */} + , fullName: string) { + const baseline = createInitialCareerForm(); + store.dispatch(setBaseline({ key: "career", baseline })); + store.dispatch( + setCache({ + key: "career", + form: { ...createInitialCareerForm(), full_name: fullName }, + documentId: "resume-1", + }), + ); +} + +/** beforeunload を dispatch し、ハンドラが抑止したか(defaultPrevented)を返す。 */ +function dispatchBeforeUnload(): boolean { + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; +} + +describe("CareerUnsavedGuard", () => { + it("ログイン済みで未保存があると beforeunload を抑止する(別ページでも有効)", () => { + const store = makeStore(); + seedCareer(store, "山田 太郎"); // baseline は空 → full_name 差分で dirty + render( + + + , + ); + expect(dispatchBeforeUnload()).toBe(true); + }); + + it("未ログインなら未保存があっても抑止しない", () => { + const store = makeStore(); + seedCareer(store, "山田 太郎"); + render( + + + , + ); + expect(dispatchBeforeUnload()).toBe(false); + }); + + it("ログイン済みでも未保存が無ければ抑止しない", () => { + const store = makeStore(); + seedCareer(store, ""); // form === baseline(どちらも空) → dirty なし + render( + + + , + ); + expect(dispatchBeforeUnload()).toBe(false); + }); + + it("不正な形のキャッシュ値でもクラッシュせず、抑止しない(安全側に倒す)", () => { + const store = makeStore(); + // experiences / qualifications 配列を欠く壊れた値を流し込む(型ガードで弾けることを検証)。 + store.dispatch( + setCache({ key: "career", form: { full_name: "山田" } as never, documentId: "resume-1" }), + ); + expect(() => + render( + + + , + ), + ).not.toThrow(); + expect(dispatchBeforeUnload()).toBe(false); + }); +}); diff --git a/frontend/src/components/CareerUnsavedGuard.tsx b/frontend/src/components/CareerUnsavedGuard.tsx new file mode 100644 index 00000000..98bba53d --- /dev/null +++ b/frontend/src/components/CareerUnsavedGuard.tsx @@ -0,0 +1,45 @@ +import { useAppSelector } from "../store"; +import { createInitialCareerForm } from "../formMappers"; +import { useCareerDirty } from "../hooks/career/useCareerDirty"; +import { useUnsavedChangesWarning } from "../hooks/useUnsavedChangesWarning"; +import type { CareerFormState } from "../payloadBuilders"; + +/** + * formCache に入っている unknown 値が CareerFormState として安全に使える形か検証する。 + * useCareerDirty / buildClean は form.experiences.map(...) など配列・文字列フィールドへ + * ネストアクセスするため、最低限その先頭構造を確認してから渡す(不正値での実行時クラッシュを防ぐ)。 + */ +function isCareerFormState(value: unknown): value is CareerFormState { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return ( + typeof v.full_name === "string" && + typeof v.career_summary === "string" && + typeof v.self_pr === "string" && + Array.isArray(v.experiences) && + Array.isArray(v.qualifications) + ); +} + +/** + * 職務経歴書に未保存の変更があるとき、アプリのどのページ(職務経歴書 / GitHub連携 / + * ブログ連携 など)からでも × 閉じ・リロード時にブラウザ標準の離脱確認を出すためのガード。 + * + * 未保存判定を職務経歴書フォームのローカル state ではなく Redux の `formCache` から行うため、 + * フォームがアンマウントされていても(別ページへ移動していても)有効に働く。 + * 描画は行わない(null)ので、本コンポーネントの再描画は周辺ツリーに波及しない。 + * + * 対象はログイン済みのみ。未ログインのお試し入力は baseline が null(未ロード)で + * dirty.any が常に false になるため自然に対象外となる(入力は sessionStorage に自動退避される)。 + */ +export function CareerUnsavedGuard({ isAuthenticated }: { isAuthenticated: boolean }) { + const cache = useAppSelector((s) => s.formCache.career); + // 不正・欠損した値が入っていた場合は安全側(未保存なし)に倒す。 + const form = isCareerFormState(cache?.form) ? cache.form : createInitialCareerForm(); + const baseline = isCareerFormState(cache?.baseline) ? cache.baseline : null; + const dirty = useCareerDirty(form, baseline); + + useUnsavedChangesWarning(isAuthenticated && dirty.any); + + return null; +} diff --git a/frontend/src/components/forms/CareerResumeForm.tsx b/frontend/src/components/forms/CareerResumeForm.tsx index 7657d80c..8d109f31 100644 --- a/frontend/src/components/forms/CareerResumeForm.tsx +++ b/frontend/src/components/forms/CareerResumeForm.tsx @@ -18,7 +18,6 @@ import { useImportPanelLayout } from "../../hooks/career/useImportPanelLayout"; import { useResumeDiffPreview } from "../../hooks/career/useResumeDiffPreview"; import { useResumeImportAssist } from "../../hooks/career/useResumeImportAssist"; import { useDocumentForm } from "../../hooks/useDocumentForm"; -import { useUnsavedChangesWarning } from "../../hooks/useUnsavedChangesWarning"; import { clearCareerDraft, loadCareerDraft, saveCareerDraft } from "../../utils/careerDraft"; import { buildCareerPayload, validateCareerForm } from "../../payloadBuilders"; import type { CareerFieldLocator, CareerFormState } from "../../payloadBuilders"; @@ -139,13 +138,6 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean /** 未保存マーク(🔴)の表示判定に使う dirty マップ */ const dirty = useCareerDirty(form, baseline); - /** - * ログイン済みで未保存の変更があるとき、× 閉じ / リロード時にブラウザ標準の離脱確認を出す。 - * formCache は redux-persist の blacklist のためリロードで消える。未保存編集の消失を防ぐ。 - * 未ログインは baseline が null で dirty.any が常に false(=対象外)。入力は sessionStorage に自動退避される。 - */ - useUnsavedChangesWarning(isAuthenticated && dirty.any); - /** * baseline(保存済み)と form(編集中)の変更点リスト。左右 diff モーダルのサイドバーと * ハイライト突合に使う。