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/common/duplication.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ CLAUDE.md にある通り「PEP8 を守るな、PEP8 を理解した上で抽象
2. **純粋関数 / 文字列変換 / 日付処理** → `frontend/src/utils/`
3. **API クライアントの共通パターン** → `frontend/src/api/client.ts` のラッパー追加
4. **フォーム入出力変換** → `frontend/src/formMappers.ts` / `frontend/src/payloadBuilders.ts`
5. **共通 UI コンポーネント** → `frontend/src/components/ui/`(ErrorToast, Skeleton 等の配置例)
5. **共通 UI コンポーネント** → `frontend/src/components/ui/`(toast/, Skeleton 等の配置例)
6. **型定義** → `frontend/src/types.ts` / `frontend/src/formTypes.ts`

### Infra (OpenTofu)
Expand Down
2 changes: 1 addition & 1 deletion .claude/rules/frontend/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ frontend/src/
│ ├── auth/ # LoginForm, RegisterForm
│ ├── blog/ # BlogPage
│ ├── icons/ # アイコンコンポーネント(Bell, Eye, Qiita, Zenn 等)
│ └── ui/ # 汎用 UI(ErrorToast, InlineSpinner, Skeleton, AsyncTaskLoading)
│ └── ui/ # 汎用 UI(toast/(ToastProvider/useToast), InlineSpinner, Skeleton, AsyncTaskLoading)
├── hooks/
│ ├── useDocumentForm.ts # フォーム CRUD の共通フック(loading / saving / error 管理)
│ ├── useMasterData.ts # マスタデータのモジュールレベルキャッシュ
Expand Down
69 changes: 69 additions & 0 deletions docs/adr/0009-frontend-toast-notification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# ADR-0009: フロントエンドの一時通知をトースト方式に統一する

## ステータス

Accepted

## コンテキスト

職務経歴書(Resume)・ブログ連携・ログイン・GitHub 連携の各画面では、操作の成功/失敗メッセージを
`{error && <p>...}` / `{success && <p>...}` のようにフォーム内へインライン表示していた。

この方式には次の課題があった。

- メッセージがレイアウト内に流し込まれるため、スクロール位置によっては見えない・気付かれない。
- 表示位置・スタイルが画面ごとにバラバラ(`shared.error` / `BlogPage.module.css` / `LoginForm.module.css` 等)。
- 成功と失敗で消える/残るの挙動を制御できず、成功メッセージが残り続ける。
- GitHub 連携だけは `ErrorToast` コンポーネントでカード表示していたが、他画面と統一されていなかった。

操作フィードバックを画面横断で一貫した「トースト(画面隅に一時表示する通知)」に統一したい。

## 決定内容

- 外部ライブラリを導入せず、**自前の Toast 基盤**を `frontend/src/components/ui/toast/` に実装する。
- `ToastProvider`(Context + スタック state)を `main.tsx` の最上位(全ルートを覆う位置)に 1 つ設置。
- `useToast()` が `showSuccess(message)` / `showError(string | AppErrorState)` / `dismiss(id)` を提供。
- `ToastViewport` は `createPortal` で body 直下に固定表示し、各ページのスタッキングコンテキスト
(`LoadingOverlay` 等)に埋もれないようにする。
- **挙動**: 成功トーストは一定時間(`SUCCESS_TOAST_DURATION_MS`)で自動消去、エラートーストは
自動消去せず × ボタンで手動クローズする。
- **既存フックは変更しない**。`useDocumentForm` / `usePdfActions` / `useBlogAccountManager` /
`useAsyncTaskPage` は従来通り error/success を state として保持し、表示層の薄いブリッジ
(`useMessageToast` / `useAppErrorToast`)でトーストへ橋渡しする。これによりフックを Provider に
依存させず、既存のユニットテストをそのまま維持する。
- 旧 `ErrorToast` コンポーネントは `ToastItem` のエラー表示(`AppErrorState` の回復アクション・
エラー ID 表示を含む)へ統合し、削除する。
- **適用範囲**: 「ページ全体の成功/失敗」のみをトースト化する。項目バリデーション(保存前の
入力チェック)とファイル取り込み補助パネルのエラーは、フォーカス・赤枠・パネル状態と密結合した
文脈情報のため従来通りインライン表示を維持する。

