Playbook

タスク管理画面を作る

固定データから始めて、タスク一覧・詳細・検索・フィルタ・詳細復帰・E2Eまでを5フェーズで組み立てる手順。

tasksadmin-uiquery-paramsplaywright

このPlaybookで作るもの

タスク管理画面で実現する体験は次のとおりです。

  • タスク一覧を開く
  • タスク詳細を見る
  • キーワードで検索する
  • ステータス・優先度・担当者・ラベルで絞り込む
  • 並び替える
  • 詳細から元の絞り込み結果へ戻る
  • 0 件時に条件を解除できる
  • Playwright E2E で主要フローを守る

完成例

完成例は /tasks で確認できます。検索、フィルタ、ソート、詳細復帰、0 件時の導線まで含めて実装済みです。

デモを見る

完成例ページは /builds/tasks にあります。

Build を見る

5 つのフェーズに分けて、この画面を段階的に構築します。

フェーズゴール
Phase 1一覧と詳細が表示できる
Phase 2検索・フィルタ・ソートで絞り込める
Phase 3URL で状態を管理し、詳細から一覧へ戻れる
Phase 4E2E テストで主要フローを保護する
Phase 5Playbook と Build で見せ方を整理する

実装の最終形はこのファイル構成になります。

ファイル役割
src/types/task.tsTask 型定義
src/lib/tasks/data.tsデータ定義・型・取得関数
src/lib/tasks/query.tsURL クエリのパース・ビルド・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.tsPlaywright 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-001TASK-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)はクエリから省略します。

フィルタ・ソート純粋関数

filterTaskssortTasks は 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_ascdueDate が未設定のタスクを末尾に寄せます。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

絞り込み中の条件をチップとして画面上部に表示します。各チップの hrefbuildTaskListHref で生成し、クリックでそのキーだけを除いた URL に遷移します。

0 件のとき EmptyState を表示し、条件の種類(q のみ・フィルタのみ・複合)に応じて解除ボタンを出し分けます。

Phase 2 完了条件: q 検索・status / priority / assignee / label フィルタ・sort・chip 解除・EmptyState が動作する。


Phase 3: 詳細復帰と URL preserve を整える

ゴール: 検索・フィルタ中の一覧から詳細へ進んだあと、元の条件へ戻れるようにする。

from パラメータ

一覧から詳細へ遷移するとき、現在の URL を from として詳細の URL に付加します。TaskCardreturnHref 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 filterURL に status が入り、active chip が出る
priority filterURL に priority が入り、active chip が出る
assignee filterURL に assignee が入り、active chip が出る
label filterURL に label が入り、active chip が出る
sortURL に 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 を純粋関数に分けた理由filterTaskssortTasks を 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 — 管理者のみ操作できるアクションを追加する