Playbook

問い合わせ管理画面を作る

問い合わせ一覧、検索・フィルタ・ソート、詳細復帰、E2Eまで含めて、実務で使える問い合わせ管理画面を組み立てる手順。

contact-requestsadmin-uiquery-paramsplaywright

このPlaybookで作るもの

問い合わせ管理画面で実現する体験は次のとおりです。

  • 問い合わせ一覧を開く
  • 問い合わせ詳細を見る
  • キーワードで検索する
  • ステータス・優先度・カテゴリで絞り込む
  • 並び替える
  • 詳細から元の絞り込み結果へ戻る
  • 0 件時に条件を解除できる
  • Playwright E2E で主要フローを守る

完成例

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

デモを見る

完成例ページは /builds/contact-requests にあります。

Build を見る

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

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

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

ファイル役割
src/lib/contact-requests/data.tsデータ定義・型・取得関数
src/lib/contact-requests/query.tsURL クエリのパース・ビルド・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.tsPlaywright 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-001CR-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 の「解除」ボタンのリンク生成に使います。

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

filterContactRequestssortContactRequests は 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

絞り込み中の条件をチップとして画面上部に表示します。各チップの hrefbuildContactListHref で生成し、クリックでそのキーだけを除いた 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 filterURL に status が入り、active chip が出る
priority filterURL に priority が入り、active chip が出る
category filterURL に category が入り、active chip が出る
sortURL に 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 を純粋関数に分けた理由filterContactRequestssortContactRequests を 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 testfilterContactRequests / sortContactRequests / parseContactListQuery のユニットテスト追加
  • DB / API 接続 — 固定データを実データに置き換える