Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,6 +17,8 @@ export default function App() {
return (
<ErrorBoundary>
<LoginPromptProvider>
{/* 職務経歴書の未保存を全ページ横断で監視し、× 閉じ/リロード時の離脱確認を出す。 */}
<CareerUnsavedGuard isAuthenticated={user !== null} />
<AppRoutes
user={user}
authLoading={authLoading}
Expand Down
84 changes: 84 additions & 0 deletions frontend/src/components/CareerUnsavedGuard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { render } from "@testing-library/react";
import { Provider } from "react-redux";
import { configureStore } from "@reduxjs/toolkit";
import { describe, it, expect } from "vitest";

import { CareerUnsavedGuard } from "./CareerUnsavedGuard";
import formCacheReducer, { setBaseline, setCache } from "../store/formCacheSlice";
import { createInitialCareerForm } from "../formMappers";

/** formCache だけを持つ最小ストアを作る。 */
function makeStore() {
return configureStore({ reducer: { formCache: formCacheReducer } });
}

/** career キャッシュへ baseline と(必要なら編集後の)form をセットする。 */
function seedCareer(store: ReturnType<typeof makeStore>, 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(
<Provider store={store}>
<CareerUnsavedGuard isAuthenticated={true} />
</Provider>,
);
expect(dispatchBeforeUnload()).toBe(true);
});

it("未ログインなら未保存があっても抑止しない", () => {
const store = makeStore();
seedCareer(store, "山田 太郎");
render(
<Provider store={store}>
<CareerUnsavedGuard isAuthenticated={false} />
</Provider>,
);
expect(dispatchBeforeUnload()).toBe(false);
});

it("ログイン済みでも未保存が無ければ抑止しない", () => {
const store = makeStore();
seedCareer(store, ""); // form === baseline(どちらも空) → dirty なし
render(
<Provider store={store}>
<CareerUnsavedGuard isAuthenticated={true} />
</Provider>,
);
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(
<Provider store={store}>
<CareerUnsavedGuard isAuthenticated={true} />
</Provider>,
),
).not.toThrow();
expect(dispatchBeforeUnload()).toBe(false);
});
});
45 changes: 45 additions & 0 deletions frontend/src/components/CareerUnsavedGuard.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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;
}
8 changes: 0 additions & 8 deletions frontend/src/components/forms/CareerResumeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 モーダルのサイドバーと
* ハイライト突合に使う。
Expand Down
Loading