diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 1995faa9..7acfd81f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -7262,9 +7262,9 @@
}
},
"node_modules/shell-quote": {
- "version": "1.8.3",
- "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
- "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
+ "version": "1.8.4",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
+ "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/frontend/package.json b/frontend/package.json
index 1eb1842e..c03a4f88 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -59,5 +59,8 @@
"vite": "^6.0.5",
"vitest": "^4.1.2",
"wrangler": "^4.90.0"
+ },
+ "overrides": {
+ "shell-quote": "^1.8.4"
}
}
diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx
index 32fcc9ab..5b90c1ca 100644
--- a/frontend/src/components/ConfirmDialog.tsx
+++ b/frontend/src/components/ConfirmDialog.tsx
@@ -1,3 +1,4 @@
+import { UI_MESSAGES } from "../constants/messages";
import styles from "./ConfirmDialog.module.css";
export function ConfirmDialog({
@@ -21,10 +22,10 @@ export function ConfirmDialog({
{message}
diff --git a/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx b/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx
index 7171c8db..ac198a74 100644
--- a/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx
+++ b/frontend/src/components/forms/CareerFormEditors/CareerExperienceEditor.tsx
@@ -1,5 +1,5 @@
import { CAPITAL_UNITS } from "../../../constants";
-import type { CareerClientFieldKey, CareerExperienceFieldKey } from "../../../formTypes";
+import type { CareerExperienceFieldKey, ClientMutationHandlers } from "../../../formTypes";
import type { ExperienceDirty } from "../../../hooks/career/useCareerDirty";
import { useFocusOnMatch } from "../../../hooks/useFocusOnMatch";
import {
@@ -17,8 +17,12 @@ import { DirtyDot } from "../../ui/DirtyDot";
import { PlusIcon } from "../../icons/PlusIcon";
import { ClientEditor } from "./ClientEditor";
-/** CareerExperienceEditor のプロパティ型 */
-type CareerExperienceEditorProps = {
+/**
+ * CareerExperienceEditor のプロパティ型。
+ * 取引先(client)配下のハンドラ群は ClientEditor へ素通しするため
+ * ClientMutationHandlers で ClientEditor と共有する。
+ */
+type CareerExperienceEditorProps = ClientMutationHandlers & {
/** 編集対象の職務経歴データ */
exp: CareerExperienceForm;
/** この職務経歴のインデックス */
@@ -29,27 +33,8 @@ type CareerExperienceEditorProps = {
key: CareerExperienceFieldKey,
value: string | boolean,
) => void;
- /** 取引先フィールド変更ハンドラ */
- onUpdateClientField: (
- expIndex: number,
- clientIndex: number,
- key: CareerClientFieldKey,
- value: string,
- ) => void;
- /** 取引先「取引先なし」切替ハンドラ */
- onUpdateClientHasClient: (expIndex: number, clientIndex: number, value: boolean) => void;
- /** 取引先「休暇」切替ハンドラ */
- onUpdateClientIsVacation: (expIndex: number, clientIndex: number, value: boolean) => void;
- /** 休暇「継続中」切替ハンドラ */
- onUpdateClientVacationIsCurrent: (expIndex: number, clientIndex: number, value: boolean) => void;
/** 取引先追加ハンドラ */
onAddClient: (expIndex: number) => void;
- /** 取引先削除ハンドラ */
- onRemoveClient: (expIndex: number, clientIndex: number) => void;
- /** プロジェクト削除ハンドラ */
- onRemoveProject: (expIndex: number, clientIndex: number, projIndex: number) => void;
- /** プロジェクト編集モーダルを開くハンドラ */
- onOpenProjectModal: (expIndex: number, clientIndex: number, projIndex: number | null) => void;
/** 職務経歴削除ハンドラ */
onRemoveExperience: (index: number) => void;
/** プロジェクトサマリーテキストを生成する関数 */
diff --git a/frontend/src/components/forms/CareerFormEditors/ClientEditor.tsx b/frontend/src/components/forms/CareerFormEditors/ClientEditor.tsx
index 4e54f0c4..f0a147e9 100644
--- a/frontend/src/components/forms/CareerFormEditors/ClientEditor.tsx
+++ b/frontend/src/components/forms/CareerFormEditors/ClientEditor.tsx
@@ -1,4 +1,4 @@
-import type { CareerClientFieldKey } from "../../../formTypes";
+import type { ClientMutationHandlers } from "../../../formTypes";
import type { ClientDirty } from "../../../hooks/career/useCareerDirty";
import { useFocusOnMatch } from "../../../hooks/useFocusOnMatch";
import {
@@ -14,8 +14,8 @@ import { DeleteIconButton } from "../../ui/DeleteIconButton";
import { DirtyDot } from "../../ui/DirtyDot";
import { PlusIcon } from "../../icons/PlusIcon";
-/** ClientEditor のプロパティ型 */
-type ClientEditorProps = {
+/** ClientEditor のプロパティ型(取引先配下のハンドラ群は ClientMutationHandlers で共有) */
+type ClientEditorProps = ClientMutationHandlers & {
/** 編集対象の取引先データ */
client: CareerClientForm;
/** 親となる職務経歴のインデックス */
@@ -24,25 +24,6 @@ type ClientEditorProps = {
clientIndex: number;
/** この取引先の dirty 情報。未指定なら 🔴 表示なし。 */
dirty?: ClientDirty;
- /** 取引先フィールド変更ハンドラ */
- onUpdateClientField: (
- expIndex: number,
- clientIndex: number,
- key: CareerClientFieldKey,
- value: string,
- ) => void;
- /** 取引先「取引先なし」切替ハンドラ */
- onUpdateClientHasClient: (expIndex: number, clientIndex: number, value: boolean) => void;
- /** 取引先「休暇」切替ハンドラ */
- onUpdateClientIsVacation: (expIndex: number, clientIndex: number, value: boolean) => void;
- /** 休暇「継続中」切替ハンドラ */
- onUpdateClientVacationIsCurrent: (expIndex: number, clientIndex: number, value: boolean) => void;
- /** プロジェクト削除ハンドラ */
- onRemoveProject: (expIndex: number, clientIndex: number, projIndex: number) => void;
- /** プロジェクト編集モーダルを開くハンドラ */
- onOpenProjectModal: (expIndex: number, clientIndex: number, projIndex: number | null) => void;
- /** 取引先削除ハンドラ */
- onRemoveClient: (expIndex: number, clientIndex: number) => void;
/** プロジェクトサマリーテキストを生成する関数 */
projectSummary: (proj: CareerProjectForm) => string;
/** バリデーション失敗フィールドの位置情報(休暇期間のフォーカス・赤枠用) */
diff --git a/frontend/src/components/forms/CareerResumeForm.test.tsx b/frontend/src/components/forms/CareerResumeForm.test.tsx
new file mode 100644
index 00000000..8d5f0f18
--- /dev/null
+++ b/frontend/src/components/forms/CareerResumeForm.test.tsx
@@ -0,0 +1,78 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { Provider } from "react-redux";
+import { configureStore } from "@reduxjs/toolkit";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+import { CareerResumeForm } from "./CareerResumeForm";
+import { LoginPromptContext } from "../auth/loginPromptContext";
+import { ToastProvider } from "../ui/toast";
+import formCacheReducer from "../../store/formCacheSlice";
+import { UI_MESSAGES, VALIDATION_MESSAGES } from "../../constants/messages";
+
+// 原本ビューは react-pdf / pdf.js を遅延ロードするため、フォームの単体テストではスタブに差し替える。
+vi.mock("./ResumeSourceTracePanel", () => ({
+ ResumeSourceTracePanel: () => ,
+}));
+
+// マスタデータ取得は実 fetch を避けて空配列を返す(匿名分岐の検証には不要なため)。
+vi.mock("../../api/master-data", () => ({
+ getQualifications: vi.fn().mockResolvedValue([]),
+ getTechnologyStacks: vi.fn().mockResolvedValue([]),
+}));
+
+/** formCache だけを持つ最小ストアを作る。 */
+function makeStore() {
+ return configureStore({ reducer: { formCache: formCacheReducer } });
+}
+
+/** 未ログイン状態の CareerResumeForm を描画し、requestLogin スパイを返す。 */
+function renderAnonymousForm() {
+ const requestLogin = vi.fn();
+ render(
+
+
+
+
+
+
+ ,
+ );
+ return { requestLogin };
+}
+
+describe("CareerResumeForm(未ログインの保存導線)", () => {
+ beforeEach(() => {
+ // ドラフト退避(sessionStorage)がテスト間で漏れないようにする。
+ window.sessionStorage.clear();
+ });
+
+ /** 保存ボタンはマスタデータ取得が終わるまで非活性なので、活性化を待ってから返す。 */
+ async function waitForEnabledSaveButton(): Promise {
+ const button = screen.getByRole("button", { name: UI_MESSAGES.FORM_SAVE });
+ await waitFor(() => expect(button).toBeEnabled());
+ return button;
+ }
+
+ it("氏名が空のまま保存すると、ログインを促さず氏名必須エラーを表示する", async () => {
+ const { requestLogin } = renderAnonymousForm();
+
+ fireEvent.click(await waitForEnabledSaveButton());
+
+ expect(screen.getByText(VALIDATION_MESSAGES.FULL_NAME_REQUIRED)).toBeInTheDocument();
+ expect(requestLogin).not.toHaveBeenCalled();
+ });
+
+ it("氏名を入力して保存すると、バリデーションを通してログイン促進を呼ぶ", async () => {
+ const { requestLogin } = renderAnonymousForm();
+
+ const saveButton = await waitForEnabledSaveButton();
+ // 氏名入力はプレースホルダ文言ではなくラベル(ロール + アクセシブル名)で特定する。
+ fireEvent.change(screen.getByRole("textbox", { name: /氏名/ }), {
+ target: { value: "山田 太郎" },
+ });
+ fireEvent.click(saveButton);
+
+ expect(requestLogin).toHaveBeenCalledTimes(1);
+ expect(screen.queryByText(VALIDATION_MESSAGES.FULL_NAME_REQUIRED)).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/forms/CareerResumeForm.tsx b/frontend/src/components/forms/CareerResumeForm.tsx
index 8d109f31..d930b1ac 100644
--- a/frontend/src/components/forms/CareerResumeForm.tsx
+++ b/frontend/src/components/forms/CareerResumeForm.tsx
@@ -298,8 +298,8 @@ export function CareerResumeForm({ isAuthenticated }: { isAuthenticated: boolean
<>
{showDeleteConfirm && (
setShowDeleteConfirm(false)}
confirming={deleting}
diff --git a/frontend/src/constants/messages.ts b/frontend/src/constants/messages.ts
index 14ff1b87..a5d277cc 100644
--- a/frontend/src/constants/messages.ts
+++ b/frontend/src/constants/messages.ts
@@ -156,6 +156,17 @@ export const UI_MESSAGES = {
RESUME_DELETE_VACATION: "休業を削除",
RESUME_DELETE_PROJECT: "プロジェクトを削除",
RESUME_DELETE_QUALIFICATION: "資格を削除",
+ // 職務経歴書「データを削除」確認ダイアログ(ConfirmDialog の message / confirmLabel)
+ RESUME_DELETE_CONFIRM:
+ "職務経歴書のデータを全て削除します。この操作は取り消せません。本当に削除しますか?",
+ RESUME_DELETE_CONFIRM_LABEL: "削除する",
+ // 確認ダイアログ共通(ConfirmDialog のボタン文言)
+ CONFIRM_DELETING: "削除中...",
+ CONFIRM_CANCEL: "キャンセル",
+ // ドキュメントフォームの保存ボタン文言(useDocumentForm の saveButtonText)
+ FORM_SAVING: "保存中...",
+ FORM_UPDATE: "更新する",
+ FORM_SAVE: "保存する",
} as const;
/** 外部リンク URL(GitHub リポジトリ / Issue 報告先など)の SSoT */
diff --git a/frontend/src/formTypes.ts b/frontend/src/formTypes.ts
index 945531dd..611a78ed 100644
--- a/frontend/src/formTypes.ts
+++ b/frontend/src/formTypes.ts
@@ -17,3 +17,30 @@ export type CareerClientFieldKey =
| "vacation_description";
export type CareerProjectFieldKey = "name" | "role" | "description";
export type CareerProjectPeriodFieldKey = "start_date" | "end_date" | "is_current";
+
+/**
+ * 取引先(client)配下のミューテーションハンドラ群。
+ * CareerExperienceEditor が ClientEditor へ素通しするため、両 Props 型で共有する
+ * (片方だけ引数を足したときの取り違えを型で防ぐ)。
+ */
+export type ClientMutationHandlers = {
+ /** 取引先フィールド変更ハンドラ */
+ onUpdateClientField: (
+ expIndex: number,
+ clientIndex: number,
+ key: CareerClientFieldKey,
+ value: string,
+ ) => void;
+ /** 取引先「取引先なし」切替ハンドラ */
+ onUpdateClientHasClient: (expIndex: number, clientIndex: number, value: boolean) => void;
+ /** 取引先「休暇」切替ハンドラ */
+ onUpdateClientIsVacation: (expIndex: number, clientIndex: number, value: boolean) => void;
+ /** 休暇「継続中」切替ハンドラ */
+ onUpdateClientVacationIsCurrent: (expIndex: number, clientIndex: number, value: boolean) => void;
+ /** プロジェクト削除ハンドラ */
+ onRemoveProject: (expIndex: number, clientIndex: number, projIndex: number) => void;
+ /** プロジェクト編集モーダルを開くハンドラ */
+ onOpenProjectModal: (expIndex: number, clientIndex: number, projIndex: number | null) => void;
+ /** 取引先削除ハンドラ */
+ onRemoveClient: (expIndex: number, clientIndex: number) => void;
+};
diff --git a/frontend/src/hooks/career/useCareerExperienceMutators.ts b/frontend/src/hooks/career/useCareerExperienceMutators.ts
index b88e47d0..d6b668d3 100644
--- a/frontend/src/hooks/career/useCareerExperienceMutators.ts
+++ b/frontend/src/hooks/career/useCareerExperienceMutators.ts
@@ -10,6 +10,7 @@ import type {
CareerExperienceFieldKey,
} from "../../formTypes";
import type {
+ CareerClientForm,
CareerExperienceForm,
CareerFormState,
CareerProjectForm,
@@ -24,23 +25,48 @@ export function useCareerExperienceMutators(
experiences: CareerExperienceForm[],
setForm: Dispatch>,
) {
+ /**
+ * 指定 index の experience を updater で書き換える共通ヘルパ。
+ * 三階層 immutable 更新の「該当 1 件だけ map で差し替える」定型をここに集約する。
+ */
+ const updateExperienceAt = (
+ expIndex: number,
+ updater: (exp: CareerExperienceForm) => CareerExperienceForm,
+ ) => {
+ setForm((prev) => ({
+ ...prev,
+ experiences: prev.experiences.map((exp, ei) => (ei === expIndex ? updater(exp) : exp)),
+ }));
+ };
+
+ /**
+ * 指定座標(experience → client)の client を updater で書き換える共通ヘルパ。
+ * updateExperienceAt の上に client 1 件差し替えを重ねる。
+ */
+ const updateClientAt = (
+ expIndex: number,
+ clientIndex: number,
+ updater: (client: CareerClientForm) => CareerClientForm,
+ ) => {
+ updateExperienceAt(expIndex, (exp) => ({
+ ...exp,
+ clients: exp.clients.map((c, ci) => (ci === clientIndex ? updater(c) : c)),
+ }));
+ };
+
/** experience フィールド変更ハンドラ */
const updateExperienceField = (
index: number,
key: CareerExperienceFieldKey,
value: string | boolean,
) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, i) => {
- if (i !== index) return exp;
- if (key === "is_current") {
- const isCurrent = Boolean(value);
- return { ...exp, is_current: isCurrent, end_date: isCurrent ? "" : exp.end_date };
- }
- return { ...exp, [key]: value };
- }),
- }));
+ updateExperienceAt(index, (exp) => {
+ if (key === "is_current") {
+ const isCurrent = Boolean(value);
+ return { ...exp, is_current: isCurrent, end_date: isCurrent ? "" : exp.end_date };
+ }
+ return { ...exp, [key]: value };
+ });
};
/** client フィールド変更ハンドラ */
@@ -50,50 +76,21 @@ export function useCareerExperienceMutators(
key: CareerClientFieldKey,
value: string,
) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, ei) => {
- if (ei !== expIndex) return exp;
- return {
- ...exp,
- clients: exp.clients.map((c, ci) =>
- ci === clientIndex ? { ...c, [key]: value } : c,
- ),
- };
- }),
- }));
+ updateClientAt(expIndex, clientIndex, (c) => ({ ...c, [key]: value }));
};
/** 「取引先なし」フラグ切り替えハンドラ */
const updateClientHasClient = (expIndex: number, clientIndex: number, value: boolean) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, ei) => {
- if (ei !== expIndex) return exp;
- return {
- ...exp,
- clients: exp.clients.map((c, ci) =>
- ci === clientIndex ? { ...c, has_client: value, name: value ? c.name : "" } : c,
- ),
- };
- }),
+ updateClientAt(expIndex, clientIndex, (c) => ({
+ ...c,
+ has_client: value,
+ name: value ? c.name : "",
}));
};
/** 「休暇」フラグ切り替えハンドラ */
const updateClientIsVacation = (expIndex: number, clientIndex: number, value: boolean) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, ei) => {
- if (ei !== expIndex) return exp;
- return {
- ...exp,
- clients: exp.clients.map((c, ci) =>
- ci === clientIndex ? { ...c, is_vacation: value } : c,
- ),
- };
- }),
- }));
+ updateClientAt(expIndex, clientIndex, (c) => ({ ...c, is_vacation: value }));
};
/** 休暇の「継続中」フラグ切り替えハンドラ(継続中なら終了年月をクリア) */
@@ -102,75 +99,40 @@ export function useCareerExperienceMutators(
clientIndex: number,
value: boolean,
) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, ei) => {
- if (ei !== expIndex) return exp;
- return {
- ...exp,
- clients: exp.clients.map((c, ci) =>
- ci === clientIndex
- ? {
- ...c,
- vacation_is_current: value,
- vacation_end_date: value ? "" : c.vacation_end_date,
- }
- : c,
- ),
- };
- }),
+ updateClientAt(expIndex, clientIndex, (c) => ({
+ ...c,
+ vacation_is_current: value,
+ vacation_end_date: value ? "" : c.vacation_end_date,
}));
};
/** 取引先追加ハンドラ */
const addClient = (expIndex: number) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, ei) =>
- ei === expIndex
- ? { ...exp, clients: [...exp.clients, { ...blankCareerClient }] }
- : exp,
- ),
+ updateExperienceAt(expIndex, (exp) => ({
+ ...exp,
+ clients: [...exp.clients, { ...blankCareerClient }],
}));
};
- /** 取引先削除ハンドラ */
+ /** 取引先削除ハンドラ(最後の 1 件は空レコードへリセット) */
const removeClient = (expIndex: number, clientIndex: number) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, ei) => {
- if (ei !== expIndex) return exp;
- return {
- ...exp,
- clients:
- exp.clients.length === 1
- ? [{ ...blankCareerClient }]
- : exp.clients.filter((_, ci) => ci !== clientIndex),
- };
- }),
+ updateExperienceAt(expIndex, (exp) => ({
+ ...exp,
+ clients:
+ exp.clients.length === 1
+ ? [{ ...blankCareerClient }]
+ : exp.clients.filter((_, ci) => ci !== clientIndex),
}));
};
- /** プロジェクト削除ハンドラ */
+ /** プロジェクト削除ハンドラ(最後の 1 件は空レコードへリセット) */
const removeProject = (expIndex: number, clientIndex: number, projIndex: number) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, ei) => {
- if (ei !== expIndex) return exp;
- return {
- ...exp,
- clients: exp.clients.map((c, ci) => {
- if (ci !== clientIndex) return c;
- return {
- ...c,
- projects:
- c.projects.length === 1
- ? [{ ...blankCareerProject }]
- : c.projects.filter((_, pi) => pi !== projIndex),
- };
- }),
- };
- }),
+ updateClientAt(expIndex, clientIndex, (c) => ({
+ ...c,
+ projects:
+ c.projects.length === 1
+ ? [{ ...blankCareerProject }]
+ : c.projects.filter((_, pi) => pi !== projIndex),
}));
};
@@ -223,25 +185,11 @@ export function useCareerExperienceMutators(
projIndex: number | null,
project: CareerProjectForm,
) => {
- setForm((prev) => ({
- ...prev,
- experiences: prev.experiences.map((exp, ei) => {
- if (ei !== expIndex) return exp;
- return {
- ...exp,
- clients: exp.clients.map((c, ci) => {
- if (ci !== clientIndex) return c;
- if (projIndex === null) {
- return { ...c, projects: [...c.projects, project] };
- }
- return {
- ...c,
- projects: c.projects.map((p, pi) => (pi === projIndex ? project : p)),
- };
- }),
- };
- }),
- }));
+ updateClientAt(expIndex, clientIndex, (c) =>
+ projIndex === null
+ ? { ...c, projects: [...c.projects, project] }
+ : { ...c, projects: c.projects.map((p, pi) => (pi === projIndex ? project : p)) },
+ );
};
return {
diff --git a/frontend/src/hooks/useDocumentForm.ts b/frontend/src/hooks/useDocumentForm.ts
index 5e68f9e6..03c52a44 100644
--- a/frontend/src/hooks/useDocumentForm.ts
+++ b/frontend/src/hooks/useDocumentForm.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
-import { FALLBACK_MESSAGES } from "../constants/messages";
+import { FALLBACK_MESSAGES, UI_MESSAGES } from "../constants/messages";
import { useAppDispatch, useAppSelector } from "../store";
import {
clearCache,
@@ -172,8 +172,8 @@ export function useDocumentForm {
- if (saving) return "保存中...";
- return documentId ? "更新する" : "保存する";
+ if (saving) return UI_MESSAGES.FORM_SAVING;
+ return documentId ? UI_MESSAGES.FORM_UPDATE : UI_MESSAGES.FORM_SAVE;
}, [documentId, saving]);
/**