diff --git a/.claude/rules/frontend/architecture.md b/.claude/rules/frontend/architecture.md index 1cf4e7f4..aba4b5f1 100644 --- a/.claude/rules/frontend/architecture.md +++ b/.claude/rules/frontend/architecture.md @@ -43,7 +43,7 @@ frontend/src/ │ ├── useAuthSession.ts # 認証セッション状態 │ ├── useTheme.ts │ ├── blog/ # useBlogAccountManager -│ └── career/ # useCareerDirty / useCareerExperienceMutators / useProjectModalForm / useProjectModalState / useProjectFormDirty / usePhotoUpload / useImportPanelLayout / useResumeImportAssist +│ └── career/ # useCareerDirty / useCareerExperienceMutators / useProjectModalForm / useProjectModalState / usePhotoUpload / useImportPanelLayout / useResumeImportAssist ├── api/ │ ├── client.ts # fetch ラッパー(Cookie 認証、401 ハンドリング) │ └── *.ts # ドメイン別 API モジュール(auth, blog, resumes, master-data, notifications, download, ai-resume, githubLink) diff --git a/frontend/e2e/career-dirty-indicator.spec.ts b/frontend/e2e/career-dirty-indicator.spec.ts index 86fed835..021f1342 100644 --- a/frontend/e2e/career-dirty-indicator.spec.ts +++ b/frontend/e2e/career-dirty-indicator.spec.ts @@ -89,7 +89,7 @@ test.describe("職務経歴書 未保存マーク", () => { await setupResumeApi(page); }); - test("ProjectModal 内でフィールドを編集すると該当ラベル横に 🔴 が出る", async ({ page }) => { + test("ProjectModal でフィールドを編集すると即時にフォームへ反映され 🔴 が出る", async ({ page }) => { // baseResume を上書きして 1 件のプロジェクトを持たせる const baseResumeWithProject = { id: "resume-1", @@ -143,17 +143,16 @@ test.describe("職務経歴書 未保存マーク", () => { // 初期状態: dirty なし await expect(page.getByTestId("dirty-dot")).toHaveCount(0); - // プロジェクト「編集」ボタンを押す - await page.getByRole("button", { name: "編集" }).click(); + // プロジェクト「編集」ボタンを押す(自己PR/職務要約の編集ボタンと区別するため完全一致) + await page.getByRole("button", { name: "編集", exact: true }).click(); - // モーダル内のプロジェクト名を変更 + // モーダル内のプロジェクト名を変更(入力は即時にフォームへ反映される) const projectNameInput = page.getByPlaceholder("例: エネルギー業界 IoT Web API アプリ新規開発"); await projectNameInput.fill("既存プロジェクト改"); - // モーダル内で 🔴 が表示される(タイトル横とプロジェクト名ラベル横) + // 即時反映により、モーダル背後のフォーム側に未保存マーク 🔴 が現れる + // (即時保存化に伴いモーダル内には 🔴 を出さない設計) await expect(page.getByTestId("dirty-dot").first()).toBeVisible(); - const dotCount = await page.getByTestId("dirty-dot").count(); - expect(dotCount).toBeGreaterThanOrEqual(2); }); test("新規ユーザー(DB データなし)で氏名を編集すると 🔴 が表示される", async ({ page }) => { diff --git a/frontend/e2e/career-field-modal.spec.ts b/frontend/e2e/career-field-modal.spec.ts new file mode 100644 index 00000000..93378efc --- /dev/null +++ b/frontend/e2e/career-field-modal.spec.ts @@ -0,0 +1,93 @@ +import { test, expect, type Page } from "@playwright/test"; + +import { UI_MESSAGES, charCountLabel } from "../src/constants/messages"; +import { setupAuth, waitForAuthenticatedLayout } from "./helpers/auth"; + +/** + * 自己PR・職務要約の専用入力モーダル E2E。 + * + * これらのフィールドはフォーム本体ではプレビュー + 編集ボタンに集約され、入力は専用モーダルで行う。 + * シナリオ: + * 1. 「自己PRを編集」ボタンでモーダルが開き、現在値が入力欄に出る + * 2. 入力すると右下の文字数カウント(空白除外)が更新される + * 3. × で閉じると、入力値がフォーム側プレビューに反映されている + */ + +/** 職務経歴書の最小 API モック(マスタ・最新取得)。 */ +async function setupResumeApi(page: Page) { + 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({ + id: "resume-1", + full_name: "山田 太郎", + career_summary: "初期サマリー", + self_pr: "初期自己PR", + experiences: [], + qualifications: [], + }), + }), + ); +} + +test.describe("職務経歴書 自己PR・職務要約モーダル", () => { + test.beforeEach(async ({ page }) => { + await setupAuth(page); + await setupResumeApi(page); + await page.goto("/career"); + await waitForAuthenticatedLayout(page); + }); + + test("自己PRをモーダルで編集すると文字数カウントが更新されフォームへ反映される", async ({ + page, + }) => { + // フォーム本体には現在値のプレビューが出ている。 + await expect(page.getByText("初期自己PR")).toBeVisible(); + + // 「自己PRを編集」ボタンでモーダルを開く。 + await page + .getByRole("button", { name: `${UI_MESSAGES.FIELD_SELF_PR}を${UI_MESSAGES.EDIT}` }) + .click(); + + // モーダル内の入力欄(textarea は本フォームではモーダル内にのみ存在)に現在値がロードされている。 + const textarea = page.locator("textarea"); + await expect(textarea).toHaveValue("初期自己PR"); + + // 入力すると右下の文字数カウント(空白除外)が更新される。 + await textarea.fill("新しい 自己PR 本文"); + // 空白を除くと「新しい自己PR本文」= 9 文字(新・し・い・自・己・P・R・本・文)。 + await expect(page.getByText(charCountLabel(9), { exact: true })).toBeVisible(); + + // × で閉じる。 + await page.getByRole("button", { name: UI_MESSAGES.MODAL_CLOSE, exact: true }).click(); + await expect(textarea).toHaveCount(0); + + // フォーム側プレビューに編集値が反映されている(即時に formCache へ反映)。 + await expect(page.getByText("新しい 自己PR 本文")).toBeVisible(); + }); + + test("必須未入力で保存すると職務要約モーダルが自動で開く", async ({ page }) => { + // 職務要約を空にするため、まずモーダルを開いて全消しする。 + await page + .getByRole("button", { + name: `${UI_MESSAGES.FIELD_CAREER_SUMMARY}を${UI_MESSAGES.EDIT}`, + }) + .click(); + const textarea = page.locator("textarea"); + await textarea.fill(""); + await page.getByRole("button", { name: UI_MESSAGES.MODAL_CLOSE, exact: true }).click(); + await expect(textarea).toHaveCount(0); + + // 保存(更新)ボタンを押すと、職務要約が未入力なのでモーダルが自動で開く。 + await page.getByRole("button", { name: /更新する|保存する/ }).click(); + // 開いたのが職務要約モーダルであることをタイトル(完全一致)で確認する。 + await expect(page.getByText(UI_MESSAGES.FIELD_CAREER_SUMMARY, { exact: true })).toBeVisible(); + }); +}); diff --git a/frontend/src/components/forms/CareerResumeForm.tsx b/frontend/src/components/forms/CareerResumeForm.tsx index 1adc664a..f7bb9617 100644 --- a/frontend/src/components/forms/CareerResumeForm.tsx +++ b/frontend/src/components/forms/CareerResumeForm.tsx @@ -28,6 +28,7 @@ import { useMessageToast } from "../ui/toast"; import shared from "../../styles/shared.module.css"; import { ConfirmDialog } from "../ConfirmDialog"; import { CareerDiffModal } from "./CareerDiffModal"; +import { MarkdownFieldModal } from "./MarkdownFieldModal"; import { Skeleton } from "../ui/Skeleton"; import { PdfPreviewModal } from "./PdfPreviewModal"; import { ResumeSourceTracePanel } from "./ResumeSourceTracePanel"; @@ -43,6 +44,8 @@ export function CareerResumeForm() { const [showSaveConfirm, setShowSaveConfirm] = useState(false); // PDF 原本ビュー(右カラム)の折りたたみ状態。折りたたむと入力フォームが全幅に広がる。 const [pdfCollapsed, setPdfCollapsed] = useState(false); + // 自己PR / 職務要約の入力モーダルの対象フィールド(null で閉じている)。 + const [editingField, setEditingField] = useState<"career_summary" | "self_pr" | null>(null); const assist = useResumeImportAssist(); const splitRef = useRef(null); const { width: pdfWidth, startResize } = useImportPanelLayout(splitRef); @@ -169,6 +172,14 @@ export function CareerResumeForm() { 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); + } return; } setValidationError(null); @@ -224,6 +235,20 @@ export function CareerResumeForm() { /> )} {previewUrl && } + {editingField && ( + onChangeField(editingField, v)} + onClose={() => setEditingField(null)} + assist={assist} + invalid={focusLocator?.kind === editingField} + /> + )} {/* noValidate: 必須チェックはブラウザ標準ではなく validateCareerForm に一本化する。 標準の required バブルが先に発火すると、該当フィールドへの独自フォーカス・赤枠・ 日本語メッセージが出せず挙動が不統一になるため抑止する。 */} @@ -290,6 +315,7 @@ export function CareerResumeForm() { careerSummary={form.career_summary} loading={loading} onChange={onChangeField} + onEditCareerSummary={() => setEditingField("career_summary")} fullNameDirty={dirty.full_name} careerSummaryDirty={dirty.career_summary} focusLocator={focusLocator} @@ -335,7 +361,7 @@ export function CareerResumeForm() { onChangeField("self_pr", v)} + onEdit={() => setEditingField("self_pr")} dirty={dirty.self_pr} focusLocator={focusLocator} /> @@ -360,7 +386,9 @@ export function CareerResumeForm() { className={layout.pdfToggle} onClick={() => setPdfCollapsed((v) => !v)} aria-label={ - pdfCollapsed ? UI_MESSAGES.SOURCE_PANEL_EXPAND : UI_MESSAGES.SOURCE_PANEL_COLLAPSE + pdfCollapsed + ? UI_MESSAGES.SOURCE_PANEL_EXPAND + : UI_MESSAGES.SOURCE_PANEL_COLLAPSE } aria-expanded={!pdfCollapsed} > diff --git a/frontend/src/components/forms/MarkdownFieldModal.tsx b/frontend/src/components/forms/MarkdownFieldModal.tsx new file mode 100644 index 00000000..29c140e8 --- /dev/null +++ b/frontend/src/components/forms/MarkdownFieldModal.tsx @@ -0,0 +1,46 @@ +import { useFocusOnMatch } from "../../hooks/useFocusOnMatch"; +import type { UseResumeImportAssistReturn } from "../../hooks/career/useResumeImportAssist"; +import { CharCount } from "../ui/CharCount"; +import { ModalShell } from "../ui/ModalShell"; +import { MarkdownTextarea } from "./MarkdownTextarea"; + +type Props = { + /** ヘッダー / 入力ラベルに使うフィールド名(例: 自己PR / 職務要約) */ + title: string; + /** Markdown 値 */ + value: string; + /** 値変更ハンドラ(親の onChangeField 経由で即時に formCache へ反映される) */ + onChange: (value: string) => void; + /** モーダルを閉じるコールバック(× / オーバーレイ共通) */ + onClose: () => void; + /** 取り込み補助。ファイルがあれば右カラムに原本ビューを再掲する */ + assist?: UseResumeImportAssistReturn; + /** バリデーション失敗フィールドとして強調&フォーカスするか */ + invalid?: boolean; +}; + +/** + * 自己PR・職務要約のような単一 Markdown フィールドを専用 UI で入力させるモーダル。 + * 左に大きな入力欄、右に取り込み原本ビュー、右下に文字数カウント(空白除外)を表示する。 + * 保存ボタンは持たず、入力は即時に親(formCache)へ反映される。 + */ +export function MarkdownFieldModal({ title, value, onChange, onClose, assist, invalid }: Props) { + // バリデーション起因でモーダルが開かれた時、入力欄へフォーカスする。 + const textareaRef = useFocusOnMatch(!!invalid); + + return ( + + + + + ); +} diff --git a/frontend/src/components/forms/MarkdownFieldTrigger.module.css b/frontend/src/components/forms/MarkdownFieldTrigger.module.css new file mode 100644 index 00000000..73ec3dae --- /dev/null +++ b/frontend/src/components/forms/MarkdownFieldTrigger.module.css @@ -0,0 +1,64 @@ +.wrapper { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; +} + +/* 内容プレビュー兼編集ボタン。クリックでモーダルを開く。 */ +.trigger { + all: unset; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.6rem; + border: 1px solid var(--border-input, var(--border)); + border-radius: 6px; + padding: 0.55rem 0.7rem; + background: var(--bg-card); + min-height: 2.4rem; +} + +.trigger:hover { + border-color: var(--accent); +} + +.trigger.invalid { + border-color: var(--error); +} + +/* キーボード操作時のフォーカスを可視化する(all:unset で消えるネイティブ outline の代替)。 */ +.trigger:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* 値のプレビュー(1 行に切り詰め)。 */ +.preview { + flex: 1; + min-width: 0; + color: var(--text-primary); + font-size: 0.9rem; + line-height: 1.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.empty { + color: var(--text-muted); +} + +/* 「編集」リンク風ラベル。 */ +.editLabel { + flex-shrink: 0; + color: var(--accent); + font-size: 0.85rem; + font-weight: 600; +} diff --git a/frontend/src/components/forms/MarkdownFieldTrigger.tsx b/frontend/src/components/forms/MarkdownFieldTrigger.tsx new file mode 100644 index 00000000..aa35d7a9 --- /dev/null +++ b/frontend/src/components/forms/MarkdownFieldTrigger.tsx @@ -0,0 +1,74 @@ +import { UI_MESSAGES } from "../../constants/messages"; +import shared from "../../styles/shared.module.css"; +import { DirtyDot } from "../ui/DirtyDot"; +import { Skeleton } from "../ui/Skeleton"; +import styles from "./MarkdownFieldTrigger.module.css"; + +/** プレビューに出す最大文字数(超過分は省略記号で切り詰め) */ +const PREVIEW_MAX_LENGTH = 120; + +/** 改行を空白に潰し、先頭を切り詰めた 1 行プレビュー文字列を作る。 */ +function toPreview(value: string): string { + const flat = value.replace(/\s+/g, " ").trim(); + if (flat.length <= PREVIEW_MAX_LENGTH) return flat; + return `${flat.slice(0, PREVIEW_MAX_LENGTH)}…`; +} + +type Props = { + /** フィールド名(自己PR / 職務要約) */ + label: string; + /** 現在値(Markdown 原文) */ + value: string; + /** 未保存マーク(セクション/フォームの dirty)を表示するか */ + dirty?: boolean; + /** バリデーション失敗フィールドとして赤枠で強調するか */ + invalid?: boolean; + /** ローディング中(Skeleton 表示) */ + loading?: boolean; + /** 編集ボタン押下で入力モーダルを開くコールバック */ + onEdit: () => void; +}; + +/** + * 自己PR・職務要約をフォーム内ではプレビュー + 編集ボタンで表示するトリガ。 + * 大きな入力欄を専用モーダルへ逃がし、フォーム本体のノイズを減らす。 + */ +export function MarkdownFieldTrigger({ + label, + value, + dirty = false, + invalid = false, + loading = false, + onEdit, +}: Props) { + const preview = toPreview(value); + const isEmpty = preview.length === 0; + + return ( +
+
+ + {label} + 必須 + + +
+ {loading ? ( + + ) : ( + + )} +
+ ); +} diff --git a/frontend/src/components/forms/MarkdownTextarea.module.css b/frontend/src/components/forms/MarkdownTextarea.module.css index 283a46ff..9033406d 100644 --- a/frontend/src/components/forms/MarkdownTextarea.module.css +++ b/frontend/src/components/forms/MarkdownTextarea.module.css @@ -10,6 +10,21 @@ align-items: stretch; } +/* fill 指定時はモーダルなど親の高さいっぱいに入力欄を伸長する。 */ +.fill { + flex: 1; + min-height: 0; +} + +.fill .editorRow { + flex: 1; + min-height: 0; +} + +.fill .editor { + resize: none; +} + .editor { flex: 1; min-width: 0; diff --git a/frontend/src/components/forms/MarkdownTextarea.tsx b/frontend/src/components/forms/MarkdownTextarea.tsx index 040e4e0e..97d23c3e 100644 --- a/frontend/src/components/forms/MarkdownTextarea.tsx +++ b/frontend/src/components/forms/MarkdownTextarea.tsx @@ -22,6 +22,8 @@ type Props = { textareaRef?: Ref; /** バリデーション失敗フィールドとして強調するか(aria-invalid を付与) */ invalid?: boolean; + /** 親(モーダル等)の高さいっぱいに伸長するか(既定 false でインライン用途は不変) */ + fill?: boolean; }; /** @@ -37,11 +39,12 @@ export function MarkdownTextarea({ labelAdornment, textareaRef, invalid, + fill = false, }: Props) { const renderedHtml = useMemo(() => renderMarkdown(value), [value]); return ( -
+
{label} {required && 必須} diff --git a/frontend/src/components/forms/ProjectModal.module.css b/frontend/src/components/forms/ProjectModal.module.css index 57710055..00881c99 100644 --- a/frontend/src/components/forms/ProjectModal.module.css +++ b/frontend/src/components/forms/ProjectModal.module.css @@ -1,124 +1,6 @@ -/* サイドバーを覆わず(操作可能なまま残し)、その右のコンテンツ領域いっぱいにモーダルを開く。 - overlay 自体を left:サイドバー幅 から始めるので、z-index がサイドバー(100)より上でも - サイドバーは露出したままになる。 */ -.overlay { - position: fixed; - inset: 0; - left: var(--sidebar-width); - background: var(--overlay-bg); - display: flex; - padding: 1.2rem; - z-index: 1000; -} - -/* モーダルはコンテンツ領域いっぱいに広げる(職務経歴書ページと同等の作業領域を確保)。 */ -.modal { - background: var(--bg-card); - border-radius: 12px; - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - overflow: hidden; -} - -/* モバイルはサイドバーが上部バーになるため、その下から全幅で開く。 */ -@media (max-width: 768px) { - .overlay { - left: 0; - top: var(--topbar-height); - padding: 0.8rem; - } -} - -/* ヘッダー下の領域。左=入力フォーム / 右=取り込みブロックの 2 カラム。 - スプリッターのドラッグで左右の比率を変えられる。 */ -.bodyWrap { - flex: 1; - display: flex; - min-height: 0; - overflow: hidden; -} - -/* 入力フォームと PDF カラムの境界。ドラッグで左右比率を変える。 */ -.splitter { - flex-shrink: 0; - align-self: stretch; - width: 6px; - border-radius: 3px; - background: var(--border); - cursor: col-resize; - transition: background 0.15s; -} - -.splitter:hover { - background: var(--accent); -} - -/* モーダル内に再掲する PDF 原本ビュー(タブ固定・ページ部のみスクロール)。 - 幅は CSS 変数 --pdf-col-width で可変(縦積み時は下のメディアクエリで全幅に上書き)。 - inline style ではなく変数経由にすることで、縦積み時の全幅化を CSS 側で上書きできる。 */ -.blocksColumn { - width: var(--pdf-col-width, 320px); - flex-shrink: 0; - display: flex; - flex-direction: column; - min-height: 0; - overflow: hidden; - border-left: 1px solid var(--border); - padding: 1rem; -} - -/* 横幅が狭い時は左右 2 カラムをやめ、フォームの下に PDF を全幅で縦積みする。 - モーダルは width:90vw なのでビューポート幅で判定する。 */ -@media (max-width: 700px) { - .bodyWrap { - flex-direction: column; - overflow-y: auto; - } - - /* 縦積み時はモーダル全体スクロールに委ねる(カラム個別スクロールを解除) */ - .body { - overflow-y: visible; - } - - /* 縦積み時はドラッグでの左右リサイズを無効化 */ - .splitter { - display: none; - } - - /* 幅指定を上書きして全幅。区切りは左ボーダーから上ボーダーへ。 */ - .blocksColumn { - width: 100%; - min-height: 60vh; - border-left: none; - border-top: 1px solid var(--border); - } -} - -.header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.8rem 1rem; - border-bottom: 1px solid var(--border); - font-weight: 700; -} - -.headerActions { - display: flex; - gap: 0.5rem; -} - -.body { - flex: 1; - min-width: 0; - overflow-y: auto; - padding: 1rem; - display: flex; - flex-direction: column; - gap: 0.8rem; -} +/* ProjectModal の入力フォーム固有スタイル。 + オーバーレイ・ヘッダー・2 カラム外枠・スプリッターの共通シェルは + components/ui/ModalShell.module.css に集約済み。 */ .inline { display: flex; diff --git a/frontend/src/components/forms/ProjectModal.test.tsx b/frontend/src/components/forms/ProjectModal.test.tsx index 605cf748..eb02b562 100644 --- a/frontend/src/components/forms/ProjectModal.test.tsx +++ b/frontend/src/components/forms/ProjectModal.test.tsx @@ -1,6 +1,7 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, it, expect, vi } from "vitest"; import { ProjectModal } from "./ProjectModal"; +import { VALIDATION_MESSAGES } from "../../constants/messages"; import type { CareerProjectForm } from "../../payloadBuilders"; import type { UseResumeImportAssistReturn } from "../../hooks/career/useResumeImportAssist"; @@ -47,8 +48,31 @@ const makeAssist = ( }); describe("ProjectModal", () => { - /** 開始日 > 終了日 のとき保存ボタンが disabled になること */ - it("開始日が終了日より後の場合に保存ボタンが disabled になる", () => { + /** 保存ボタンは廃止され、入力のたびに onSave が即時に呼ばれること */ + it("入力すると onSave が即時に呼ばれる(保存ボタンなし)", () => { + const onSave = vi.fn(); + render( + , + ); + // 保存ボタンは存在しない。 + expect(screen.queryByRole("button", { name: "保存" })).toBeNull(); + // プロジェクト名を入力すると即時に onSave が呼ばれる。 + const nameInput = screen.getByPlaceholderText( + "例: エネルギー業界 IoT Web API アプリ新規開発", + ); + fireEvent.change(nameInput, { target: { value: "新規PJ" } }); + expect(onSave).toHaveBeenCalled(); + const lastCall = onSave.mock.calls[onSave.mock.calls.length - 1]; + expect(lastCall?.[0]).toMatchObject({ name: "新規PJ" }); + }); + + /** 開始日 > 終了日 のとき期間エラーをインライン表示すること */ + it("開始日が終了日より後の場合に期間エラーをインライン表示する", () => { render( { techStackNamesByCategory={new Map()} />, ); - expect(screen.getByRole("button", { name: "保存" })).toBeDisabled(); + expect(screen.getByText(VALIDATION_MESSAGES.DATE_RANGE_INVALID)).toBeInTheDocument(); }); /** PDF が選択済みの時、子モーダル内に原本ビューを再掲すること */ diff --git a/frontend/src/components/forms/ProjectModal.tsx b/frontend/src/components/forms/ProjectModal.tsx index dcb98185..c1ad63f3 100644 --- a/frontend/src/components/forms/ProjectModal.tsx +++ b/frontend/src/components/forms/ProjectModal.tsx @@ -1,4 +1,4 @@ -import { useRef, type CSSProperties } from "react"; +import { useEffect, useRef } from "react"; import type { CareerProjectForm, CareerProjectPeriodForm } from "../../payloadBuilders"; import { @@ -8,22 +8,19 @@ import { teamRoleOptions, } from "../../constants"; import type { CareerProjectPeriodFieldKey } from "../../formTypes"; -import { useProjectFormDirty } from "../../hooks/career/useProjectFormDirty"; import { useProjectModalForm } from "../../hooks/career/useProjectModalForm"; -import { useImportPanelLayout } from "../../hooks/career/useImportPanelLayout"; import { useFocusOnMatch } from "../../hooks/useFocusOnMatch"; import type { UseResumeImportAssistReturn } from "../../hooks/career/useResumeImportAssist"; import { Combobox } from "./Combobox"; import { MarkdownTextarea } from "./MarkdownTextarea"; -import { ResumeSourceTracePanel } from "./ResumeSourceTracePanel"; -import { DirtyDot } from "../ui/DirtyDot"; +import { ModalShell } from "../ui/ModalShell"; import shared from "../../styles/shared.module.css"; import styles from "./ProjectModal.module.css"; type ProjectModalProps = { /** 編集対象のプロジェクト(nullの場合は新規追加) */ project: CareerProjectForm | null; - /** 保存時のコールバック */ + /** 入力反映コールバック。入力のたびに呼ばれ、即時に親フォーム(formCache)へ反映される */ onSave: (project: CareerProjectForm) => void; /** 閉じるコールバック */ onClose: () => void; @@ -48,7 +45,11 @@ type ProjectPeriodRowProps = { periodIndex: number; canRemove: boolean; autoFocus?: { periodIndex: number; field: "start_date" | "end_date" }; - onUpdate: (periodIndex: number, key: CareerProjectPeriodFieldKey, value: string | boolean) => void; + onUpdate: ( + periodIndex: number, + key: CareerProjectPeriodFieldKey, + value: string | boolean, + ) => void; onRemove: (periodIndex: number) => void; }; @@ -144,257 +145,191 @@ export function ProjectModal({ togglePhase, } = useProjectModalForm(project); - /** モーダル内編集の dirty 判定(元データとの差分) */ - const dirty = useProjectFormDirty(local, project); - - /** PDF が選択されている時だけモーダル内に原本ビューを表示する */ - const showPdf = !!assist && !!assist.file; - - // 入力フォームと PDF カラムの比率をスプリッターのドラッグで変える。 - // PDF カラムは右側なので「幅 = コンテナ右端 - マウス X」。reservedGap はカラム間のスプリッター(6px)。 - const bodyWrapRef = useRef(null); - const { width: pdfWidth, startResize } = useImportPanelLayout(bodyWrapRef, { - initialWidth: 440, - minWidth: 240, - minFormWidth: 320, - reservedGap: 6, - }); + // 入力のたびに親フォーム(formCache)へ即時反映する。 + // マウント時スナップショットと参照等価で「未編集」を判定し、開いて触らず閉じた場合は + // 一切コミットしない(= 新規プロジェクトの空レコード生成や StrictMode 二重実行による誤コミットを防ぐ)。 + const initialRef = useRef(local); + useEffect(() => { + if (local === initialRef.current) return; + onSave(local); + // onSave は親の再生成で identity が変わるため依存に含めず、local 変化のみで発火させる。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [local]); return ( -
-
e.stopPropagation()}> -
- - {project ? "プロジェクト編集" : "プロジェクト追加"} - - -
- - -
-
+ + -
-
- +
+

期間

+ {local.periods.map((period, periodIndex) => ( + 1} + autoFocus={autoFocus} + onUpdate={updatePeriodField} + onRemove={removePeriod} + /> + ))} + +
+ {dateError && ( +

{dateError}

+ )} -
-

- 期間 - -

- {local.periods.map((period, periodIndex) => ( - 1} - autoFocus={autoFocus} - onUpdate={updatePeriodField} - onRemove={removePeriod} - /> - ))} - -
- {dateError && ( -

{dateError}

- )} + -