## 代替案

- **トーストライブラリ(sonner / react-hot-toast)導入**: Provider・自動消去・スタックが即利用できるが、
依存とバンドルサイズが増え、既存の `ErrorToast` / `ERROR_CONFIG`(回復アクション)資産との橋渡しが
別途必要になる。一時通知のためだけに依存を増やす利得が小さいと判断し却下。
- **フック内部から直接トーストを発火**(`useToast` をフックが呼ぶ): 表示と state の二重持ちを解消できるが、
対象フックすべてが ToastProvider 必須となり、既存ユニットテスト(error/success を assert)を
全面的に書き換える必要がある。契約変更の影響が大きいため、表示層ブリッジ方式を採用した。

## トレードオフ・既知のリスク

- 表示層ブリッジ(`useMessageToast`)は state の文字列変化を `useEffect` で監視する方式のため、
StrictMode の effect 二重実行や同一文言の連続表示を `ref` でガードしている。この前提が崩れると
二重表示や表示漏れの恐れがあるため、ガードはテストで固定する。
- フックは「表示されない error/success 文字列」を保持し続ける(presentation はブリッジが読む)。
state の責務が二段になる点は許容する。
- トーストは画面隅に出るため、長文メッセージや同時多発の通知ではスタックが縦に伸びる。現状は
自動消去(成功)と手動クローズ(エラー)で許容範囲とする。

## 将来の移行条件

- 通知の同時多発・グルーピング・アクション付き通知(Undo 等)の要件が増えた場合は、専用ライブラリ
(sonner 等)への移行を再検討する。
- 通知の永続化(既読管理)が必要になった場合は `NotificationBell` 系の仕組みと統合を検討する。

## 関連リンク

- 実装: `frontend/src/components/ui/toast/`
- メッセージ SSoT: `frontend/src/constants/messages.ts`、ルール `.claude/rules/frontend/messages.md`
- エラーコード連携: `frontend/src/constants/errorMessages.ts`(`ERROR_CONFIG`)
10 changes: 0 additions & 10 deletions frontend/src/components/auth/LoginForm.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,3 @@
font-size: 1rem;
color: var(--text-secondary);
}

.errorMessage {
margin: 0;
color: var(--danger-text);
background: var(--danger-bg);
border: 1px solid var(--danger-border);
border-radius: 6px;
padding: 0.75rem 1rem;
font-size: 0.9rem;
}
5 changes: 4 additions & 1 deletion frontend/src/components/auth/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from "react";

import { initiateGitHubLogin } from "../../api";
import { useMessageToast } from "../ui/toast";
import shared from "../../styles/shared.module.css";
import styles from "./LoginForm.module.css";

