zukucode
主にWEB関連の情報を技術メモとして発信しています。

React 申請・承認機能を作る④ 状態に応じた画面を作る

Reactで、下書きの編集、申請、承認・却下を行う画面を作ります。

画面側では、承認者のロール名や申請者IDを使った判定を増やさず、APIが返したallowedActionsで操作を表示します。未保存の入力や通信中の状態は、画面が管理します。

  1. 状態とDBを設計する
  2. 下書き保存と申請API
  3. 承認・却下と権限の確認
  4. 状態に応じた画面を作る(この記事)
  5. 同時操作と履歴を扱う

前回までのAPIと、同じOriginの/api/csrfを使用します。既存のログイン画面で認証済みであることが前提です。

APIの型と呼び出し処理をまとめる

まず、詳細データの型と通信処理を用意します。

approvalApi.ts
export type ApprovalDetail = {
  id: string;
  title: string;
  description: string;
  status: 'Draft' | 'Submitted' | 'Approved' | 'Rejected';
  version: number;
  decisionReason: string | null;
  allowedActions: {
    canEdit: boolean;
    canSubmit: boolean;
    canApprove: boolean;
    canReject: boolean;
  };
};

export class ApiError extends Error {
  constructor(public status: number, public code: string) {
    super(code);
  }
}

