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
2 changes: 1 addition & 1 deletion .claude/rules/frontend/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 6 additions & 7 deletions frontend/e2e/career-dirty-indicator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ test.describe("職務経歴書 未保存マーク", () => {
await setupResumeApi(page);
});

test("ProjectModal 内でフィールドを編集すると該当ラベル横に 🔴 が出る", async ({ page }) => {
test("ProjectModal でフィールドを編集すると即時にフォームへ反映され 🔴 が出る", async ({ page }) => {
// baseResume を上書きして 1 件のプロジェクトを持たせる
const baseResumeWithProject = {
id: "resume-1",
Expand Down Expand Up @@ -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 }) => {
Expand Down
93 changes: 93 additions & 0 deletions frontend/e2e/career-field-modal.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
32 changes: 30 additions & 2 deletions frontend/src/components/forms/CareerResumeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<HTMLDivElement>(null);
const { width: pdfWidth, startResize } = useImportPanelLayout(splitRef);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -224,6 +235,20 @@ export function CareerResumeForm() {
/>
)}
{previewUrl && <PdfPreviewModal previewUrl={previewUrl} onClose={closePreview} />}
{editingField && (
<MarkdownFieldModal
title={
editingField === "self_pr"
? UI_MESSAGES.FIELD_SELF_PR
: UI_MESSAGES.FIELD_CAREER_SUMMARY
}
value={form[editingField]}
onChange={(v) => onChangeField(editingField, v)}
onClose={() => setEditingField(null)}
assist={assist}
invalid={focusLocator?.kind === editingField}
/>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{/* noValidate: 必須チェックはブラウザ標準ではなく validateCareerForm に一本化する。
標準の required バブルが先に発火すると、該当フィールドへの独自フォーカス・赤枠・
日本語メッセージが出せず挙動が不統一になるため抑止する。 */}
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -335,7 +361,7 @@ export function CareerResumeForm() {
<CareerSelfPrSection
selfPr={form.self_pr}
loading={loading}
onChange={(v) => onChangeField("self_pr", v)}
onEdit={() => setEditingField("self_pr")}
dirty={dirty.self_pr}
focusLocator={focusLocator}
/>
Expand All @@ -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}
>
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/components/forms/MarkdownFieldModal.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLTextAreaElement>(!!invalid);

return (
<ModalShell title={title} onClose={onClose} assist={assist}>
<MarkdownTextarea
label={title}
value={value}
onChange={onChange}
rows={16}
fill
required
invalid={invalid}
textareaRef={textareaRef}
/>
<CharCount value={value} />
</ModalShell>
);
}
64 changes: 64 additions & 0 deletions frontend/src/components/forms/MarkdownFieldTrigger.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/* キーボード操作時のフォーカスを可視化する(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;
}
Loading
Loading