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 / usePdfPanelLayout / useResumeImportAssist
│ └── career/ # useCareerDirty / useCareerExperienceMutators / useProjectModalForm / useProjectModalState / useProjectFormDirty / usePhotoUpload / useImportPanelLayout / useResumeImportAssist
├── api/
│ ├── client.ts # fetch ラッパー(Cookie 認証、401 ハンドリング)
│ └── *.ts # ドメイン別 API モジュール(auth, blog, resumes, master-data, notifications, download, ai-resume, githubLink)
Expand Down
147 changes: 147 additions & 0 deletions frontend/e2e/career-import-assist.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { test, expect, type Locator, type Page } from "@playwright/test";

import { IMPORT_ASSIST_MESSAGES } from "../src/constants/messages";
import { setupAuth, waitForAuthenticatedLayout } from "./helpers/auth";

/**
* 職務経歴書のファイル取り込み補助(原本ビュー)E2E。
*
* 取り込み導線は右カラムの原本ビューに一本化されており、ドラッグ&ドロップ / クリック選択で
* PDF・Markdown を読み込む。ここでは Markdown の描画と、種別・サイズのエラーパターンを検証する。
*
* シナリオ:
* 1. Markdown をファイル選択で読み込む → 原本が整形描画される
* 2. Markdown をドラッグ&ドロップで読み込む → 原本が整形描画される
* 3. 原本上で選択した文字が、フォーカス中の入力欄へ流し込まれる
* 4. 拡張子が pdf/md 以外(.txt)→ 未対応エラーを表示し描画しない
* 5. サイズ上限超過の Markdown → サイズ超過エラーを表示し描画しない
*/

/** 職務経歴書の最小 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: [],
}),
}),
);
}

/** 原本ビュー内の隠しファイル input(aria-label で一意)。 */
function importInput(page: Page): Locator {
return page.locator(`input[aria-label="${IMPORT_ASSIST_MESSAGES.SELECT_FILE}"]`);
}

/** 描画された Markdown 原本の本文要素。 */
function markdownBody(page: Page): Locator {
return page.locator("[class*='markdownBody']");
}

const SAMPLE_MD = "# E2Eマークダウン見出し\n\n- 取り込みテスト項目\n";

test.describe("職務経歴書 ファイル取り込み補助", () => {
test.beforeEach(async ({ page }) => {
await setupAuth(page);
await setupResumeApi(page);
await page.goto("/career");
await waitForAuthenticatedLayout(page);
});

test("ファイル選択で Markdown を読み込むと原本が整形描画される", async ({ page }) => {
await importInput(page).setInputFiles({
name: "resume.md",
mimeType: "text/markdown",
buffer: Buffer.from(SAMPLE_MD, "utf-8"),
});

// 見出し(h1)とリスト項目が原本ビュー内に描画される
await expect(markdownBody(page).getByText("E2Eマークダウン見出し")).toBeVisible();
await expect(markdownBody(page).getByText("取り込みテスト項目")).toBeVisible();
// タブにファイル名が出る
await expect(page.getByText("resume.md")).toBeVisible();
});

test("Markdown をドラッグ&ドロップで読み込むと原本が整形描画される", async ({ page }) => {
// ファイルを載せた DataTransfer を生成し、ドロップゾーンへ drop イベントを発火する。
const dataTransfer = await page.evaluateHandle((content) => {
const dt = new DataTransfer();
dt.items.add(new File([content], "dropped.md", { type: "text/markdown" }));
return dt;
}, SAMPLE_MD);

const dropzone = page.locator("[class*='dropzone']");
await dropzone.dispatchEvent("dragover", { dataTransfer });
await dropzone.dispatchEvent("drop", { dataTransfer });

await expect(markdownBody(page).getByText("E2Eマークダウン見出し")).toBeVisible();
await expect(page.getByText("dropped.md")).toBeVisible();
});

test("原本上で選択した文字がフォーカス中の入力欄へ流し込まれる", async ({ page }) => {
await importInput(page).setInputFiles({
name: "resume.md",
mimeType: "text/markdown",
buffer: Buffer.from(SAMPLE_MD, "utf-8"),
});
await expect(markdownBody(page).getByText("取り込みテスト項目")).toBeVisible();

// 流し込み先として氏名欄をフォーカス(focusin で「最後にフォーカスした入力欄」に記録される)
const nameInput = page.getByPlaceholder("例: 山田 太郎");
await nameInput.click();

// 原本のリスト項目テキストを選択し、mouseup を発火して流し込む
await page.evaluate(() => {
const body = document.querySelector("[class*='markdownBody']");
const target = body?.querySelector("li") ?? body;
if (!target) throw new Error("markdown body not found");
const range = document.createRange();
range.selectNodeContents(target);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
});

await expect(nameInput).toHaveValue("取り込みテスト項目");
});

test("拡張子が pdf/md 以外(.txt)だと未対応エラーを表示し描画しない", async ({ page }) => {
await importInput(page).setInputFiles({
name: "note.txt",
mimeType: "text/plain",
buffer: Buffer.from("ただのテキスト", "utf-8"),
});

await expect(page.getByText(IMPORT_ASSIST_MESSAGES.UNSUPPORTED_TYPE)).toBeVisible();
// 原本は描画されず、ドロップゾーンが残る
await expect(markdownBody(page)).toHaveCount(0);
await expect(page.getByText(IMPORT_ASSIST_MESSAGES.DROPZONE)).toBeVisible();
});

test("サイズ上限を超える Markdown はサイズ超過エラーを表示し描画しない", async ({ page }) => {
// Markdown 上限は 2MB。3MB のダミーを渡して弾かれることを確認する。
await importInput(page).setInputFiles({
name: "huge.md",
mimeType: "text/markdown",
buffer: Buffer.alloc(3 * 1024 * 1024, 0x61),
});

await expect(page.getByText(IMPORT_ASSIST_MESSAGES.TOO_LARGE(2))).toBeVisible();
await expect(markdownBody(page)).toHaveCount(0);
await expect(page.getByText(IMPORT_ASSIST_MESSAGES.DROPZONE)).toBeVisible();
});
});
44 changes: 25 additions & 19 deletions frontend/src/components/forms/CareerResumeForm.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -146,48 +146,56 @@
padding-left: 1rem;
}