export async function approvalApi<T>(
  url: string, method = 'GET', body?: unknown, signal?: AbortSignal,
): Promise<T> {
  const headers: Record<string, string> = {};
  if (method !== 'GET') {
    const tokenResponse = await fetch('/api/csrf', {
      credentials: 'same-origin', cache: 'no-store', signal,
    });
    if (!tokenResponse.ok) throw new ApiError(tokenResponse.status, 'csrf_failed');
    const data: { token: string } = await tokenResponse.json();
    headers['X-CSRF-TOKEN'] = data.token;
    headers['Content-Type'] = 'application/json';
  }
  const response = await fetch(url, {
    method, credentials: 'same-origin', cache: 'no-store', headers, signal,
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  if (!response.ok) {
    const problem: { code?: string } = await response.json().catch(() => ({}));
    throw new ApiError(response.status, problem.code ?? 'request_failed');
  }
  return response.json() as Promise<T>;
}

statusは、C#でToString()したDraftなどの文字列です。プロパティ名はASP.NET Coreの標準のWeb向けJSON設定に合わせ、allowedActionsなどのcamelCaseを使います。アプリ側でJSON設定を変更している場合は合わせてください。

更新前にCSRFトークンを取得し、X-CSRF-TOKENヘッダーへ載せます。権限はそのトークンではなく、更新APIが別途確認します。

このシリーズの成功レスポンスはすべてJSONを返します。汎用のHTTP関数へ広げて204 No Contentも扱う場合は、空の本文をresponse.json()で読まない分岐を追加してください。

TypeScriptの型は実行時の検証ではありません。APIと独立して変更されるフロントエンドなどでは、受信データを検証する方法のようにスキーマ検証を追加する選択肢もあります。

入力内容とサーバーのデータを分ける

画面には、最後に取得した詳細と、入力中の件名・内容を別々に持たせます。これにより、まだ保存していない変更があるかを判定できます。

以下は、作成済みの申請IDを受け取って表示する画面です。

ApprovalPage.tsx
import { useEffect, useRef, useState } from 'react';
import { ApiError, approvalApi, type ApprovalDetail } from './approvalApi';

type Props = { organizationId: string; requestId: string };
type Command = 'save' | 'submit' | 'approve' | 'reject';

export function ApprovalPage(props: Props) {
  return <ApprovalEditor key={`${props.organizationId}/${props.requestId}`} {...props} />;
}

function ApprovalEditor({ organizationId, requestId }: Props) {
  const url = `/api/organizations/${encodeURIComponent(organizationId)}/approvals/${encodeURIComponent(requestId)}`;
  const [item, setItem] = useState<ApprovalDetail | null>(null);
  const [title, setTitle] = useState('');
  const [description, setDescription] = useState('');
  const [reason, setReason] = useState('');
  const [message, setMessage] = useState('');
  const [loading, setLoading] = useState(true);
  const [busy, setBusy] = useState(false);
  const [needsReload, setNeedsReload] = useState(false);
  const [reload, setReload] = useState(0);
  const running = useRef(false);

  function apply(data: ApprovalDetail) {
    setItem(data);
    setTitle(data.title);
    setDescription(data.description);
    setReason('');
    setNeedsReload(false);
  }

  useEffect(() => {
    const controller = new AbortController();
    let ignore = false;
    setLoading(true);
    approvalApi<ApprovalDetail>(url, 'GET', undefined, controller.signal)
      .then(data => { if (!ignore) apply(data); })
      .catch(() => {
        if (!ignore) {
          setNeedsReload(true);
          setMessage('取得できませんでした。ログイン状態や通信を確認してください。');
        }
      })
      .finally(() => { if (!ignore) setLoading(false); });
    return () => { ignore = true; controller.abort(); };
  }, [url, reload]);

  const dirty = item !== null && (title !== item.title || description !== item.description);
  const blocked = busy || loading || needsReload;

  async function execute(command: Command) {
    if (!item || blocked || running.current) return;
    if (command === 'submit' && dirty) {
      setMessage('変更内容を保存してから申請してください。');
      return;
    }
    running.current = true;
    setBusy(true);
    setMessage('');
    const body = command === 'save'
      ? { title, description, expectedVersion: item.version }
      : command === 'reject'
        ? { reason, expectedVersion: item.version }
        : { expectedVersion: item.version };
    try {
      const result = await approvalApi<ApprovalDetail>(
        command === 'save' ? url : `${url}/${command}`,
        command === 'save' ? 'PUT' : 'POST', body,
      );
      apply(result);
      setMessage('操作が完了しました。');
    } catch (error) {
      if (error instanceof ApiError && error.status === 400) {
        setMessage('入力内容を確認してください。申請には件名と内容、却下には理由が必要です。');
      } else {
        setNeedsReload(true);
        setMessage(error instanceof ApiError && error.status === 409
          ? '別の操作で更新されています。入力を控えてから最新状態を読み直してください。'
          : '操作結果を確認できません。再送する前に最新状態を読み直してください。');
      }
    } finally {
      running.current = false;
      setBusy(false);
    }
  }

  const labels = { Draft: '下書き', Submitted: '申請中', Approved: '承認済み', Rejected: '却下' };
  return (
    <section>
      {loading && <p>読み込み中…</p>}
      {item && <>
        <p>状態:{labels[item.status]}</p>
        <label>件名
          <input value={title} maxLength={100}
            disabled={blocked || !item.allowedActions.canEdit}
            onChange={e => setTitle(e.target.value)} />
        </label>
        <label>内容
          <textarea value={description} maxLength={2000}
            disabled={blocked || !item.allowedActions.canEdit}
            onChange={e => setDescription(e.target.value)} />
        </label>
        {item.allowedActions.canEdit &&
          <button disabled={blocked || !dirty} onClick={() => execute('save')}>保存</button>}
        {item.allowedActions.canSubmit &&
          <button disabled={blocked || dirty} onClick={() => execute('submit')}>申請する</button>}
        {item.allowedActions.canApprove &&
          <button disabled={blocked} onClick={() => execute('approve')}>承認する</button>}
        {item.allowedActions.canReject && <>
          <label>却下理由
            <textarea value={reason} maxLength={500} disabled={blocked}
              onChange={e => setReason(e.target.value)} />
          </label>
          <button disabled={blocked || !reason.trim()}
            onClick={() => execute('reject')}>却下する</button>
        </>}
        {item.decisionReason && <p>却下理由:{item.decisionReason}</p>}
        {dirty && <p>未保存の変更があります。</p>}
      </>}
      <p role="status">{message}</p>
      <button disabled={busy || loading} onClick={() => {
        if ((dirty || reason) && !window.confirm('入力を破棄して最新状態を読み込みますか?')) return;
        setMessage('');
        setReload(value => value + 1);
      }}>最新状態を読み直す</button>
    </section>
  );
}

新規作成ボタンではPOST /api/organizations/{org}/approvalsを呼び、返ったidでこの画面へ移動します。組織の選択やルーティングは既存のアプリへ接続してください。

ApprovalPageは組織IDと申請IDをkeyにしています。別の申請へ切り替えたときに、前の入力内容や競合状態を引き継がないためです。ログアウトや利用者の切り替えでも、画面を破棄して認証後に取得し直してください。

表示の権限と画面の状態を分ける

操作ボタンには、2種類の条件があります。

条件判断するもの
allowedActions.canApproveなど取得時点で、その利用者がその申請に行える操作
busy、loading、needsReloadいま画面で操作を受け付けてよいか

たとえば承認できる利用者でも、保存中や再取得が必要な状態ではボタンを無効にします。

useRefのrunningは、再描画より前に連続してクリックされた場合も同時に実行しないための補助です。複数のタブやAPIへの直接送信は防げないので、DB側の競合対策も必要です。

操作可否をAPIから返す理由は、操作できるかをAPIから返して権限の分岐を減らす方法でも説明しています。

未保存の変更があるときは申請しない

dirtyは、入力中の値と最後に取得した値が違うかを表します。違う場合は申請ボタンを無効にし、「保存してから申請する」流れにします。

申請APIはDBに保存済みの内容を対象にします。そのため、画面の入力だけが新しくなっている状態で申請すると、利用者が意図した内容と違うものを送る可能性があります。

保存後はAPIの返却値を採用します。サーバーで前後の空白を除去した結果や、新しいVersionも画面へ反映されます。

競合しても入力を勝手に消さない

409が返っても、その場で詳細を再取得して入力欄を上書きしません。入力内容を残し、最新状態を読み直す前に控えてもらう案内を出します。

再読込ボタンを押したときだけ、未保存の入力を破棄するか確認します。これは簡単な例なのでブラウザーの確認ダイアログを使っています。入力の差分を比較できる画面にすると、長文を扱う業務ではさらに使いやすくなります。

取得に失敗した場合も、古いデータのボタンを再び有効にせず、needsReloadで操作を止めます。「以前は承認できた」ことを理由に、新しい操作を案内しないためです。

通信失敗は、更新失敗と同じとは限らない

サーバーが承認を保存したあと、レスポンスだけ届かない場合があります。このとき、画面には通信失敗が見えていても、DBでは承認済みです。

そのため、通信失敗後に同じ更新を自動送信しません。まず最新状態を読み直して、実際に何が保存されているかを確認してもらいます。

掲載コードではエラー表示を短くまとめています。アプリへ組み込む際は、401ならログインの案内、403なら現在の権限の確認、codeが入力エラーなら該当項目への表示、という形へ広げられます。

詳細取得の古い応答を反映しない

useEffectの後処理では、リクエストを中断し、古い結果を無視するようにしています。画面の切り替え後に前の取得結果が戻ってきても、新しい画面へ適用しないためです。

Effectのクリーンアップと取得処理の扱いはReact公式のuseEffectの説明を参照してください。既にデータ取得ライブラリを使っている場合は、そのキャンセルやキャッシュ管理の仕組みへ統合できます。

2人の利用者で確認する

同じ組織に、一般メンバーAと承認権限を持つBを用意します。別のブラウザープロファイルを使うと、ログイン状態を分けて確認できます。

  1. Aが下書きを作り、件名と内容を保存する
  2. 下書きの間は、BがそのIDを指定しても閲覧できないことを確認する
  3. Aが申請すると、Aの編集欄が操作できなくなることを確認する
  4. Bが詳細を取得し、承認と却下のボタンが表示されることを確認する
  5. Bが承認すると、判断のボタンが表示されなくなることを確認する
  6. Aが最新状態を読み直し、承認済みと表示されることを確認する

Aにも承認権限を付け、自己承認のボタンが出ないことも確認します。さらにAPIを直接呼んでも拒否されることを確認してください。

次は同時操作と履歴を扱うで、保存層と競合時の動作を仕上げます。


関連記事