Expand All @@ -11,6 +12,9 @@ export function LoginForm({
}) {
const [isLoading, setIsLoading] = useState(false);

// OAuth 失敗などのログインエラーをトーストで通知する(手動クローズ)。
useMessageToast(githubError, "error");

const handleGitHubLogin = async () => {
setIsLoading(true);
try {
Expand All @@ -31,7 +35,6 @@ export function LoginForm({
) : (
<div className={styles.loginBox}>
<h1 className={styles.loginTitle}>DevForge</h1>
{githubError && <p className={styles.errorMessage}>{githubError}</p>}
<div className={styles.loginActions}>
<button
type="button"
Expand Down
16 changes: 0 additions & 16 deletions frontend/src/components/blog/BlogPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -296,22 +296,6 @@
font-size: 0.9rem;
}

/* ── エラー・メッセージ ────────────────────────────────────── */

.errorMessage {
margin: 0.5rem 0;
color: var(--error);
font-weight: 600;
font-size: 0.9rem;
}

.successMessage {
margin: 0.5rem 0;
color: var(--success);
font-weight: 600;
font-size: 0.9rem;
}

/* ── レスポンシブ ─────────────────────────────────────────── */

@media (max-width: 768px) {
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/components/blog/BlogPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { BlogScoreCard } from "./BlogScoreCard";
import { BlogPlatformList } from "./BlogPlatformList";
import { BlogArticleList } from "./BlogArticleList";
import { InlineSpinner } from "../ui/InlineSpinner";
import { useMessageToast } from "../ui/toast";
import shared from "../../styles/shared.module.css";
import styles from "./BlogPage.module.css";

type PlatformFilter = "all" | "zenn" | "note" | "qiita";

Expand All @@ -32,6 +32,10 @@ export function BlogPage() {
handleDelete,
} = useBlogAccountManager(filter);

// アカウント連携/同期/解除の成否をトーストで通知する(成功は自動消去、失敗は手動クローズ)。
useMessageToast(success, "success");
useMessageToast(accountError, "error");

if (loading) {
return (
<>
Expand All @@ -52,9 +56,6 @@ export function BlogPage() {
</div>

<div className={shared.pageBody}>
{accountError && <p className={styles.errorMessage}>{accountError}</p>}
{success && <p className={styles.successMessage}>{success}</p>}

<BlogPlatformList
accountMap={accountMap}
draftUsernames={draftUsernames}
Expand Down
36 changes: 24 additions & 12 deletions frontend/src/components/forms/CareerResumeForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
getLatestCareerResume,
updateCareerResume,
} from "../../api";
import { UI_MESSAGES } from "../../constants/messages";
import { SUCCESS_MESSAGES, UI_MESSAGES } from "../../constants/messages";
import { createInitialCareerForm, mapCareerResumeToForm } from "../../formMappers";
import { useCareerDirty } from "../../hooks/career/useCareerDirty";
import { useImportPanelLayout } from "../../hooks/career/useImportPanelLayout";
Expand All @@ -23,6 +23,7 @@ import { buildCareerChanges } from "../../utils/careerDiff";
import type { CareerTextFieldKey } from "../../formTypes";
import { useQualifications, useTechnologyStacks } from "../../hooks/useMasterData";
import { usePdfActions } from "../../hooks/usePdfActions";
import { useMessageToast } from "../ui/toast";
import shared from "../../styles/shared.module.css";
import { ConfirmDialog } from "../ConfirmDialog";
import { CareerDiffModal } from "./CareerDiffModal";
Expand Down Expand Up @@ -54,8 +55,6 @@ export function CareerResumeForm() {
deleting,
error: formError,
success: formSuccess,
setError,
setSuccess,
save,
deleteDoc,
saveButtonText,
Expand All @@ -67,10 +66,17 @@ export function CareerResumeForm() {
deleteDocument: deleteCareerResume,
buildPayload: buildCareerPayload,
mapResponseToForm: mapCareerResumeToForm,
successMessage: "職務経歴書を保存しました。PDF出力できます。",
successMessage: SUCCESS_MESSAGES.CAREER_SAVED,
cacheKey: "career",
});

/**
* 保存前バリデーション(項目バリデーション)のメッセージ。
* 保存/削除/PDF などの非同期処理の成否はトーストで通知するが、
* 入力エラーは該当フィールドのフォーカス・赤枠とセットでフォーム内にインライン表示する。
*/
const [validationError, setValidationError] = useState<string | null>(null);

const { items: techStackOptions, loading: techLoading } = useTechnologyStacks();
const { items: qualificationOptions, loading: qualLoading } = useQualifications();
const qualificationNames = qualificationOptions.map((item) => item.name);
Expand Down Expand Up @@ -105,9 +111,14 @@ export function CareerResumeForm() {
getPdfBlobUrl: getCareerResumePdfBlobUrl,
});

/** PDF アクションまたはフォーム保存のエラー・成功メッセージを統合して表示する */
const error = pdfError ?? formError ?? null;
const success = pdfSuccess ?? formSuccess;
// PDF アクションとフォーム保存/削除の成否を、チャンネルごとに独立してトーストで通知する。
// pdf と form を `??` で統合すると、片方の値が残っている間にもう片方が更新されても
// 統合値が変化せずトーストが出ないため、それぞれ個別に橋渡しする。
// 成功は自動消去、失敗は手動クローズ(ブリッジ内で variant 別に制御)。
useMessageToast(formSuccess, "success");
useMessageToast(formError, "error");
useMessageToast(pdfSuccess, "success");
useMessageToast(pdfError, "error");

/** Skeleton 表示・入力ロックの統合フラグ */
const formLocked = loading;
Expand All @@ -130,13 +141,15 @@ export function CareerResumeForm() {
const setFormAndClearFocus = useCallback<Dispatch<SetStateAction<CareerFormState>>>(
(action) => {
setFocusTarget(null);
setValidationError(null);
setForm(action);
},
[setForm],
);

const onChangeField = (key: CareerTextFieldKey, value: string) => {
setFocusTarget(null);
setValidationError(null);
setForm((prev) => ({ ...prev, [key]: value }));
};

Expand All @@ -145,12 +158,12 @@ export function CareerResumeForm() {
// 保存前にフォーム全体を検証し、最初のエラーフィールドへフォーカスする。
const validation = validateCareerForm(form);
if (validation) {
setError(validation.message);
setSuccess(null);
setValidationError(validation.message);
focusNonceRef.current += 1;
setFocusTarget({ locator: validation.locator, nonce: focusNonceRef.current });
return;
}
setValidationError(null);
setFocusTarget(null);
// 変更が無ければ確認を挟まずそのまま保存。変更があれば確認ダイアログを開く。
if (changes.length === 0) {
Expand Down Expand Up @@ -221,7 +234,7 @@ export function CareerResumeForm() {
<button
type="button"
onClick={() =>
resumeId && onDownloadPdf(resumeId, "職務経歴書PDFをダウンロードしました。")
resumeId && onDownloadPdf(resumeId, SUCCESS_MESSAGES.CAREER_PDF_DOWNLOADED)
}
disabled={!resumeId || downloading || formLocked}
>
Expand Down Expand Up @@ -258,8 +271,7 @@ export function CareerResumeForm() {
>
{/* 左: 入力フォーム(選択中フィールドは緑枠 = import-assign-form の :focus CSS) */}
<div className={`${shared.form} import-assign-form ${layout.formCol}`}>
{error && <p className={shared.error}>{error}</p>}
{success && <p className={shared.success}>{success}</p>}
{validationError && <p className={shared.error}>{validationError}</p>}

{/* 基本情報: 氏名・職務要約 */}
<CareerBasicInfoSection
Expand Down
14 changes: 4 additions & 10 deletions frontend/src/components/github-link/GitHubLinkDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
toAppError,
type GitHubLinkResponse,
} from "../../api";
import { ErrorToast } from "../ui/ErrorToast";
import { InlineSpinner } from "../ui/InlineSpinner";
import { AsyncTaskLoading } from "../ui/AsyncTaskLoading";
import { useAppErrorToast } from "../ui/toast";
import { FALLBACK_MESSAGES, LOADING_MESSAGES, UI_MESSAGES } from "../../constants/messages";
import { useAsyncTaskPage } from "../../hooks/useAsyncTaskPage";
import { ContributionHeatmap } from "./ContributionHeatmap";
Expand Down Expand Up @@ -48,6 +48,9 @@ export function GitHubLinkDashboard() {
fetchProgress: getGitHubLinkProgress,
});

// 連携実行・ポーリング失敗のエラー(AppErrorState)をトーストで通知する(回復アクション付き・手動クローズ)。
useAppErrorToast(error);

/**
* GitHub 連携を実行する(非同期バックグラウンド)。
* サイドバーから渡された includeForks を使う。
Expand Down Expand Up @@ -89,15 +92,6 @@ export function GitHubLinkDashboard() {
// ── 入力 / 結果フェーズ ─────────────────────────────────────────
return (
<div className={styles.dashboard}>
{error && (
<ErrorToast
code={error.code}
message={error.message}
action={error.action}
errorId={error.errorId}
/>
)}

{!result ? (
<div className={styles.emptyState}>
<p>{UI_MESSAGES.GITHUB_LINK_EMPTY}</p>
Expand Down
50 changes: 0 additions & 50 deletions frontend/src/components/ui/ErrorToast.module.css

This file was deleted.

Loading
Loading