From 8d6917a054c0c07e0c27c1cdf0e03d0fa2e53a12 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Fri, 22 May 2026 10:49:37 +0900 Subject: [PATCH 1/6] unedited dot add --- .claude/CLAUDE.md | 3 - .claude/rules/backend/auth-security.md | 2 - .claude/rules/backend/database.md | 2 +- frontend/e2e/career-dirty-indicator.spec.ts | 114 ++++++++ .../CareerExperienceEditor.tsx | 152 ++++++---- .../src/components/forms/CareerResumeForm.tsx | 14 + .../src/components/forms/MarkdownTextarea.tsx | 7 +- .../forms/sections/CareerBasicInfoSection.tsx | 16 +- .../sections/CareerExperienceSection.tsx | 14 +- .../sections/CareerQualificationsSection.tsx | 70 +++-- .../forms/sections/CareerSelfPrSection.tsx | 6 +- frontend/src/components/ui/DirtyDot.tsx | 26 ++ .../src/hooks/career/useCareerDirty.test.ts | 147 ++++++++++ frontend/src/hooks/career/useCareerDirty.ts | 264 ++++++++++++++++++ frontend/src/hooks/useDocumentForm.ts | 44 ++- frontend/src/store/formCacheSlice.ts | 27 +- infra/modules/cloud_run/main.tf | 1 - 17 files changed, 802 insertions(+), 107 deletions(-) create mode 100644 frontend/e2e/career-dirty-indicator.spec.ts create mode 100644 frontend/src/components/ui/DirtyDot.tsx create mode 100644 frontend/src/hooks/career/useCareerDirty.test.ts create mode 100644 frontend/src/hooks/career/useCareerDirty.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9f3a1dfc..c9d91712 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -103,9 +103,6 @@ CI 定義: `.github/workflows/ci.yml` | 種別 | 名前 | |---|---| | 職務経歴書(career history) | `Resume` / `resumes` テーブル | -| 履歴書(personal CV) | `Rirekisho` / `rirekisho` テーブル | - -> `rirekisho` は日本語ローマ字のため cSpell の警告が出るが無視してよい。 ## 環境変数 diff --git a/.claude/rules/backend/auth-security.md b/.claude/rules/backend/auth-security.md index 540dac8b..09079aef 100644 --- a/.claude/rules/backend/auth-security.md +++ b/.claude/rules/backend/auth-security.md @@ -17,8 +17,6 @@ paths: - 認証 Cookie 属性は `COOKIE_SECURE` / `COOKIE_SAMESITE` で制御する ## 暗号化 - -- 履歴書(Rirekisho)の個人情報フィールド(email / phone / postal_code / address)は `app/core/encryption.py` で暗号化保存 - 鍵は `FIELD_ENCRYPTION_KEY` 環境変数(Fernet) ## セキュリティ diff --git a/.claude/rules/backend/database.md b/.claude/rules/backend/database.md index d6f12f06..32e9ebc3 100644 --- a/.claude/rules/backend/database.md +++ b/.claude/rules/backend/database.md @@ -5,7 +5,7 @@ paths: # DB設計ルール -- `basic_info` / `resumes` / `rirekisho` は **1ユーザー1件** を前提にし、`user_id` を一意制約で縛ること +- `basic_info` / `resumes` は **1ユーザー1件** を前提にし、`user_id` を一意制約で縛ること - 可変長データを JSON カラムへ増やさないこと。資格・学歴・職歴・職務経歴の明細・ブログタグは子テーブルへ正規化すること - 日付は可能な限り DB の `DATE` / `TIMESTAMP` を使うこと - `blog_articles` は `account_id` 起点で管理し、`user_id` や `platform` を冗長保持しないこと diff --git a/frontend/e2e/career-dirty-indicator.spec.ts b/frontend/e2e/career-dirty-indicator.spec.ts new file mode 100644 index 00000000..dac88bb3 --- /dev/null +++ b/frontend/e2e/career-dirty-indicator.spec.ts @@ -0,0 +1,114 @@ +import { test, expect, type Page } from "@playwright/test"; +import { setupAuth, waitForAuthenticatedLayout } from "./helpers/auth"; + +/** + * 職務経歴書の未保存マーク(🔴)E2E。 + * + * シナリオ: + * 1. 職務経歴書を開く → 🔴 なし + * 2. 氏名を編集 → 氏名横と保存ボタン横に 🔴 + * 3. キャリア分析タブへ遷移して職務経歴書に戻る → 🔴 が維持されている(Redux 保持) + * 4. 保存(PUT /api/resumes/{id})→ baseline 更新で 🔴 が全消失 + */ + +/** 職務経歴書の API モックをまとめてセットする */ +async function setupResumeApi(page: Page) { + const baseResume = { + id: "resume-1", + full_name: "山田 太郎", + career_summary: "サマリー", + self_pr: "自己PR", + experiences: [ + { + company: "株式会社A", + business_description: "受託開発", + start_date: "2020-04", + end_date: null, + is_current: true, + employee_count: "100", + capital: "1", + clients: [], + }, + ], + qualifications: [], + }; + + // マスタデータ + 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: "[]" }), + ); + + // 最新取得 + await page.route("**/api/resumes/latest", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(baseResume), + }), + ); + + // 更新(PUT)。レスポンスは編集後の値で返す前提だが、テストでは保存後 🔴 が消えれば良いので + // request 内容をそのまま baseline として返す。 + await page.route("**/api/resumes/resume-1", async (route) => { + if (route.request().method() === "PUT") { + const body = JSON.parse(route.request().postData() ?? "{}"); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ...baseResume, ...body }), + }); + return; + } + await route.fallback(); + }); + + // キャリア分析ページ用モック(タブ遷移先の 404 ループを避ける) + await page.route("**/api/career-analysis/**", (route) => + route.fulfill({ status: 200, contentType: "application/json", body: "[]" }), + ); +} + +test.describe("職務経歴書 未保存マーク", () => { + test.beforeEach(async ({ page }) => { + await setupAuth(page); + await setupResumeApi(page); + }); + + test("編集 → 🔴 表示 → タブ遷移後も維持 → 保存で消える", async ({ page }) => { + await page.goto("/career"); + await waitForAuthenticatedLayout(page); + + // 1. 初期状態: 🔴 は出ていない + await expect(page.getByTestId("dirty-dot")).toHaveCount(0); + + // 2. 氏名を編集 + const nameInput = page.getByPlaceholder("例: 山田 太郎"); + await nameInput.fill("佐藤 花子"); + + // 🔴 が 1 つ以上現れる(氏名 label + 保存ボタン横、計 2 以上) + await expect(page.getByTestId("dirty-dot").first()).toBeVisible(); + const dirtyCountAfterEdit = await page.getByTestId("dirty-dot").count(); + expect(dirtyCountAfterEdit).toBeGreaterThanOrEqual(2); + + // 3. キャリア分析タブへ遷移 + await page.getByRole("link", { name: "キャリア分析" }).click(); + await expect(page).toHaveURL(/\/career_analysis/); + + // 職務経歴書に戻る + await page.getByRole("link", { name: "職務経歴書" }).click(); + await expect(page).toHaveURL(/\/career$/); + await waitForAuthenticatedLayout(page); + + // 編集値と 🔴 が維持されている + await expect(nameInput).toHaveValue("佐藤 花子"); + const dirtyCountAfterReturn = await page.getByTestId("dirty-dot").count(); + expect(dirtyCountAfterReturn).toBeGreaterThanOrEqual(2); + + // 4. 保存 → 🔴 が全消失 + await page.getByRole("button", { name: /更新する|保存する/ }).click(); + await expect(page.getByTestId("dirty-dot")).toHaveCount(0); + }); +}); diff --git a/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx b/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx index c91f67c8..c331d138 100644 --- a/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx +++ b/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx @@ -1,4 +1,5 @@ import type { CareerClientFieldKey, CareerExperienceFieldKey } from "../../../formTypes"; +import type { ExperienceDirty } from "../../../hooks/career/useCareerDirty"; import { validateDateRange, type CareerExperienceForm, @@ -6,6 +7,7 @@ import { } from "../../../payloadBuilders"; import shared from "../../../styles/shared.module.css"; import styles from "../CareerResumeForm.module.css"; +import { DirtyDot } from "../../ui/DirtyDot"; /** CareerExperienceEditor のプロパティ型 */ type CareerExperienceEditorProps = { @@ -40,6 +42,8 @@ type CareerExperienceEditorProps = { onRemoveExperience: (index: number) => void; /** プロジェクトサマリーテキストを生成する関数 */ projectSummary: (proj: CareerProjectForm) => string; + /** この経歴の dirty 情報。未指定なら 🔴 表示なし。 */ + dirty?: ExperienceDirty; }; /** @@ -58,12 +62,17 @@ export function CareerExperienceEditor({ onOpenProjectModal, onRemoveExperience, projectSummary, + dirty, }: CareerExperienceEditorProps) { + const fieldDirty = dirty?.fields; return (
+ {/* この経歴ブロック全体の未保存マーク(配下のクライアント・プロジェクトを含めて集約) */} +