このPlaybookで作るもの
タスク管理画面で実現する体験は次のとおりです。
- タスク一覧を開く
- タスク詳細を見る
- キーワードで検索する
- ステータス・優先度・担当者・ラベルで絞り込む
- 並び替える
- 詳細から元の絞り込み結果へ戻る
- 0 件時に条件を解除できる
- Playwright E2E で主要フローを守る
完成例
完成例は
/tasksで確認できます。検索、フィルタ、ソート、詳細復帰、0 件時の導線まで含めて実装済みです。
完成例ページは
/builds/tasksにあります。
5 つのフェーズに分けて、この画面を段階的に構築します。
| フェーズ | ゴール |
|---|---|
| Phase 1 | 一覧と詳細が表示できる |
| Phase 2 | 検索・フィルタ・ソートで絞り込める |
| Phase 3 | URL で状態を管理し、詳細から一覧へ戻れる |
| Phase 4 | E2E テストで主要フローを保護する |
| Phase 5 | Playbook と Build で見せ方を整理する |
実装の最終形はこのファイル構成になります。
| ファイル | 役割 |
|---|---|
src/types/task.ts | Task 型定義 |
src/lib/tasks/data.ts | データ定義・型・取得関数 |
src/lib/tasks/query.ts | URL クエリのパース・ビルド・open redirect 検証 |
src/lib/tasks/filters.ts | フィルタ・ソート純粋関数 |
src/app/tasks/page.tsx | 一覧ページ(サーバーコンポーネント) |
src/app/tasks/[id]/page.tsx | 詳細ページ(from パラメータ対応) |
src/components/tasks/TaskCard.tsx | タスクカード(returnHref prop 対応) |
src/components/tasks/TaskFilterPanel.tsx | サイドバーフィルタ(クライアント) |
tests/tasks-flow.spec.ts | Playwright E2E テスト |
Phase 1: 一覧・詳細の土台を作る
ゴール: 固定データで、タスク管理を画面として成立させる。
データ定義
まず型とデータを定義します。TaskStatus / TaskPriority は union type にしておくことで、後から追加するフィルタ・バッジ処理で exhaustive check が効きます。
// src/types/task.ts
export type TaskStatus = "todo" | "in_progress" | "blocked" | "done";
export type TaskPriority = "low" | "medium" | "high";
export type Task = {
id: string;
title: string;
summary: string;
description: string;
status: TaskStatus;
priority: TaskPriority;
assignee: string;
labels: string[];
dueDate?: string;
createdAt: string;
updatedAt: string;
};
固定データ TASK-001〜TASK-007 を配列で定義し、getTasks / getTaskById / getTaskIds の取得関数を用意します。担当者とラベルの一覧は getUniqueAssignees / getUniqueLabels でフィルタパネルに渡します。
一覧ページ
サーバーコンポーネントでデータを取得し、カードを並べるだけから始めます。
// src/app/tasks/page.tsx
export default async function TasksPage({ searchParams }: Props) {
const all = getTasks();
return (
<div>
<h1>タスク管理</h1>
<ul>
{all.map((task) => (
<li key={task.id}>
<Link href={`/tasks/${task.id}`}>{task.title}</Link>
</li>
))}
</ul>
</div>
);
}
詳細ページ
generateStaticParams で全 ID を静的生成し、notFound() で存在しない ID に対応します。
// src/app/tasks/[id]/page.tsx
export function generateStaticParams() {
return getTaskIds().map((id) => ({ id }));
}
export default async function TaskDetailPage({ params }: Props) {
const { id } = await params;
const task = getTaskById(id);
if (!task) notFound();
return (
<article>
<Link href="/tasks">タスク一覧に戻る</Link>
<h1>{task.title}</h1>
<p>{task.summary}</p>
</article>
);
}
Phase 1 完了条件: 一覧に複数のタスクが表示され、詳細へ遷移できる。存在しない ID が 404 になる。詳細ページが SSG される。
Phase 2: 検索・フィルタ・ソートを足す
ゴール: タスクを探せる・絞れるようにする。
URL クエリのパース
parseTaskListQuery でサーバーコンポーネントの searchParams を型付き構造体に変換します。不正な値は undefined / デフォルト値にフォールバックし、型安全を保ちます。
// src/lib/tasks/query.ts
export type TaskListQuery = {
q?: string;
status?: TaskStatus;
priority?: TaskPriority;
assignee?: string;
label?: string;
sort: TaskSort;
};
export function parseTaskListQuery(
params: Record<string, string | string[] | undefined>
): TaskListQuery {
return {
q: parseTrimmedString(params.q),
status: parseStatus(params.status),
priority: parsePriority(params.priority),
assignee: parseTrimmedString(params.assignee),
label: parseTrimmedString(params.label),
sort: parseSort(params.sort),
};
}
buildTaskListHref は現在の params に変更・削除を適用した URL 文字列を返します。チップ解除や EmptyState の「解除」ボタンのリンク生成に使います。デフォルト sort(updated_desc)はクエリから省略します。
フィルタ・ソート純粋関数
filterTasks と sortTasks は UI に依存しない純粋関数です。
// src/lib/tasks/filters.ts
export function filterTasks(items: Task[], query: TaskListQuery): Task[] {
return items.filter((task) => {
if (query.status && task.status !== query.status) return false;
if (query.priority && task.priority !== query.priority) return false;
if (query.assignee && task.assignee !== query.assignee) return false;
if (query.label && !task.labels.includes(query.label)) return false;
if (query.q) {
const q = query.q.toLowerCase();
const hit =
task.title.toLowerCase().includes(q) ||
task.summary.toLowerCase().includes(q) ||
task.description.toLowerCase().includes(q) ||
task.assignee.toLowerCase().includes(q) ||
task.labels.some((l) => l.toLowerCase().includes(q));
if (!hit) return false;
}
return true;
});
}
sort は updated_desc / created_desc / due_asc / priority_desc の 4 種類です。due_asc は dueDate が未設定のタスクを末尾に寄せます。priority のソートは { high: 0, medium: 1, low: 2 } の数値マップで実装します。
フィルタパネル
TaskFilterPanel はクライアントコンポーネントです。useSearchParams でクエリを読み、router.push で更新します。
const update = useCallback(
(key: string, value: string) => {
const params = Object.fromEntries(searchParams.entries());
const href = buildTaskListHref({ params, set: { [key]: value || undefined } });
router.push(href);
},
[router, searchParams]
);
アクティブチップと EmptyState
絞り込み中の条件をチップとして画面上部に表示します。各チップの href は buildTaskListHref で生成し、クリックでそのキーだけを除いた URL に遷移します。
0 件のとき EmptyState を表示し、条件の種類(q のみ・フィルタのみ・複合)に応じて解除ボタンを出し分けます。
Phase 2 完了条件: q 検索・status / priority / assignee / label フィルタ・sort・chip 解除・EmptyState が動作する。
Phase 3: 詳細復帰と URL preserve を整える
ゴール: 検索・フィルタ中の一覧から詳細へ進んだあと、元の条件へ戻れるようにする。
from パラメータ
一覧から詳細へ遷移するとき、現在の URL を from として詳細の URL に付加します。TaskCard は returnHref prop を受け取り、詳細リンクを組み立てます。
// src/components/tasks/TaskCard.tsx
type Props = { task: Task; returnHref?: string; };
export function TaskCard({ task, returnHref }: Props) {
const href = returnHref
? `/tasks/${task.id}?from=${encodeURIComponent(returnHref)}`
: `/tasks/${task.id}`;
...
}
一覧ページ側では現在の URL を buildTaskListHref で組み立てて returnHref として渡します。
// src/app/tasks/page.tsx
const currentListHref = buildTaskListHref({ params });
...
<TaskCard key={task.id} task={task} returnHref={currentListHref} />
open redirect 対策
詳細ページは from をサーバー側で検証します。/tasks または /tasks? 始まり以外は弾きます。
// src/lib/tasks/query.ts
export function parseTaskReturnHref(value: unknown): string {
if (
typeof value === "string" &&
(value === "/tasks" || value.startsWith("/tasks?"))
) {
return value;
}
return "/tasks";
}
https://example.com や //evil.com、/admin、/contact-requests はすべて /tasks にフォールバックします。
戻りリンクの切り替え
from の値によってリンクのラベルを動的に変えます。
<Link href={returnHref}>
← {returnHref === "/tasks" ? "タスク一覧に戻る" : "絞り込み結果に戻る"}
</Link>
Phase 3 完了条件: 検索中の一覧から詳細へ進み、元の検索結果へ戻れる。外部 URL の from が /tasks にフォールバックする。
Phase 4: E2Eで守る
ゴール: 主要フローを Playwright で常時検証し、リグレッションを防ぐ。
テストは tests/tasks-flow.spec.ts にまとめます。
| テスト | 確認内容 |
|---|---|
| 基本表示 | ページ・カード・badge が表示される |
| q 検索 | URL に q が入り、件数が絞り込み形式になる |
| status filter | URL に status が入り、active chip が出る |
| priority filter | URL に priority が入り、active chip が出る |
| assignee filter | URL に assignee が入り、active chip が出る |
| label filter | URL に label が入り、active chip が出る |
| sort | URL に sort が入りカードが表示される |
| chip 解除 | 対象 query が URL から消え、他は保持される |
| EmptyState | キーワード解除で q が URL から消える |
| EmptyState(filter 付き) | フィルタ解除で status が消え q は残る |
| 詳細遷移 | from 付き URL で進み、「絞り込み結果に戻る」が出る |
| open redirect 防止 | 不正な from 6 パターンが /tasks にフォールバック |
フィルタ select はサイドバーに存在するため、select を直接操作する代わりに URL ナビゲーションで条件をセットして UI 反映を確認します。
// select をクリックする代わりに URL で直接セット
await page.goto("/tasks?status=todo");
await expect(page).toHaveURL(/status=todo/);
await expect(page.getByTestId("task-active-chip")).toBeVisible();
Phase 4 完了条件: npx playwright test がすべてパスし、既存の samples・contact-requests E2E も維持される。
Phase 5: Playbook / Build 化する
ゴール: 実装したものをサイト内でどう見せるかを整理する。
| 種別 | 役割 | タスク管理での扱い |
|---|---|---|
| Samples | 個別のコード例 | 各フェーズで使ったコードを必要に応じて抽出 |
| Playbooks | 作り方の記録 | このページ |
| Builds | 専用の小システム | /builds/tasks |
/builds/tasks は完成例ページです。demoPath: /tasks でデモ画面へのリンクを持ち、relatedPlaybooks でこの Playbook への導線を持ちます。
Phase 5 完了条件: /playbooks/tasks が表示される。/builds/tasks から Playbook とデモの両方へ移動できる。
設計判断まとめ
まず固定データで始めた理由。DB 設計や API 設計が確定する前にフロント実装を進めるためです。型と関数のインターフェースを先に決めておけば、後から実データに差し替えるだけで済みます。
URL クエリに検索条件を持たせた理由。ブラウザの戻る・シェア・リロードで同じ絞り込み状態を再現できます。サーバーコンポーネントが searchParams を読むだけでデータ取得・表示が完結し、グローバル状態管理が不要になります。
filter / sort を純粋関数に分けた理由。filterTasks と sortTasks を UI から切り離すことで、引数・戻り値が明確になります。5 フィルタ次元が増えても変更箇所が集中し、コンポーネントに波及しません。
from で一覧復帰した理由。フィルタ状態を URL で管理しているため、詳細ページは「どこから来たか」を知りません。from パラメータで一覧の URL を渡すことで、戻り先の絞り込み条件を正確に再現できます。
from を厳密に検証する理由。外部 URL をそのまま from に使うと open redirect 脆弱性になります。/tasks または /tasks? のみ許可し、それ以外は /tasks にフォールバックすることで、外部サイトへの誘導を防ぎます。
E2E で業務フローを守る理由。URL クエリの組み合わせが多く、手動テストで全パターンを確認するのは困難です。主要フローを Playwright で固定することで、実装変更によるリグレッションを自動検出できます。
Build と Playbook を分ける理由。Build は「動いている画面を触れる完成例」、Playbook は「手順を追いながら学べる記録」です。役割を分けることで、完成例を触りたい人と作り方を知りたい人それぞれに適した入口を提供できます。
次に足せるもの
- タスク作成 — 新規タスクを追加するフォームを実装する
- タスク編集 — タイトル・説明・担当者を編集する
- ステータス更新 — Server Action でステータスを変更し、
revalidatePathでページを更新する - 担当者変更 — 担当者フィールドをインラインで更新する
- コメント投稿 — 詳細ページにコメント一覧と投稿フォームを追加する
- 履歴 — ステータス変更の時系列ログを表示する
- カンバン — ステータス列でタスクをドラッグ&ドロップで移動する
- DB / API 接続 — 固定データを Supabase / Prisma のクエリに置き換える
- Admin Actions — 管理者のみ操作できるアクションを追加する