/* PDF パネルの開閉トグル。展開時はカラム右上に小さく配置する。 */
/* 原本パネルの開閉トグル。展開時はカラム右上にシェブロンだけを配置する。
サイドバーの折りたたみボタン(.sidebarCollapseButton)と同じく、枠なし・
背景なしのシェブロンで表現する。 */
.pdfToggle {
align-self: flex-end;
flex-shrink: 0;
margin-bottom: 0.4rem;
padding: 0.15rem 0.5rem;
font-size: 0.85rem;
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.1rem;
line-height: 1;
border: 1px solid var(--border-input);
border-radius: 4px;
background: var(--bg-input);
color: var(--text-primary);
padding: 0 0.5rem;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}

.pdfToggle:hover {
color: var(--text-primary);
background: var(--ghost-hover);
}

/* 折りたたみ時: 幅を CSS 変数に依らず固定の細いレールにし、トグルを縦書きで全高表示する
/* 折りたたみ時: 幅を固定の細いレールにし、上部にトグルだけを置く
panel 本体は JSX 側で非レンダリングなので、ここはレールの見た目だけを定義する。 */
.pdfColCollapsed {
position: relative;
width: 2.5rem;
align-items: center;
padding-left: 0;
}

/* 折りたたみ時の再展開ボタン。サイドバーの再展開ボタン(.sidebarOpenButton)と同じく
枠付き・角丸・影付きのコンパクトなシェブロンにする。 */
.pdfColCollapsed .pdfToggle {
writing-mode: vertical-rl;
align-self: center;
margin-top: 0.4rem;
margin-bottom: 0;
padding: 0.6rem 0.2rem;
height: 100%;
padding: 0.3rem 0.55rem;
font-size: 1rem;
border: 1px solid var(--border-input);
border-radius: 6px;
background: var(--bg-input);
color: var(--text-primary);
box-shadow: var(--shadow-card);
}

/* 折りたたみレールに表示する取り込み補助エラーのインジケータ。
トグルの上に小さな赤丸として固定し、クリックでパネルを展開する。 */
トグルの下に小さな赤丸として並べ、クリックでパネルを展開する。 */
.collapsedError {
position: absolute;
top: 0.4rem;
align-self: center;
margin-top: 0.4rem;
width: 1.4rem;
height: 1.4rem;
border: none;
Expand Down Expand Up @@ -228,7 +236,7 @@
padding-top: 1rem;
}

/* 縦積みかつ折りたたみ時は、全幅の薄いバーにしてトグルを横書きへ戻す
/* 縦積みかつ折りたたみ時は、全幅の薄いバーにしてトグルを左寄せにする
(細い縦レールのままだと縦積みレイアウトで不自然なため)。 */
.pdfColCollapsed {
width: 100%;
Expand All @@ -238,9 +246,7 @@
}

