From 4da8ada2f5e280997438bbe7a39416f2a71f4af8 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Mon, 8 Jun 2026 22:01:34 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(career):=20=E6=9C=AA=E3=83=AD=E3=82=B0?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=81=AE=E3=81=8A=E8=A9=A6=E3=81=97=E5=85=A5?= =?UTF-8?q?=E5=8A=9B=E5=8B=95=E7=B7=9A=E3=81=A8=E3=82=B5=E3=82=A4=E3=83=89?= =?UTF-8?q?=E3=83=90=E3=83=BC/=E3=83=98=E3=83=83=E3=83=80=E3=83=BCUI?= =?UTF-8?q?=E3=82=92=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 未ログインでも職務経歴書を入力可能にし、保存/プレビュー/ダウンロード/ GitHub・ブログ連携への遷移時にログイン促進モーダルを表示する - 入力ドラフトを sessionStorage に退避し OAuth 往復後に復元(新規ユーザーは 自動保存、既存ユーザーはフォーム復元のみ)。useCareerDraftRestore / careerDraft を追加 - AuthenticatedLayout を SidebarLayout に統合し /career を認証有無の両対応に (未ログインは連携メニューを押下でログイン誘導、職務経歴書のみ入力可) - 未ログインのフッターは username 位置にログインボタン+▲メニュー (ダークモード切替 / Issue報告 / 著作権表示)を配置 - ヘッダーの保存/プレビュー/PDF出力/Markdown出力/削除をアイコン化 (日本語 aria-label を維持) - useDocumentForm に skipLoad を追加し save() を Promise 化 - 関連ユニットテスト(careerDraft / useCareerDraftRestore / LoginPromptModal / useDocumentForm)と認証フロー E2E を追加・更新 Co-Authored-By: Claude Opus 4.8 --- frontend/e2e/auth.spec.ts | 206 ++++++++++++++++-- frontend/src/App.module.css | 2 + frontend/src/App.tsx | 25 ++- .../src/components/AuthenticatedLayout.tsx | 156 ------------- frontend/src/components/SidebarLayout.tsx | 201 +++++++++++++++++ frontend/src/components/UserMenu.module.css | 55 +++++ frontend/src/components/UserMenu.tsx | 65 ++++-- .../auth/LoginPromptModal.module.css | 91 ++++++++ .../components/auth/LoginPromptModal.test.tsx | 44 ++++ .../src/components/auth/LoginPromptModal.tsx | 66 ++++++ .../components/auth/LoginPromptProvider.tsx | 21 ++ .../src/components/auth/loginPromptContext.ts | 20 ++ .../forms/CareerResumeForm.module.css | 24 ++ .../src/components/forms/CareerResumeForm.tsx | 187 ++++++++++++---- frontend/src/components/icons/EyeIcon.tsx | 4 +- .../src/components/icons/GitHubMarkIcon.tsx | 20 ++ .../components/icons/MarkdownDownloadIcon.tsx | 30 +++ .../src/components/icons/PdfDownloadIcon.tsx | 51 +++++ frontend/src/components/icons/SaveIcon.tsx | 19 ++ frontend/src/constants/messages.ts | 32 +++ .../career/useCareerDraftRestore.test.ts | 109 +++++++++ .../src/hooks/career/useCareerDraftRestore.ts | 73 +++++++ frontend/src/hooks/useDocumentForm.test.ts | 56 +++++ frontend/src/hooks/useDocumentForm.ts | 26 ++- frontend/src/pages/CareerPage.tsx | 8 +- frontend/src/router/routes.tsx | 54 ++++- frontend/src/styles.css | 2 +- frontend/src/utils/careerDraft.test.ts | 38 ++++ frontend/src/utils/careerDraft.ts | 44 ++++ 29 files changed, 1476 insertions(+), 253 deletions(-) delete mode 100644 frontend/src/components/AuthenticatedLayout.tsx create mode 100644 frontend/src/components/SidebarLayout.tsx create mode 100644 frontend/src/components/auth/LoginPromptModal.module.css create mode 100644 frontend/src/components/auth/LoginPromptModal.test.tsx create mode 100644 frontend/src/components/auth/LoginPromptModal.tsx create mode 100644 frontend/src/components/auth/LoginPromptProvider.tsx create mode 100644 frontend/src/components/auth/loginPromptContext.ts create mode 100644 frontend/src/components/icons/GitHubMarkIcon.tsx create mode 100644 frontend/src/components/icons/MarkdownDownloadIcon.tsx create mode 100644 frontend/src/components/icons/PdfDownloadIcon.tsx create mode 100644 frontend/src/components/icons/SaveIcon.tsx create mode 100644 frontend/src/hooks/career/useCareerDraftRestore.test.ts create mode 100644 frontend/src/hooks/career/useCareerDraftRestore.ts create mode 100644 frontend/src/utils/careerDraft.test.ts create mode 100644 frontend/src/utils/careerDraft.ts diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts index 91b20788..b50b1435 100644 --- a/frontend/e2e/auth.spec.ts +++ b/frontend/e2e/auth.spec.ts @@ -1,11 +1,28 @@ import { test, expect } from "@playwright/test"; +import { setupAuth, waitForAuthenticatedLayout } from "./helpers/auth"; + /** * 認証フロー E2E テスト + * + * 動線方針: 未ログインでも職務経歴書を入力でき(お試し入力)、保存時にログインを促す。 + * `/career` はログイン強制せず、`/login` は明示ログイン・OAuth エラー着地用に残す。 */ -test.describe("未認証ユーザー", () => { - test("ルートへのアクセスは /login へリダイレクトされる", async ({ page }) => { +/** localhost:8000 への全リクエストを 404 で返すキャッチオール(master-data 等を解決させる)。 */ +async function routeBackendNotFound(page: import("@playwright/test").Page) { + await page.route("http://localhost:8000/**", (route) => + route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ code: "NOT_FOUND", message: "not found" }), + }), + ); +} + +test.describe("未認証ユーザー(お試し入力)", () => { + test("ルート / は /login ではなく職務経歴書のお試し入力画面を表示する", async ({ page }) => { + await routeBackendNotFound(page); await page.route("**/auth/me", (route) => route.fulfill({ status: 401, @@ -15,13 +32,23 @@ test.describe("未認証ユーザー", () => { ); await page.goto("/"); - await page.waitForURL("**/login", { timeout: 5_000 }); - // ログインボタンが表示されることを確認する - await expect(page.getByRole("button", { name: "GitHubでログイン" })).toBeVisible(); + // /login へリダイレクトされず、職務経歴書フォームが表示される + await expect(page.getByRole("heading", { name: "職務経歴書" })).toBeVisible(); + await expect(page).toHaveURL(/\/career/); + // 氏名を入力できる(お試し入力) + await page.getByPlaceholder("例: 山田 太郎").fill("山田太郎"); + await expect(page.getByPlaceholder("例: 山田 太郎")).toHaveValue("山田太郎"); + + // サイドバー: ユーザーメニュー位置にログイントリガーが表示される + await expect(page.getByRole("button", { name: "ログイン" })).toBeVisible(); + // GitHub連携 / ブログ連携 は表示され、押下でログイン導線になる(非活性ではない) + await expect(page.getByRole("button", { name: "GitHub連携" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "ブログ連携" })).toBeEnabled(); }); - test("/career へ直接アクセスすると /login にリダイレクトされる", async ({ page }) => { + test("未ログインで連携メニューを押すとログイン促進モーダルが出る", async ({ page }) => { + await routeBackendNotFound(page); await page.route("**/auth/me", (route) => route.fulfill({ status: 401, @@ -31,14 +58,33 @@ test.describe("未認証ユーザー", () => { ); await page.goto("/career"); - await page.waitForURL("**/login", { timeout: 5_000 }); + await page.getByRole("button", { name: "GitHub連携" }).click(); + await expect( + page.getByRole("heading", { name: "ログインして保存しましょう" }), + ).toBeVisible(); + }); + + test("未ログインでプレビューを押すとログイン促進モーダルが出る", async ({ page }) => { + await routeBackendNotFound(page); + await page.route("**/auth/me", (route) => + route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ detail: "Unauthorized" }), + }), + ); - await expect(page.getByRole("button", { name: "GitHubでログイン" })).toBeVisible(); + await page.goto("/career"); + await page.getByRole("button", { name: "プレビュー" }).click(); + await expect( + page.getByRole("heading", { name: "ログインして保存しましょう" }), + ).toBeVisible(); }); - test("GitHubでログインボタンを押すと /auth/github/login-url が呼ばれる", async ({ page }) => { + test("未ログインで保存しようとするとログイン促進モーダルが出る", async ({ page }) => { let loginUrlRequest: string | null = null; + await routeBackendNotFound(page); await page.route("**/auth/me", (route) => route.fulfill({ status: 401, @@ -46,7 +92,6 @@ test.describe("未認証ユーザー", () => { body: JSON.stringify({ detail: "Unauthorized" }), }), ); - await page.route("**/auth/github/login-url*", (route) => { loginUrlRequest = route.request().url(); return route.fulfill({ @@ -58,19 +103,146 @@ test.describe("未認証ユーザー", () => { }), }); }); - - // mock OAuth URL への遷移を止める await page.route("**/mock-github-oauth*", (route) => + route.fulfill({ status: 200, contentType: "text/html", body: "" }), + ); + + await page.goto("/career"); + await page.getByPlaceholder("例: 山田 太郎").fill("山田太郎"); + await page.getByRole("button", { name: "保存する" }).click(); + + // ログイン促進モーダルが表示される + await expect( + page.getByRole("heading", { name: "ログインして保存しましょう" }), + ).toBeVisible(); + + // 入力ドラフトが sessionStorage に退避される(ログイン往復で失わないため) + expect( + await page.evaluate(() => sessionStorage.getItem("career_draft")), + ).toContain("山田太郎"); + + // モーダルの GitHub ログインで OAuth 開始 URL が呼ばれる + await page.getByRole("button", { name: "GitHubでログイン" }).click(); + await expect.poll(() => loginUrlRequest).not.toBeNull(); + expect(loginUrlRequest).toContain("/auth/github/login-url"); + expect(loginUrlRequest).toContain("return_to="); + }); + + test("退避ドラフトはログイン後に自動保存され失われない(新規ユーザー)", async ({ page }) => { + // 認証済み(サイドバー付き)状態をセットアップ。 + await setupAuth(page); + // API はホスト名非依存(Vite プロキシ経由のため)でモックする。 + // 未モックの /api/* が実バックエンドに到達して 401 を返すと _onUnauthorized で + // 匿名へ戻ってしまうため、必要なエンドポイントをすべて塞ぐ。 + await page.route("**/api/master-data/qualification", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "[]" }), + ); + await page.route("**/api/master-data/technology-stack", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "[]" }), + ); + // 新規ユーザー: 既存経歴書なし(404)。 + await page.route("**/api/resumes/latest", (route) => route.fulfill({ - status: 200, - contentType: "text/html", - body: "mock oauth", + status: 404, + contentType: "application/json", + body: JSON.stringify({ code: "NOT_FOUND", message: "not found" }), + }), + ); + // 自動保存の POST を捕捉する。 + let createdName: string | null = null; + await page.route("**/api/resumes", (route) => { + if (route.request().method() === "POST") { + createdName = route.request().postDataJSON()?.full_name ?? null; + return route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ + id: "new-resume-id", + full_name: createdName ?? "", + career_summary: "", + self_pr: "", + experiences: [], + qualifications: [], + }), + }); + } + return route.fulfill({ status: 404, contentType: "application/json", body: "{}" }); + }); + + // ログイン往復から復帰した状態を再現するため、アプリ起動前にドラフトを sessionStorage へ仕込む。 + await page.addInitScript(() => { + sessionStorage.setItem( + "career_draft", + JSON.stringify({ + full_name: "山田太郎", + career_summary: "テスト要約", + self_pr: "テスト自己PR", + experiences: [ + { + company: "株式会社テスト", + business_description: "受託開発", + start_date: "2020-04", + end_date: null, + is_current: true, + is_it_company: true, + description: "", + employee_count: "100", + capital: "1", + capital_unit: "万円", + clients: [], + }, + ], + qualifications: [], + }), + ); + }); + + await page.goto("/career"); + await waitForAuthenticatedLayout(page); + // サイドバーのナビゲーション(認証済みレイアウト専用)が出ていることを確認する。 + await expect(page.getByRole("link", { name: "職務経歴書" })).toBeVisible(); + + // 退避ドラフトが自動保存(POST)され、入力した氏名が送られる。 + await expect.poll(() => createdName).toBe("山田太郎"); + // フォームにも氏名が反映され、入力は失われていない。 + await expect(page.getByPlaceholder("例: 山田 太郎")).toHaveValue("山田太郎"); + // 退避領域は消費後に破棄される。 + await expect + .poll(() => page.evaluate(() => sessionStorage.getItem("career_draft"))) + .toBeNull(); + }); +}); + +test.describe("ログイン画面", () => { + test("/login の GitHubでログインボタンを押すと /auth/github/login-url が呼ばれる", async ({ + page, + }) => { + let loginUrlRequest: string | null = null; + + await routeBackendNotFound(page); + await page.route("**/auth/me", (route) => + route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ detail: "Unauthorized" }), }), ); + await page.route("**/auth/github/login-url*", (route) => { + loginUrlRequest = route.request().url(); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + authorization_url: "http://localhost:5173/mock-github-oauth", + state: "mock-state", + }), + }); + }); + await page.route("**/mock-github-oauth*", (route) => + route.fulfill({ status: 200, contentType: "text/html", body: "" }), + ); await page.goto("/login"); - await page.waitForURL("**/login", { timeout: 5_000 }); - await page.getByRole("button", { name: "GitHubでログイン" }).click(); await expect.poll(() => loginUrlRequest).not.toBeNull(); expect(loginUrlRequest).toContain("/auth/github/login-url"); diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css index 05eb2765..81134c40 100644 --- a/frontend/src/App.module.css +++ b/frontend/src/App.module.css @@ -128,6 +128,7 @@ padding-left: calc(1.2rem - 3px); } + /* ── GitHub連携: 展開グループ(フォーク含むオプション)──────────── */ .sidebarItemGroup { @@ -195,6 +196,7 @@ border-top: 1px solid var(--sidebar-border); } + .mainContent { flex: 1; margin-left: var(--sidebar-width); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9b870471..a7f00c76 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import ErrorBoundary from "./components/ErrorBoundary"; +import { LoginPromptProvider } from "./components/auth/LoginPromptProvider"; import { useAuthSession } from "./hooks/useAuthSession"; import { useTheme } from "./hooks/useTheme"; import { AppRoutes } from "./router"; @@ -14,17 +15,19 @@ export default function App() { return ( - { - void handleLogout(); - }} - onLoginSuccess={handleLoginSuccess} - /> + + { + void handleLogout(); + }} + onLoginSuccess={handleLoginSuccess} + /> + ); } diff --git a/frontend/src/components/AuthenticatedLayout.tsx b/frontend/src/components/AuthenticatedLayout.tsx deleted file mode 100644 index 2bdfa7d5..00000000 --- a/frontend/src/components/AuthenticatedLayout.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useState } from "react"; -import { NavLink, Outlet, useNavigate } from "react-router-dom"; - -import type { AuthUser } from "../router/guards"; -import type { Theme } from "../hooks/useTheme"; -import { UI_MESSAGES } from "../constants/messages"; -import { NotificationBell } from "./NotificationBell"; -import { UserMenu } from "./UserMenu"; -import { ChevronDownIcon } from "./icons/ChevronDownIcon"; -import shared from "../styles/shared.module.css"; -import styles from "../App.module.css"; - -/** - * 認証済みユーザー向けのサイドバー付きレイアウト。 - * PrivateRoute でガードされた後にのみレンダリングされるため、user は非 null。 - */ -export function AuthenticatedLayout({ - user, - theme, - onToggleTheme, - onLogout, -}: { - user: AuthUser; - theme: Theme; - onToggleTheme: () => void; - onLogout: () => void; -}) { - const navigate = useNavigate(); - // GitHub 連携オプション(フォーク含む)の開閉とチェック状態。 - const [githubOptionsOpen, setGithubOptionsOpen] = useState(false); - const [includeForks, setIncludeForks] = useState(false); - // サイドバーの折りたたみ状態。折りたたむと本文領域が全幅に広がる。 - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - - /** - * GitHub 連携を実行する。 - * 連携 API のトリガーはサブパネル内の「連携実行」ボタンのみ。 - * サイドバー項目のクリックは画面遷移に徹し、連携リクエストは飛ばさない - * (不要なリクエストの温床になるため)。 - * 実行意図を runNonce としてページへ渡し、ダッシュボード側で - * 連携実行(ポーリング)とエラー表示を担わせる。 - */ - const triggerGitHubLink = () => { - navigate("/github_link", { - state: { runNonce: Date.now(), includeForks }, - }); - }; - - return ( -
-
- {/* 折りたたみ中のみ表示する、サイドバーを再展開するための固定ボタン。 */} - {sidebarCollapsed && ( - - )} - - -
- -
-
-
- ); -} diff --git a/frontend/src/components/SidebarLayout.tsx b/frontend/src/components/SidebarLayout.tsx new file mode 100644 index 00000000..0a02753b --- /dev/null +++ b/frontend/src/components/SidebarLayout.tsx @@ -0,0 +1,201 @@ +import { useState } from "react"; +import type { ReactNode } from "react"; +import { NavLink, Outlet, useNavigate } from "react-router-dom"; + +import { initiateGitHubLogin } from "../api"; +import type { AuthUser } from "../router/guards"; +import type { Theme } from "../hooks/useTheme"; +import { AUTH_PROMPT_MESSAGES, UI_MESSAGES } from "../constants/messages"; +import { NotificationBell } from "./NotificationBell"; +import { UserMenu } from "./UserMenu"; +import { useLoginPrompt } from "./auth/loginPromptContext"; +import { ChevronDownIcon } from "./icons/ChevronDownIcon"; +import shared from "../styles/shared.module.css"; +import styles from "../App.module.css"; + +/** + * サイドバー付きの共通レイアウト。認証状態に応じて中身を出し分ける。 + * + * - 認証済み (`user` 非 null): 全ナビゲーションを活性化し、フッターに通知ベル + ユーザーメニュー。 + * レイアウトルートとして使う場合は `children` を渡さず `` を描画する。 + * - 未認証 (`user` === null): 職務経歴書のお試し入力のみ可能。GitHub連携 / ブログ連携は + * 表示するが、押下するとログイン促進モーダルを開く。フッターはユーザーメニュー(ログインモード)で、 + * トリガーが「ログイン」、メニューにダークモード / ログイン / Issue報告 / 著作権表示を出す + * (通知ベルは出さない)。 + */ +export function SidebarLayout({ + user, + theme, + onToggleTheme, + onLogout, + children, +}: { + user: AuthUser | null; + theme: Theme; + onToggleTheme: () => void; + onLogout: () => void; + children?: ReactNode; +}) { + const navigate = useNavigate(); + const requestLogin = useLoginPrompt(); + // GitHub 連携オプション(フォーク含む)の開閉とチェック状態。 + const [githubOptionsOpen, setGithubOptionsOpen] = useState(false); + const [includeForks, setIncludeForks] = useState(false); + // サイドバーの折りたたみ状態。折りたたむと本文領域が全幅に広がる。 + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + + const isAuthenticated = user !== null; + + /** + * GitHub 連携を実行する。 + * 連携 API のトリガーはサブパネル内の「連携実行」ボタンのみ。 + * サイドバー項目のクリックは画面遷移に徹し、連携リクエストは飛ばさない + * (不要なリクエストの温床になるため)。 + * 実行意図を runNonce としてページへ渡し、ダッシュボード側で + * 連携実行(ポーリング)とエラー表示を担わせる。 + */ + const triggerGitHubLink = () => { + navigate("/github_link", { + state: { runNonce: Date.now(), includeForks }, + }); + }; + + const handleLogin = () => { + void initiateGitHubLogin(window.location.origin); + }; + + return ( +
+
+ {/* 折りたたみ中のみ表示する、サイドバーを再展開するための固定ボタン。 */} + {sidebarCollapsed && ( + + )} + + +
{children ?? }
+
+
+ ); +} diff --git a/frontend/src/components/UserMenu.module.css b/frontend/src/components/UserMenu.module.css index 7562eae9..e4f6850d 100644 --- a/frontend/src/components/UserMenu.module.css +++ b/frontend/src/components/UserMenu.module.css @@ -39,6 +39,61 @@ opacity: 0.6; } +/* 未認証フッター: ログインボタン + ▲ メニュー開閉ボタンを横並びにするコンテナ。 */ +.guestTrigger { + display: flex; + align-items: stretch; + gap: 0.4rem; + padding: 0 0.2rem; +} + +/* ユーザー名位置のログインボタン(GitHub ログイン開始)。 */ +.loginButton { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: var(--github-bg); + color: #fff; + border: none; + border-radius: 6px; + padding: 0.5rem 0.6rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} + +.loginButton:hover { + background: var(--github-hover); +} + +.loginIcon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +/* ▲ メニュー(ダークモード / Issue報告 / 著作権)開閉ボタン。 */ +.guestChevron { + flex-shrink: 0; + background: none; + border: none; + border-radius: 6px; + padding: 0 0.5rem; + color: var(--sidebar-username); + font-size: 0.7rem; + opacity: 0.7; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; +} + +.guestChevron:hover { + background: var(--sidebar-hover-bg); + opacity: 1; +} + .menu { position: absolute; bottom: calc(100% + 0.4rem); diff --git a/frontend/src/components/UserMenu.tsx b/frontend/src/components/UserMenu.tsx index 1cfc1175..44b4955c 100644 --- a/frontend/src/components/UserMenu.tsx +++ b/frontend/src/components/UserMenu.tsx @@ -1,19 +1,30 @@ import { useEffect, useRef, useState } from "react"; -import { EXTERNAL_LINKS, UI_MESSAGES } from "../constants/messages"; +import { AUTH_PROMPT_MESSAGES, EXTERNAL_LINKS, UI_MESSAGES } from "../constants/messages"; import type { Theme } from "../hooks/useTheme"; +import { GitHubMarkIcon } from "./icons/GitHubMarkIcon"; import styles from "./UserMenu.module.css"; +/** + * サイドバーフッターのユーザーメニュー。 + * 認証済み: ユーザー名トリガー(押下でメニュー開閉)+ ダークモード / ログアウト / Issue報告 / コピーライト。 + * 未認証: ユーザー名位置に「ログイン」ボタン(押下で OAuth 開始)+ 右に ▲ チェブロン(押下でメニュー開閉)。 + * メニューはダークモード / Issue報告 / コピーライト(ログアウト/ログイン項目は出さない)。 + */ export function UserMenu({ + isAuthenticated, username, theme, onToggleTheme, onLogout, + onLogin, }: { + isAuthenticated: boolean; username: string | null; theme: Theme; onToggleTheme: () => void; onLogout: () => void; + onLogin: () => void; }) { const [open, setOpen] = useState(false); const ref = useRef(null); @@ -42,14 +53,19 @@ export function UserMenu({ ); } diff --git a/frontend/src/components/auth/LoginPromptModal.module.css b/frontend/src/components/auth/LoginPromptModal.module.css new file mode 100644 index 00000000..6bb710e5 --- /dev/null +++ b/frontend/src/components/auth/LoginPromptModal.module.css @@ -0,0 +1,91 @@ +.overlay { + position: fixed; + inset: 0; + background: var(--overlay-bg); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.dialog { + background: var(--bg-card); + color: var(--text-primary); + border-radius: 12px; + padding: 2rem 1.8rem; + width: 90vw; + max-width: 420px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25); + text-align: center; +} + +.title { + margin: 0 0 0.8rem; + font-size: 1.3rem; + font-weight: 700; +} + +.description { + margin: 0 0 0.5rem; + font-size: 0.95rem; + line-height: 1.6; + color: var(--text-secondary); +} + +.draftKept { + margin: 0 0 1.5rem; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.actions { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.githubLogin { + background: var(--github-bg); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; +} + +.githubLogin:hover { + background: var(--github-hover); +} + +.githubIcon { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.later { + background: none; +} + +.loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 1.5rem 0; +} + +.loading p { + margin: 0; + font-weight: 600; +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid var(--spinner-track); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} diff --git a/frontend/src/components/auth/LoginPromptModal.test.tsx b/frontend/src/components/auth/LoginPromptModal.test.tsx new file mode 100644 index 00000000..9a6725d7 --- /dev/null +++ b/frontend/src/components/auth/LoginPromptModal.test.tsx @@ -0,0 +1,44 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { AUTH_PROMPT_MESSAGES } from "../../constants/messages"; +import { LoginPromptModal } from "./LoginPromptModal"; + +// initiateGitHubLogin は実際に window.location.assign する副作用があるためモックする。 +const initiateGitHubLogin = vi.fn<() => Promise>().mockResolvedValue(undefined); +vi.mock("../../api", () => ({ + initiateGitHubLogin: (...args: unknown[]) => initiateGitHubLogin(...(args as [])), +})); + +describe("LoginPromptModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("タイトルと説明を表示する", () => { + render( {}} />); + + expect(screen.getByText(AUTH_PROMPT_MESSAGES.TITLE)).toBeInTheDocument(); + expect(screen.getByText(AUTH_PROMPT_MESSAGES.DESCRIPTION)).toBeInTheDocument(); + }); + + it("GitHubログインボタンで initiateGitHubLogin を呼ぶ", async () => { + render( {}} />); + + fireEvent.click( + screen.getByRole("button", { name: AUTH_PROMPT_MESSAGES.GITHUB_LOGIN }), + ); + + await waitFor(() => { + expect(initiateGitHubLogin).toHaveBeenCalledWith(window.location.origin); + }); + }); + + it("「あとで」で onClose を呼ぶ", () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: AUTH_PROMPT_MESSAGES.LATER })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/auth/LoginPromptModal.tsx b/frontend/src/components/auth/LoginPromptModal.tsx new file mode 100644 index 00000000..1104be07 --- /dev/null +++ b/frontend/src/components/auth/LoginPromptModal.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; + +import { initiateGitHubLogin } from "../../api"; +import { AUTH_PROMPT_MESSAGES } from "../../constants/messages"; +import { GitHubMarkIcon } from "../icons/GitHubMarkIcon"; +import styles from "./LoginPromptModal.module.css"; + +/** + * 未ログインで職務経歴書を保存しようとしたときに表示するログイン促進モーダル。 + * + * 入力済みドラフトは呼び出し側で sessionStorage に退避済み。ここではログインへ誘導するだけで、 + * GitHub OAuth へリダイレクトする。往復後 `/career` に戻ると useCareerDraftRestore がドラフトを復元する。 + */ +export function LoginPromptModal({ onClose }: { onClose: () => void }) { + const [isLoading, setIsLoading] = useState(false); + + const handleGitHubLogin = async () => { + setIsLoading(true); + try { + await initiateGitHubLogin(window.location.origin); + } catch { + // 開始に失敗したらボタンを再操作可能に戻す(エラーはリダイレクト前のため握って良い)。 + setIsLoading(false); + } + }; + + return ( +
+
e.stopPropagation()} + > + {isLoading ? ( +
+
+

{AUTH_PROMPT_MESSAGES.REDIRECTING}

+
+ ) : ( + <> +

{AUTH_PROMPT_MESSAGES.TITLE}

+

{AUTH_PROMPT_MESSAGES.DESCRIPTION}

+

{AUTH_PROMPT_MESSAGES.DRAFT_KEPT}

+
+ + +
+ + )} +
+
+ ); +} diff --git a/frontend/src/components/auth/LoginPromptProvider.tsx b/frontend/src/components/auth/LoginPromptProvider.tsx new file mode 100644 index 00000000..4e335099 --- /dev/null +++ b/frontend/src/components/auth/LoginPromptProvider.tsx @@ -0,0 +1,21 @@ +import { useCallback, useState } from "react"; +import type { ReactNode } from "react"; + +import { LoginPromptModal } from "./LoginPromptModal"; +import { LoginPromptContext } from "./loginPromptContext"; + +/** + * ログイン促進モーダルを 1 つだけ保持し、配下のどこからでも `useLoginPrompt()` で開けるようにする Provider。 + * サイドバー(SidebarLayout)とお試し入力フォーム(CareerResumeForm)の双方が同じモーダルを共有する。 + */ +export function LoginPromptProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const requestLogin = useCallback(() => setOpen(true), []); + + return ( + + {children} + {open && setOpen(false)} />} + + ); +} diff --git a/frontend/src/components/auth/loginPromptContext.ts b/frontend/src/components/auth/loginPromptContext.ts new file mode 100644 index 00000000..9a4fa8a7 --- /dev/null +++ b/frontend/src/components/auth/loginPromptContext.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from "react"; + +/** + * ログイン促進モーダルを開く関数を配布するコンテキスト。 + * 未ログインで要ログイン機能(プレビュー / ダウンロード / 連携への遷移など)を使おうとしたときに、 + * サイドバーとお試し入力フォームの双方から同じ 1 つのモーダルを開くために使う。 + * + * Provider 本体は {@link ../auth/LoginPromptProvider} を参照。 + */ +export const LoginPromptContext = createContext<(() => void) | null>(null); + +/** ログイン促進モーダルを開く関数を取得する。Provider 配下でのみ利用できる。 */ +export function useLoginPrompt(): () => void { + const ctx = useContext(LoginPromptContext); + if (!ctx) { + // 配線ミスを握りつぶさず即座に気付けるようにする(開発者向け内部エラー)。 + throw new Error("useLoginPrompt must be used within a LoginPromptProvider"); + } + return ctx; +} diff --git a/frontend/src/components/forms/CareerResumeForm.module.css b/frontend/src/components/forms/CareerResumeForm.module.css index 87042523..469ce8a7 100644 --- a/frontend/src/components/forms/CareerResumeForm.module.css +++ b/frontend/src/components/forms/CareerResumeForm.module.css @@ -1,3 +1,27 @@ +/* ヘッダーのアイコンボタン(PDF出力 / Markdown出力)。アイコンを中央寄せする。 */ +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.headerIcon { + width: 18px; + height: 18px; + display: block; +} + +/* PDF 出力中のローディングスピナー(アイコンと同サイズ)。spin は styles.css のグローバル定義。 */ +.buttonSpinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--spinner-track); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + .inputWithUnit { display: flex; align-items: center; diff --git a/frontend/src/components/forms/CareerResumeForm.tsx b/frontend/src/components/forms/CareerResumeForm.tsx index 23dc70f2..8d109f31 100644 --- a/frontend/src/components/forms/CareerResumeForm.tsx +++ b/frontend/src/components/forms/CareerResumeForm.tsx @@ -1,4 +1,4 @@ -import { CSSProperties, FormEvent, useCallback, useMemo, useRef, useState } from "react"; +import { CSSProperties, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Dispatch, SetStateAction } from "react"; import { @@ -10,13 +10,15 @@ import { getLatestCareerResume, updateCareerResume, } from "../../api"; -import { SUCCESS_MESSAGES, UI_MESSAGES } from "../../constants/messages"; +import { AUTH_PROMPT_MESSAGES, SUCCESS_MESSAGES, UI_MESSAGES } from "../../constants/messages"; import { createInitialCareerForm, mapCareerResumeToForm } from "../../formMappers"; import { useCareerDirty } from "../../hooks/career/useCareerDirty"; +import { useCareerDraftRestore } from "../../hooks/career/useCareerDraftRestore"; import { useImportPanelLayout } from "../../hooks/career/useImportPanelLayout"; import { useResumeDiffPreview } from "../../hooks/career/useResumeDiffPreview"; import { useResumeImportAssist } from "../../hooks/career/useResumeImportAssist"; import { useDocumentForm } from "../../hooks/useDocumentForm"; +import { clearCareerDraft, loadCareerDraft, saveCareerDraft } from "../../utils/careerDraft"; import { buildCareerPayload, validateCareerForm } from "../../payloadBuilders"; import type { CareerFieldLocator, CareerFormState } from "../../payloadBuilders"; import { buildCareerChanges } from "../../utils/careerDiff"; @@ -26,9 +28,15 @@ import { usePdfActions } from "../../hooks/usePdfActions"; import { useMessageToast } from "../ui/toast"; import shared from "../../styles/shared.module.css"; import { ConfirmDialog } from "../ConfirmDialog"; +import { useLoginPrompt } from "../auth/loginPromptContext"; import { CareerDiffModal } from "./CareerDiffModal"; import { MarkdownFieldModal } from "./MarkdownFieldModal"; import { Skeleton } from "../ui/Skeleton"; +import { SaveIcon } from "../icons/SaveIcon"; +import { EyeIcon } from "../icons/EyeIcon"; +import { TrashIcon } from "../icons/TrashIcon"; +import { PdfDownloadIcon } from "../icons/PdfDownloadIcon"; +import { MarkdownDownloadIcon } from "../icons/MarkdownDownloadIcon"; import { PdfPreviewModal } from "./PdfPreviewModal"; import { ResumeSourceTracePanel } from "./ResumeSourceTracePanel"; import layout from "./CareerResumeForm.module.css"; @@ -37,7 +45,9 @@ import { CareerExperienceSection } from "./sections/CareerExperienceSection"; import { CareerQualificationsSection } from "./sections/CareerQualificationsSection"; import { CareerSelfPrSection } from "./sections/CareerSelfPrSection"; -export function CareerResumeForm() { +export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean }) { + // 未ログインで要ログイン機能を使おうとしたときに開く共通モーダル。 + const requestLogin = useLoginPrompt(); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); // 保存時の変更点確認ダイアログの表示状態。 const [showSaveConfirm, setShowSaveConfirm] = useState(false); @@ -48,6 +58,17 @@ export function CareerResumeForm() { const assist = useResumeImportAssist(); const splitRef = useRef(null); const { width: pdfWidth, startResize } = useImportPanelLayout(splitRef); + + /** + * 初期フォーム。未ログインのお試し入力では、退避ドラフトがあればそれを初期値にする + * (リロードやログイン往復後も入力を失わない。ドラフトは下の effect で常に最新化する)。 + * 認証済みでは空フォーム(サーバ最新は useDocumentForm の loadLatest が反映する)。 + */ + const createInitialForm = useCallback( + () => (isAuthenticated ? createInitialCareerForm() : loadCareerDraft() ?? createInitialCareerForm()), + [isAuthenticated], + ); + const { form, setForm, @@ -62,7 +83,7 @@ export function CareerResumeForm() { deleteDoc, saveButtonText, } = useDocumentForm({ - createInitialForm: createInitialCareerForm, + createInitialForm, loadLatest: getLatestCareerResume, createDocument: createCareerResume, updateDocument: updateCareerResume, @@ -71,8 +92,38 @@ export function CareerResumeForm() { mapResponseToForm: mapCareerResumeToForm, successMessage: SUCCESS_MESSAGES.CAREER_SAVED, cacheKey: "career", + // 未ログインのお試し入力では loadLatest を行わず空フォームで起動する(401 を無駄打ちしない)。 + skipLoad: !isAuthenticated, + }); + + // ログイン後(往復から復帰)に退避ドラフトを復元する情報トースト用メッセージ。 + const [restoreMessage, setRestoreMessage] = useState(null); + + // 未ログイン入力 → ログイン往復してきたユーザーの退避ドラフトをフォームへ橋渡しする。 + useCareerDraftRestore({ + isAuthenticated, + loading, + documentId: resumeId, + setForm, + save, + notifyRestored: () => setRestoreMessage(AUTH_PROMPT_MESSAGES.DRAFT_RESTORED), }); + /** + * 未ログイン時は入力内容を常に sessionStorage に退避し続ける。 + * これによりプレビュー/ダウンロード/連携メニューなど任意のログイン導線を踏んでも + * 入力が失われず、ログイン往復後に復元できる。氏名が空のうちは意味のあるドラフトでないため退避しない + * (空ドラフトでログイン後に自動保存が空振りするのを防ぐ)。 + */ + useEffect(() => { + if (isAuthenticated) return; + if (form.full_name.trim()) { + saveCareerDraft(form); + } else { + clearCareerDraft(); + } + }, [isAuthenticated, form]); + /** * 保存前バリデーション(項目バリデーション)のメッセージ。 * 保存/削除/PDF などの非同期処理の成否はトーストで通知するが、 @@ -118,14 +169,12 @@ export function CareerResumeForm() { getPdfBlobUrl: getCareerResumePdfBlobUrl, }); - // PDF アクションとフォーム保存/削除の成否を、チャンネルごとに独立してトーストで通知する。 - // pdf と form を `??` で統合すると、片方の値が残っている間にもう片方が更新されても - // 統合値が変化せずトーストが出ないため、それぞれ個別に橋渡しする。 - // 成功は自動消去、失敗は手動クローズ(ブリッジ内で variant 別に制御)。 useMessageToast(formSuccess, "success"); useMessageToast(formError, "error"); useMessageToast(pdfSuccess, "success"); useMessageToast(pdfError, "error"); + // ログイン後のドラフト復元(既存経歴書あり)を通知する情報トースト。 + useMessageToast(restoreMessage, "success"); /** Skeleton 表示・入力ロックの統合フラグ */ const formLocked = loading; @@ -160,22 +209,44 @@ export function CareerResumeForm() { setForm((prev) => ({ ...prev, [key]: value })); }; + /** バリデーション失敗を画面へ反映する(メッセージ・フォーカス・モーダル自動展開)。 */ + const applyValidationError = (validation: NonNullable>) => { + setValidationError(validation.message); + focusNonceRef.current += 1; + setFocusTarget({ locator: validation.locator, nonce: focusNonceRef.current }); + // 自己PR / 職務要約はモーダルへ逃がしているため、該当フィールドの失敗時はモーダルを自動で開く + // (隠れた textarea には直接フォーカスできないため)。 + if ( + validation.locator.kind === "career_summary" || + validation.locator.kind === "self_pr" + ) { + setEditingField(validation.locator.kind); + } + }; + const onSubmit = (event: FormEvent) => { event.preventDefault(); + + // 未ログインのお試し入力: 全項目の入力完了は求めず(カジュアルな体験を優先)、 + // 氏名だけ確認して(空の経歴書でログインさせない)ドラフトを退避し、ログインを促す。 + // 残りの項目検証はログイン後の実保存時にサーバ側で行う。 + if (!isAuthenticated) { + if (!form.full_name.trim()) { + const validation = validateCareerForm(form); + if (validation) applyValidationError(validation); + return; + } + setValidationError(null); + setFocusTarget(null); + // 入力内容は effect で sessionStorage に退避済み。ログインを促す。 + requestLogin(); + return; + } + // 保存前にフォーム全体を検証し、最初のエラーフィールドへフォーカスする。 const validation = validateCareerForm(form); if (validation) { - setValidationError(validation.message); - focusNonceRef.current += 1; - setFocusTarget({ locator: validation.locator, nonce: focusNonceRef.current }); - // 自己PR / 職務要約はモーダルへ逃がしているため、該当フィールドの失敗時はモーダルを自動で開く - // (隠れた textarea には直接フォーカスできないため)。 - if ( - validation.locator.kind === "career_summary" || - validation.locator.kind === "self_pr" - ) { - setEditingField(validation.locator.kind); - } + applyValidationError(validation); return; } setValidationError(null); @@ -202,6 +273,27 @@ export function CareerResumeForm() { setShowDeleteConfirm(false); }; + /** + * 要ログイン機能(プレビュー / PDF / Markdown 出力)のハンドラ。 + * 未ログインならログイン促進モーダルを開き、ログイン済みなら本来の処理を行う。 + * 入力中ドラフトは effect で sessionStorage に退避済みのため、ログイン往復後も失われない。 + */ + const handlePreview = () => { + if (!isAuthenticated) return requestLogin(); + if (resumeId) onPreviewPdf(resumeId); + }; + const handleDownloadPdf = () => { + if (!isAuthenticated) return requestLogin(); + if (resumeId) onDownloadPdf(resumeId, SUCCESS_MESSAGES.CAREER_PDF_DOWNLOADED); + }; + const handleDownloadMarkdown = () => { + if (!isAuthenticated) return requestLogin(); + if (resumeId) onDownloadMarkdown(resumeId); + }; + // 未ログインでは要ログイン機能ボタンを活性にしてログイン導線にする。 + // ログイン済みでは保存済み(resumeId あり)まで非活性。 + const exportDisabled = formLocked || (isAuthenticated && !resumeId); + return ( <> {showDeleteConfirm && ( @@ -250,48 +342,67 @@ export function CareerResumeForm() {

職務経歴書

{/* ファイル取り込みは右カラムの原本ビュー(ドラッグ&ドロップ / クリック)に集約。 */} -
- {/* 左=入力フォーム / 右=PDF 原本ビュー。スプリッターで左右リサイズする。 - splitWrap はコンテナクエリの基準(= split に割り当てられる実幅)。 - 横幅が足りない時は split を縦積みに切り替える。PDF カラム幅は CSS 変数で渡し、 - 縦積み時は CSS 側で全幅に上書きする(inline style だと上書きできないため変数経由)。 */}
)} @@ -155,7 +155,7 @@ export function SidebarLayout({ onClick={requestLogin} title={AUTH_PROMPT_MESSAGES.LOGIN_REQUIRED_HINT} > - GitHub連携 + {UI_MESSAGES.NAV_GITHUB_LINK} )} @@ -167,7 +167,7 @@ export function SidebarLayout({ `${styles.sidebarItem} ${isActive ? styles.active : ""}` } > - ブログ連携 + {UI_MESSAGES.NAV_BLOG_LINK} ) : ( )} diff --git a/frontend/src/components/UserMenu.tsx b/frontend/src/components/UserMenu.tsx index 44b4955c..c9e8ee13 100644 --- a/frontend/src/components/UserMenu.tsx +++ b/frontend/src/components/UserMenu.tsx @@ -47,7 +47,7 @@ export function UserMenu({ {open && (
@@ -98,7 +98,7 @@ export function UserMenu({ )} {isAuthenticated ? ( ) : ( diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts index 1800b662..14ff1b87 100644 --- a/frontend/src/constants/messages.ts +++ b/frontend/src/constants/messages.ts @@ -114,6 +114,19 @@ export const UI_MESSAGES = { SIDEBAR_EXPAND: "サイドバーを開く", // 未認証フッターの ▲ チェブロン(ダークモード / Issue報告 / 著作権メニュー)の aria-label FOOTER_MENU: "メニュー", + // サイドバーのナビゲーション項目 + NAV_CAREER: "職務経歴書", + NAV_GITHUB_LINK: "GitHub連携", + NAV_BLOG_LINK: "ブログ連携", + // GitHub連携サブパネル + GITHUB_LINK_OPTIONS: "GitHub連携オプション", + GITHUB_LINK_RUN: "連携実行", + GITHUB_INCLUDE_FORKS: "フォークしたリポジトリを含む", + // ユーザーメニュー(フッター) + DARK_MODE: "ダークモード", + LOGOUT: "ログアウト", + // 認証済みだがユーザー名が取得できない場合のフォールバック表示 + MENU_FALLBACK: "Menu", SOURCE_PANEL_COLLAPSE: "原本パネルを閉じる", SOURCE_PANEL_EXPAND: "原本パネルを開く", // 入力モーダルの × 閉じるボタン(aria-label) diff --git a/frontend/src/utils/careerDraft.test.ts b/frontend/src/utils/careerDraft.test.ts index d1632722..0697f89b 100644 --- a/frontend/src/utils/careerDraft.test.ts +++ b/frontend/src/utils/careerDraft.test.ts @@ -35,4 +35,13 @@ describe("careerDraft", () => { expect(loadCareerDraft()).toBeNull(); expect(sessionStorage.getItem("career_draft")).toBeNull(); }); + + it("形が不正なドラフト(旧フォーマット等)は null を返し破棄する", () => { + // パースは成功するが CareerFormState の必須形(experiences 配列等)を満たさない。 + sessionStorage.setItem("career_draft", JSON.stringify({ full_name: "山田太郎" })); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + expect(loadCareerDraft()).toBeNull(); + expect(sessionStorage.getItem("career_draft")).toBeNull(); + }); }); diff --git a/frontend/src/utils/careerDraft.ts b/frontend/src/utils/careerDraft.ts index 55fd95b3..a82c5982 100644 --- a/frontend/src/utils/careerDraft.ts +++ b/frontend/src/utils/careerDraft.ts @@ -25,17 +25,41 @@ export function saveCareerDraft(form: CareerFormState): void { } } -/** 退避済みドラフトを読み出す。存在しない / 壊れている場合は null。 */ +/** + * パース済みオブジェクトが CareerFormState の最低限の形を満たすかを検証する。 + * 旧フォーマットや破損データを復元してフォーム描画時に落ちる(experiences.map 等)のを防ぐ軽量チェック。 + */ +function isCareerDraft(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) + ); +} + +/** 退避済みドラフトを読み出す。存在しない / 壊れている / 形が不正な場合は null(破棄する)。 */ export function loadCareerDraft(): CareerFormState | null { const raw = sessionStorage.getItem(CAREER_DRAFT_KEY); if (!raw) return null; + let parsed: unknown; try { - return JSON.parse(raw) as CareerFormState; + parsed = JSON.parse(raw); } catch (error) { console.warn("職務経歴書ドラフトの読み出しに失敗しました", error); clearCareerDraft(); return null; } + // パースは成功したが想定の形でない(旧フォーマット等)場合も破棄する。 + if (!isCareerDraft(parsed)) { + console.warn("職務経歴書ドラフトの形式が不正なため破棄します"); + clearCareerDraft(); + return null; + } + return parsed; } /** 退避済みドラフトを破棄する。 */