このPlaybookで作るもの
問い合わせ管理画面で実現する体験は次のとおりです。
- 問い合わせ一覧を開く
- 問い合わせ詳細を見る
- キーワードで検索する
- ステータス・優先度・カテゴリで絞り込む
- 並び替える
- 詳細から元の絞り込み結果へ戻る
- 0 件時に条件を解除できる
- Playwright E2E で主要フローを守る
完成例
完成例は
/contact-requestsで確認できます。検索、フィルタ、ソート、詳細復帰、0 件時の導線まで含めて実装済みです。
完成例ページは
/builds/contact-requestsにあります。
5 つのフェーズに分けて、この画面を段階的に構築します。
| フェーズ | ゴール |
|---|---|
| Phase 1 | 一覧と詳細が表示できる |
| Phase 2 | 検索・フィルタ・ソートで絞り込める |
| Phase 3 | URL で状態を管理し、詳細から一覧へ戻れる |
| Phase 4 | E2E テストで主要フローを保護する |
| Phase 5 | Playbook と Build で見せ方を整理する |
実装の最終形はこのファイル構成になります。
| ファイル | 役割 |
|---|---|
src/lib/contact-requests/data.ts | データ定義・型・取得関数 |
src/lib/contact-requests/query.ts | URL クエリのパース・ビルド・open redirect 検証 |
src/lib/contact-requests/filters.ts | フィルタ・ソート純粋関数 |
src/app/contact-requests/page.tsx | 一覧ページ(サーバーコンポーネント) |
src/app/contact-requests/[id]/page.tsx | 詳細ページ(from パラメータ対応) |
src/components/contact-requests/ContactFilterPanel.tsx | サイドバーフィルタ(クライアント) |
tests/contact-requests-flow.spec.ts | Playwright E2E テスト |
Phase 1: 一覧・詳細の土台を作る
ゴール: 固定データで、問い合わせ管理を画面として成立させる。
データ定義
まず型とデータを定義します。ContactStatus / ContactPriority / ContactCategory は union type にしておくことで、後から追加するフィルタ・バッジ処理で exhaustive check が効きます。
// src/lib/contact-requests/data.ts
export type ContactStatus = "open" | "in_progress" | "resolved" | "closed";
export type ContactPriority = "high" | "medium" | "low";
export type ContactCategory = "bug" | "feature" | "how_to" | "other";
export type ContactRequest = {
id: string;
subject: string;
body: string;
status: ContactStatus;
priority: ContactPriority;
category: ContactCategory;
requester: string;
createdAt: string;
updatedAt: string;
};
固定データ CR-001〜CR-005 を配列で定義し、getAllContactRequests / getContactRequestById / getAllContactRequestIds の 3 関数を用意します。
一覧ページ
サーバーコンポーネントでデータを取得し、カードを並べるだけから始めます。
// src/app/contact-requests/page.tsx
export default async function ContactRequestsPage({ searchParams }: Props) {
const all = getAllContactRequests();
return (
<div>
<h1>問い合わせ管理</h1>
<ul>
{all.map((req) => (
<li key={req.id}>
<Link href={`/contact-requests/${req.id}`}>
{req.subject}
</Link>
</li>
))}
</ul>
</div>
);
}
詳細ページ
generateStaticParams で全 ID を静的生成し、notFound() で存在しない ID に対応します。
// src/app/contact-requests/[id]/page.tsx
export async function generateStaticParams() {
return getAllContactRequestIds().map((id) => ({ id }));
}
export default async function ContactRequestDetailPage({ params }: Props) {
const { id } = await params;
const req = getContactRequestById(id);
if (!req) notFound();
return (
<article>
<Link href="/contact-requests">問い合わせ一覧に戻る</Link>
<h1>{req.subject}</h1>
<p>{req.body}</p>
</article>
);
}
Phase 1 完了条件: 一覧に複数の問い合わせが表示され、詳細へ遷移できる。存在しない ID が 404 になる。
Phase 2: 検索・フィルタ・ソートを足す
ゴール: 問い合わせを探せる・絞れるようにする。
URL クエリのパース
parseContactListQuery でサーバーコンポーネントの searchParams を型付き構造体に変換します。不正な値は undefined / デフォルト値にフォールバックし、型安全を保ちます。
// src/lib/contact-requests/query.ts
export type ContactFilterParams = {
q?: string;
status?: ContactStatus;
priority?: ContactPriority;
category?: ContactCategory;
sort: ContactSortKey;
};
export function parseContactListQuery(
params: Record<string, string | string[] | undefined>
): ContactFilterParams {
return {
q: typeof params.q === "string" ? params.q : undefined,
status: parseStatus(params.status),
priority: parsePriority(params.priority),
category: parseCategory(params.category),
sort: parseSort(params.sort),
};
}
buildContactListHref は現在の params に変更・削除を適用した URL 文字列を返します。チップ解除や EmptyState の「解除」ボタンのリンク生成に使います。
フィルタ・ソート純粋関数
filterContactRequests と sortContactRequests は UI に依存しない純粋関数です。
// src/lib/contact-requests/filters.ts
export function filterContactRequests(
requests: ContactRequest[],
params: ContactFilterParams
): ContactRequest[] {
return requests.filter((r) => {
if (params.status && r.status !== params.status) return false;
if (params.priority && r.priority !== params.priority) return false;
if (params.category && r.category !== params.category) return false;
if (params.q) {
const q = params.q.toLowerCase();
const hit =
r.subject.toLowerCase().includes(q) ||
r.body.toLowerCase().includes(q) ||
r.requester.toLowerCase().includes(q);
if (!hit) return false;
}
return true;
});
}
sort は updated_desc / created_desc / priority_desc の 3 種類です。priority のソートは { high: 0, medium: 1, low: 2 } の数値マップで実装します。
フィルタパネル
ContactFilterPanel はクライアントコンポーネントです。useSearchParams でクエリを読み、router.push で更新します。
const update = useCallback(
(key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) { params.set(key, value); } else { params.delete(key); }
router.push(`${pathname}?${params.toString()}`);
},
[router, pathname, searchParams]
);
アクティブチップと EmptyState
絞り込み中の条件をチップとして画面上部に表示します。各チップの href は buildContactListHref で生成し、クリックでそのキーだけを除いた URL に遷移します。
0 件のとき EmptyState を表示し、条件の種類に応じて解除ボタンを出し分けます。
Phase 2 完了条件: q 検索・status / priority / category フィルタ・sort・chip 解除・EmptyState が動作する。
Phase 3: 詳細復帰と URL preserve を整える
ゴール: 検索・フィルタ中の一覧から詳細へ進んだあと、元の条件へ戻れるようにする。
from パラメータ
一覧から詳細へ遷移するとき、現在の URL を from として詳細の URL に付加します。
// src/app/contact-requests/page.tsx
const currentListHref = buildContactListHref({ params });
<Link href={`/contact-requests/${req.id}?from=${encodeURIComponent(currentListHref)}`}>
open redirect 対策
詳細ページは from をサーバー側で検証します。/contact-requests または /contact-requests?... 以外は弾きます。
// src/lib/contact-requests/query.ts
export function parseContactReturnHref(value: unknown): string {
if (
typeof value === "string" &&
(value === "/contact-requests" || value.startsWith("/contact-requests?"))
) {
return value;
}
return "/contact-requests";
}
https://example.com や //evil.com、/admin、/contact-requests-other はすべて /contact-requests にフォールバックします。
戻りリンクの切り替え
from の値によってリンクのラベルと遷移先を動的に変えます。
const isFilteredReturn = returnHref !== "/contact-requests";
<Link href={returnHref}>
← {isFilteredReturn ? "絞り込み結果に戻る" : "問い合わせ一覧に戻る"}
</Link>
Phase 3 完了条件: 検索中の一覧から詳細へ進み、元の検索結果へ戻れる。外部 URL の from が /contact-requests にフォールバックする。
Phase 4: E2Eで守る
ゴール: 主要フローを Playwright で常時検証し、リグレッションを防ぐ。
テストは tests/contact-requests-flow.spec.ts にまとめます。
| テスト | 確認内容 |
|---|---|
| 基本表示 | ページ・カード・badge が表示される |
| q 検索 | URL に q が入り、件数が絞り込み形式になる |
| status filter | URL に status が入り、active chip が出る |
| priority filter | URL に priority が入り、active chip が出る |
| category filter | URL に category が入り、active chip が出る |
| sort | URL に sort が入りカードが表示される |
| chip 解除 | 対象 query が URL から消え、他は保持される |
| EmptyState | キーワード解除で q が URL から消える |
| EmptyState(filter 付き) | フィルタ解除で category が消え q は残る |
| 詳細遷移 | from 付き URL で進み、「絞り込み結果に戻る」が出る |
| open redirect 防止 | 不正な from は /contact-requests にフォールバック |
| Builds → デモ | /builds から build 詳細を経由して /contact-requests へ |
フィルタ select はサイドバーに存在するため、select を直接操作する代わりに URL ナビゲーションで条件をセットして UI 反映を確認します。
// select をクリックする代わりに URL で直接セット
await page.goto("/contact-requests?status=open");
await expect(page).toHaveURL(/status=open/);
Phase 4 完了条件: npx playwright test がすべてパスし、既存の samples E2E も維持される。
Phase 5: Playbook / Build 化する
ゴール: 実装したものをサイト内でどう見せるかを整理する。
| 種別 | 役割 | 問い合わせ管理での扱い |
|---|---|---|
| Samples | 個別のコード例 | 各フェーズで使ったコードを必要に応じて抽出 |
| Playbooks | 作り方の記録 | このページ |
| Builds | 専用の小システム | /builds/contact-requests |
/builds/contact-requests は完成例ページです。demoPath: /contact-requests でデモ画面へのリンクを持ち、relatedPlaybooks でこの Playbook への導線を持ちます。
Phase 5 完了条件: /playbooks/contact-requests が表示される。/builds/contact-requests から Playbook とデモの両方へ移動できる。
実務画面への展開
この問い合わせ管理は、以下を追加することで本格的な業務ツールになります。
- ステータス更新 — Server Action でステータスを変更し、
revalidatePathでページを更新する - 担当者割り当て — 担当者フィールドと担当者フィルタを追加する
- コメント投稿 — 詳細ページにコメント一覧と投稿フォームを追加する
- 対応履歴 — ステータス変更の履歴を時系列で表示する
- メール通知 — 新規問い合わせ受付・ステータス変更時にメールを送信する
- DB / API 接続 — 固定データを Supabase / Prisma のクエリに置き換える
- 認証・権限 — 管理者のみアクセス可能にする
設計判断まとめ
まず固定データで始めた理由。DB 設計や API 設計が確定する前にフロント実装を進めるためです。型と関数のインターフェースを先に決めておけば、後から実データに差し替えるだけで済みます。
URL クエリに検索条件を持たせた理由。ブラウザの戻る・シェア・リロードで同じ絞り込み状態を再現できます。サーバーコンポーネントが searchParams を読むだけでデータ取得・表示が完結し、グローバル状態管理が不要になります。
filter / sort を純粋関数に分けた理由。filterContactRequests と sortContactRequests を UI から切り離すことで、引数・戻り値が明確になります。単体テストが書きやすく、フィルタロジックの変更がコンポーネントに波及しません。
from で一覧復帰した理由。フィルタ状態を URL で管理しているため、詳細ページは「どこから来たか」を知りません。from パラメータで一覧の URL を渡すことで、戻り先の絞り込み条件を正確に再現できます。
from を検証する理由。外部 URL をそのまま from に使うと open redirect 脆弱性になります。/contact-requests または /contact-requests?... のみ許可し、それ以外は /contact-requests にフォールバックすることで、外部サイトへの誘導を防ぎます。
E2E で業務フローを守る理由。URL クエリの組み合わせが多く、手動テストで全パターンを確認するのは困難です。主要フローを Playwright で固定することで、実装変更によるリグレッションを自動検出できます。
Build を公開する理由。Playbook は「作り方の記録」ですが、読者が動いている画面を触れることで理解が深まります。demoPath で実際の画面へ遷移できる Build を用意することで、コードと動作の対応を確認しやすくします。
次に足せるもの
- ステータス更新 — Server Action でステータスを変更する
- コメント投稿 — 詳細ページにコメント一覧と投稿フォームを追加する
- 対応履歴 — ステータス変更の時系列ログを表示する
- 担当者フィルタ — 担当者フィールドを追加してフィルタに組み込む
- Unit test —
filterContactRequests/sortContactRequests/parseContactListQueryのユニットテスト追加 - DB / API 接続 — 固定データを実データに置き換える