.pdfColCollapsed .pdfToggle {
writing-mode: horizontal-tb;
align-self: flex-start;
height: auto;
padding: 0.3rem 0.6rem;
margin-top: 0;
}
}
36 changes: 11 additions & 25 deletions frontend/src/components/forms/CareerResumeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import {
getLatestCareerResume,
updateCareerResume,
} from "../../api";
import { IMPORT_ASSIST_MESSAGES, UI_MESSAGES } from "../../constants/messages";
import { UI_MESSAGES } from "../../constants/messages";
import { createInitialCareerForm, mapCareerResumeToForm } from "../../formMappers";
import { useCareerDirty } from "../../hooks/career/useCareerDirty";
import { usePdfPanelLayout } from "../../hooks/career/usePdfPanelLayout";
import { useImportPanelLayout } from "../../hooks/career/useImportPanelLayout";
import { useResumeImportAssist } from "../../hooks/career/useResumeImportAssist";
import { useDocumentForm } from "../../hooks/useDocumentForm";
import { buildCareerPayload } from "../../payloadBuilders";
Expand All @@ -23,7 +23,7 @@ import shared from "../../styles/shared.module.css";
import { ConfirmDialog } from "../ConfirmDialog";
import { Skeleton } from "../ui/Skeleton";
import { PdfPreviewModal } from "./PdfPreviewModal";
import { ResumePdfTracePanel } from "./ResumePdfTracePanel";
import { ResumeSourceTracePanel } from "./ResumeSourceTracePanel";
import layout from "./CareerResumeForm.module.css";
import { CareerBasicInfoSection } from "./sections/CareerBasicInfoSection";
import { CareerExperienceSection } from "./sections/CareerExperienceSection";
Expand All @@ -35,9 +35,8 @@ export function CareerResumeForm() {
// PDF 原本ビュー(右カラム)の折りたたみ状態。折りたたむと入力フォームが全幅に広がる。
const [pdfCollapsed, setPdfCollapsed] = useState(false);
const assist = useResumeImportAssist();
const pdfInputRef = useRef<HTMLInputElement>(null);
const splitRef = useRef<HTMLDivElement>(null);
const { width: pdfWidth, startResize } = usePdfPanelLayout(splitRef);
const { width: pdfWidth, startResize } = useImportPanelLayout(splitRef);
const {
form,
setForm,
Expand Down Expand Up @@ -125,20 +124,7 @@ export function CareerResumeForm() {
<div className={shared.pageHeader}>
<h1>職務経歴書</h1>
<div className={shared.pageHeaderActions}>
{/* PDF 取り込み(従来のヘッダー位置)。選択した PDF を右カラムに原本表示する */}
<input
ref={pdfInputRef}
type="file"
accept="application/pdf"
style={{ display: "none" }}
onChange={assist.handleFileChange}
aria-label={IMPORT_ASSIST_MESSAGES.SELECT_FILE}
/>
<button type="button" className="ghost" onClick={() => pdfInputRef.current?.click()}>
{assist.file
? IMPORT_ASSIST_MESSAGES.RESELECT_FILE
: IMPORT_ASSIST_MESSAGES.SELECT_FILE}
</button>
{/* ファイル取り込みは右カラムの原本ビュー(ドラッグ&ドロップ / クリック)に集約。 */}
<button type="submit" className="primary" disabled={!canSubmit || saving}>
{saveButtonText}
</button>
Expand Down Expand Up @@ -253,7 +239,7 @@ export function CareerResumeForm() {
/>
)}

{/* 右: PDF 原本ビュー(独立スクロール)。文字を選択して入力欄へ流し込む。
{/* 右: 原本ビュー(独立スクロール)。文字を選択して入力欄へ流し込む。
幅は CSS 変数 --pdf-col-width を CSS 側で参照(縦積み時は全幅へ上書き)。
折りたたみ時は細いレールになり、トグルだけを表示する。 */}
<aside className={`${layout.pdfCol} ${pdfCollapsed ? layout.pdfColCollapsed : ""}`}>
Expand All @@ -262,26 +248,26 @@ export function CareerResumeForm() {
className={layout.pdfToggle}
onClick={() => setPdfCollapsed((v) => !v)}
aria-label={
pdfCollapsed ? UI_MESSAGES.PDF_PANEL_EXPAND : UI_MESSAGES.PDF_PANEL_COLLAPSE
pdfCollapsed ? UI_MESSAGES.SOURCE_PANEL_EXPAND : UI_MESSAGES.SOURCE_PANEL_COLLAPSE
}
aria-expanded={!pdfCollapsed}
>
{pdfCollapsed ? "‹ PDF" : ""}
{pdfCollapsed ? "«" : "»"}
</button>
{/* 折りたたみ中でも取り込み補助のエラー(サイズ超過・パース失敗)は隠さない。
クリックでパネルを展開し、全文を ResumePdfTracePanel で表示できるようにする。 */}
クリックでパネルを展開し、全文を ResumeSourceTracePanel で表示できるようにする。 */}
{pdfCollapsed && assist.error && (
<button
type="button"
className={layout.collapsedError}
onClick={() => setPdfCollapsed(false)}
title={assist.error}
aria-label={UI_MESSAGES.PDF_PANEL_EXPAND}
aria-label={UI_MESSAGES.SOURCE_PANEL_EXPAND}
>
!
</button>
)}
{!pdfCollapsed && <ResumePdfTracePanel assist={assist} />}
{!pdfCollapsed && <ResumeSourceTracePanel assist={assist} />}
</aside>
</div>
</div>
Expand Down
Loading
Loading