Next.jsのチュートリアルをやってみる【アクセシビリティの向上編】

前回

Next.jsのチュートリアルをやってみる【エラー処理編】

はじめに

前回はエラーをキャッチし、ユーザーにフォールバックUIを表示する方法について学びました。ですが、フォームバリデーションについてはまだ学ぶべき内容があります。

今回はサーバーアクションを使用してサーバーサイドのバリデーションを実装する方法と、ReactのuseActionStateフックを使用してフォームエラーを表示する方法を、アクセシビリティに考慮しながら学んでいきます。

アクセシビリティとは?

そもそもアクセシビリティとはというところから説明していきます。

アクセシビリティとは、障害のある人を含め、だれもが使用できるウェブアプリケーションを設計し実装することを指します。キーボードナビゲーション、セマンティックHTML、画像、色、動画など、多くの分野をカバーする必要があるトピックです。

チュートリアルではアクセシビリティについて深く掘り下げてはいませんが、Next.jsで利用可能な機能と、アプリケーションをより利用しやすくするための一般的なパターンについて説明していきます。

アクセシビリティについて深堀りしたい方は、Next.jsがお勧めしているweb.devが公開しているLearn Accessibilityを確認してください。

Next.jsでESLintアクセシビリティプラグインを使用する

Next.jsのESLint設定には、アクセシビリティの問題を早期に発見するためのeslint-plugin-jsx-a11yプラグインが含まれています。たとえば、altテキストがない画像、aria-*属性やrole属性の間違った使い方などを警告してくれます。

試したい方はpackage.jsonファイルにスクリプトとしてnext-lintを追加してください。

/package.json
"scripts": {
    "build": "next build",
    "dev": "next dev",
    "start": "next start",
    "lint": "next lint"
},

pnpm lintを実行すると以下の内容が出力されるます。

Baseがコードの正確性のための推奨ルール、Strictは推奨ルールに加えて、より意見の分かれるルールを含め、バグの検出も可能にするモードとなります。

今回はNext.jsのチュートリアルに合わせるため、下方向キー・Enterキーの順に押してBaseを選択し、インストールしましょう。

? How would you like to configure ESLint? https://nextjs.org/docs/basic-features/eslint
❯  Strict (recommended)
   Base
   Cancel

インストールが完了したら、再度pnpm lintを実行してみましょう。以下の様に警告とエラーがない旨が出力されます。

> next lint
✔ No ESLint warnings or errors

しかし、alt属性を消したらどうなるでしょうか。/app/ui/invoices/table.tsx<Image>からalt属性を一時的に消して、再度実行し確認してみましょう。すると、以下の様に警告が出力されるようになります。

./app/ui/invoices/table.tsx
88:23  Warning: Image elements must have an alt prop, either with meaningful text, or an empty string for decorative images.  jsx-a11y/alt-text

フォームのアクセシビリティの向上

実はこれまでのチュートリアルでフォームのアクセシビリティを向上させるために以下のようなことを行っていました。

項目内容
セマンティック
HTML
<div>の代わりにセマンティック要素である<input><option>などを使う。これによりATと呼ばれる支援技術は入力要素に焦点をあて、適切な文脈情報をユーザーに提供し、フォームをナビゲートし理解しやすくすることができます。
ラベリング<label>htmlFor属性を含めることで、各フォーム・フィールドが説明的なテキストとラベルを持つようになります。これは文脈を提供することで、ATサポートを向上させ、ユーザーがラベルをクリックすることで対応する入力フィールドにフォーカスできるようにすることで、ユーザビリティを向上させます。
フォーカス
アウトライン
フィールドがフォーカスされたときにアウトラインが表示されるように適切にスタイルされます。これはアクセシビリティにとって重要で、ページ上でアクティブな要素を視覚的に示すので、キーボードとスクリーンリーダーの両方のユーザーがフォームのどこにいるのかを理解するのに役立ちます。Tabキーを押すことで確認することができます。

これらはフォームを多くのユーザーにとって使いやすいものにするためにいい土台となります。ですがフォームのバリデーションやエラーには対応していません。

フォームバリデーション

http://localhost:3000/dashboard/invoices/createにアクセスして、空のフォームを送信してみましょう。

エラーが発生したと思います。これはフォームの値を空にしてサーバーアクションに送信しているからです。クライアント側またはサーバー側でフォームのバリデーションを行うことで、防ぐことができます。

クライアント側のバリデーション

クライアント側でフォームのバリデーションを行う方法はいくつかありますが、一番簡単なのは<input>要素と<select>要素にrequired属性を追加して、ブラウザが提供するフォームバリデーションに頼ることです。

create-form
<select
  id="customer"
  name="customerId"
  className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
  defaultValue=""
  required
>

{/* ... */}

<input
  id="amount"
  name="amount"
  type="number"
  placeholder="Enter USD amount"
  className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
  required
/>

もう一度空の値でフォームを送信してみましょう。そうするとブラウザが警告を表示すると思います。

ATの中にはブラウザのバリデーションをサポートしているものもあるので、この方法は基本的には問題ありません。クライアント側のバリデーションに代わるものとして、サーバー側のバリデーションがあります。

サーバー側のバリデーション

サーバー上でバリデーションを行うことで以下のようなメリットがあります。

  • データをデータベースに送信する前にデータが期待される形式であることを確認する。
  • 悪意のあるユーザーがクライアント側のバリデーションを回避するリスクを減らす。
  • 有効なデータとみなされる情報源を一つにする。

まず初めにcreate-form.tsxコンポーネントで、reactuseActionStateフックをインポートしましょう。useActionStateはフックなので'use client'ディレクティブを使用して、フォームをクライアントコンポーネントにする必要があります。

/app/ui/invoices/create-form.tsx
'use client';
 
// ...
import { useActionState } from 'react';

フォームコンポーネントの中のuseActionStateフックを確認すると、以下のような形式になっています。

  • actioninitialiStateの二つの引数をとる。
  • [state, formAction]を戻り値とする。
    フォームの状態とフォームが送信されたときに呼び出される関数を表す。

createInvoiceアクションをuseActionStateの第一引数として渡して、<form action={}>属性内でformActionを呼び出します。この際、第二引数のinitialStateはまだ用意していないので、同じ引数名を仮で入れておきます。

/app/ui/invoices/create-form.tsx
// ...
import { useActionState } from 'react';
 
export default function Form({ customers }: { customers: CustomerField[] }) {
  const [state, formAction] = useActionState(createInvoice, initialState);
 
  return <form action={formAction}>...</form>;
}

続いて第二引数のinitialStateを用意していきます。この場合、messageerrorという二つの空のキーを持つオブジェクトを作成し、サーバー上でバリデーションを行うためactions.tsファイルからState型をインポートします。

/app/ui/invoices/create-form.tsx
// ...
import { createInvoice, State } from '@/app/lib/actions';
import { useActionState } from 'react';
 
export default function Form({ customers }: { customers: CustomerField[] }) {
  const initialState: State = { message: null, errors: {} };
  const [state, formAction] = useActionState(createInvoice, initialState);
 
  return <form action={formAction}>...</form>;
}

action.tsファイルではZodを使用してフォームデータを検証することができます。FormSchemaを以下の様に変更しましょう。

/app/lib/actions.ts
const FormSchema = z.object({
  id: z.string(),
  customerId: z.string({
    invalid_type_error: 'Please select a customer.',
  }),
  amount: z.coerce
    .number()
    .gt(0, { message: 'Please enter an amount greater than $0.' }),
  status: z.enum(['pending', 'paid'], {
    invalid_type_error: 'Please select an invoice status.',
  }),
  date: z.string(),
});

ここではエラーを出す値の場合どのようなエラー内容を出すかを決めています。

customerIdZodはすでに文字列を想定しているため、文字列が空の場合はエラーを投げます。
amount文字列から数値に強制しているので、文字列が空の場合はデフォルトで0になります。
.gt()関数を使用して、常に0よりも大きい金額を表示するようにしましょう。
statusZodは"pending"または"paid"を想定しているため、空の場合はエラーを投げます。ユーザーがステータスを選択しなかった場合のメッセージも追加しましょう。

続いて、createInvoiceアクションを更新してprevStateformDataを受け取れるようにいたします。prevStateにはuseActionStateフックから渡された状態が含まれます。今回の例のアクションでは使用しませんが、必須のpropです。

/app/lib/actions.ts
export type State = {
  errors?: {
    customerId?: string[];
    amount?: string[];
    status?: string[];
  };
  message?: string | null;
};
 
export async function createInvoice(prevState: State, formData: FormData) {
  // ...
}

続いてZodのparse()関数をsafeParse()に変更しましょう。sageParse()は成功フィールドもしくはエラーフィールドを含むオブジェクトを返します。これにより、try/catchブロックの中にこのロジックを置くことなく、より適切にバリデーションを処理することができます。

/app/lib/actions.ts
export async function createInvoice(prevState: State, formData: FormData) {
  // Zodを使用してフォームフィールドのバリデーションを行う
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // ...
}

そして、データベースに送信する前に、フォームフィールドが条件付きで正しくバリデーションされたかを確認します。validatedFieldが成功しなかった場合は、Zodからのエラーメッセージとともに早期リターンを行います。

/app/lib/actions.ts
export async function createInvoice(prevState: State, formData: FormData) {
  // Zodを使用してフォームフィールドのバリデーションを行う
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // バリデーションに失敗した場合はエラーをすぐに返す。
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Missing Fields. Failed to Create Invoice.',
    };
  }
 
  // ...
}

最後に、try/catchブロックの外でフォームのバリデーションを個別に処理しているので、データベースエラーに対して特定のメッセージを返します。safeParseに変更したのでcustomerId, amount, statusを取得できなくなってしまいました。代わりにvalidatedFields.dataで代入しましょう。

/app/lib/actions.ts
export async function createInvoice(prevState: State, formData: FormData) {
  // Zodを使用してフォームフィールドのバリデーションを行う
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // バリデーションに失敗した場合はエラーをすぐに返す。
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Missing Fields. Failed to Create Invoice.',
    };
  }
 
  //  データベースに挿入するデータを準備する
  const { customerId, amount, status } = validatedFields.data;
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
 
  // データベースにデータを挿入
  try {
    await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
    `;
  } catch (error) {
    return {
      // データベースエラーが発生した場合は具体的なエラーメッセージを返す
      message: 'Database Error: Failed to Create Invoice.',
    };
  }
 
  // 請求書ページのキャッシュをバリデーションし、ユーザーをリダイレクトする
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

バリデーションの処理の追加が完了したので、フォームコンポーネントにエラーを表示してみましょう。

create-form.tsxコンポーネントに戻って、フォームの状態を使用してエラーにアクセスすることができます。書く特定のエラーをチェックする三項演算子を追加しましょう。customerフィールドに以下の処理を入れます。

/app/ui/invoices/create-form.tsx
<form action={formAction}>
  <div className="rounded-md bg-gray-50 p-4 md:p-6">
    {/* Customer Name */}
    <div className="mb-4">
      <label htmlFor="customer" className="mb-2 block text-sm font-medium">
        Choose customer
      </label>
      <div className="relative">
        <select
          id="customer"
          name="customerId"
          className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
          defaultValue=""
          aria-describedby="customer-error"
        >
          <option value="" disabled>
            Select a customer
          </option>
          {customers.map((name) => (
            <option key={name.id} value={name.id}>
              {name.name}
            </option>
          ))}
        </select>
        <UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
      </div>
      <div id="customer-error" aria-live="polite" aria-atomic="true">
        {state.errors?.customerId &&
          state.errors.customerId.map((error: string) => (
            <p className="mt-2 text-sm text-red-500" key={error}>
              {error}
            </p>
          ))}
      </div>
    </div>
    // ...
  </div>
</form>
aria-describedby="customer-error"select要素とエラー・メッセージ・コンテナとの関係が確立されます。id="customer-error"のコンテナがselect要素を記述していることを示しています。スクリーン・リーダーはセレクトボックスにインタラクトする際にこの説明を読み、エラーを通知します。
id="customer-error"このid属性はselect入力のエラーメッセージを保持するHTMLを識別します。aria-describedbyが関係を確立するために必要になります。
aria-live="polite"スクリーン・リーダーはdiv内のエラーが更新されたときに、ユーザーに丁寧に通知する必要があります。コンテンツが変更されたとき(エラーを修正など)、スクリーン・リーダーは変更をユーザーに通知しますが、ユーザーの邪魔にならないようにユーザーがアイドル状態であるときに限ります。

練習:バリデーションを追加しよう

以上の内容で学んだことを使用して金額・状態・請求書更新フォームにバリデーションを追加してみましょう。以下内容は筆者作成のコードですが、事前に各自で行ってから確認することをお勧めいたします。

create-form.tsx
'use client';

import { CustomerField } from '@/app/lib/definitions';
import Link from 'next/link';
import {
  CheckIcon,
  ClockIcon,
  CurrencyDollarIcon,
  UserCircleIcon,
} from '@heroicons/react/24/outline';
import { Button } from '@/app/ui/button';
import { createInvoice, State } from '@/app/lib/actions';
import { useActionState } from 'react';

export default function Form({
  customers,
}: {
  customers: CustomerField[]
}) {
  const initialState: State = { message: null, errors: {} };
  const [state, formAction] = useActionState(createInvoice, initialState);

  return (
    <form action={formAction}>
      <div className="rounded-md bg-gray-50 p-4 md:p-6">
        {/* Customer Name */}
        <div className="mb-4">
          <label htmlFor="customer" className="mb-2 block text-sm font-medium">
            Choose customer
          </label>
          <div className="relative">
            <select
              id="customer"
              name="customerId"
              className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
              defaultValue=""
              aria-describedby="customer-error"
            >
              <option value="" disabled>
                Select a customer
              </option>
              {customers.map((customer) => (
                <option key={customer.id} value={customer.id}>
                  {customer.name}
                </option>
              ))}
            </select>
            <UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
          </div>
          <div id="customer-error" aria-live="polite" aria-atomic="true">
            {state.errors?.customerId &&
              state.errors.customerId.map((error: string) => (
                <p className="mt-2 text-sm text-red-500" key={error}>
                  {error}
                </p>
              ))}
          </div>
        </div>

        {/* Invoice Amount */}
        <div className="mb-4">
          <label htmlFor="amount" className="mb-2 block text-sm font-medium">
            Choose an amount
          </label>
          <div className="relative mt-2 rounded-md">
            <div className="relative">
              <input
                id="amount"
                name="amount"
                type="number"
                step="0.01"
                placeholder="Enter USD amount"
                className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
                aria-describedby="amount-error"
              />
              <CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
            </div>
          </div>
          <div id="amount-error" aria-live="polite" aria-atomic="true">
            {state.errors?.amount &&
              state.errors.amount.map((error: string) => (
                <p className="mt-2 text-sm text-red-500" key={error}>
                  {error}
                </p>
              ))}
          </div>
        </div>

        {/* Invoice Status */}
        <fieldset>
          <legend className="mb-2 block text-sm font-medium">
            Set the invoice status
          </legend>
          <div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
            <div className="flex gap-4">
              <div className="flex items-center">
                <input
                  id="pending"
                  name="status"
                  type="radio"
                  value="pending"
                  className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
                  aria-describedby="status-error"
                />
                <label
                  htmlFor="pending"
                  className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600"
                >
                  Pending <ClockIcon className="h-4 w-4" />
                </label>
              </div>
              <div className="flex items-center">
                <input
                  id="paid"
                  name="status"
                  type="radio"
                  value="paid"
                  className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
                  aria-describedby="status-error"
                />
                <label
                  htmlFor="paid"
                  className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white"
                >
                  Paid <CheckIcon className="h-4 w-4" />
                </label>
              </div>
            </div>
          </div>
        </fieldset>
        <div id="status-error" aria-live="polite" aria-atomic="true">
          {state.errors?.status &&
            state.errors.status.map((error: string) => (
              <p className="mt-2 text-sm text-red-500" key={error}>
                {error}
              </p>
            ))}
        </div>
        <div id="create-error" aria-live="polite" aria-atomic="true">
          {state.message &&
            <p className="mt-2 text-sm text-red-500">
              {state.message}
            </p>}
        </div>
      </div>
      <div className="mt-6 flex justify-end gap-4">
        <Link
          href="/dashboard/invoices"
          className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
        >
          Cancel
        </Link>
        <Button type="submit">Create Invoice</Button>
      </div>
    </form>
  );
}
edit-form.tsx
'use client';

import { CustomerField, InvoiceForm } from '@/app/lib/definitions';
import {
  CheckIcon,
  ClockIcon,
  CurrencyDollarIcon,
  UserCircleIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { Button } from '@/app/ui/button';
import { updateInvoice, State } from '@/app/lib/actions';
import { useActionState } from 'react';

export default function EditInvoiceForm({
  invoice,
  customers,
}: {
  invoice: InvoiceForm;
  customers: CustomerField[];
}) {
  const initialState: State = { message: null, errors: {} };
  const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
  const [state, formAction] = useActionState(updateInvoiceWithId, initialState);
  return (
    <form action={formAction}>
      <div className="rounded-md bg-gray-50 p-4 md:p-6">
        {/* Customer Name */}
        <div className="mb-4">
          <label htmlFor="customer" className="mb-2 block text-sm font-medium">
            Choose customer
          </label>
          <div className="relative">
            <select
              id="customer"
              name="customerId"
              className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
              defaultValue={invoice.customer_id}
              aria-describedby="customer-error"
            >
              <option value="" disabled>
                Select a customer
              </option>
              {customers.map((customer) => (
                <option key={customer.id} value={customer.id}>
                  {customer.name}
                </option>
              ))}
            </select>
            <UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
          </div>
          <div id="customer-error" aria-live="polite" aria-atomic="true">
            {state.errors?.customerId &&
              state.errors.customerId.map((error: string) => (
                <p className="mt-2 text-sm text-red-500" key={error}>
                  {error}
                </p>
              ))}
          </div>
        </div>

        {/* Invoice Amount */}
        <div className="mb-4">
          <label htmlFor="amount" className="mb-2 block text-sm font-medium">
            Choose an amount
          </label>
          <div className="relative mt-2 rounded-md">
            <div className="relative">
              <input
                id="amount"
                name="amount"
                type="number"
                step="0.01"
                defaultValue={invoice.amount}
                placeholder="Enter USD amount"
                className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
                aria-describedby="amount-error"
              />
              <CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
            </div>
          </div>
          <div id="amount-error" aria-live="polite" aria-atomic="true">
            {state.errors?.amount &&
              state.errors.amount.map((error: string) => (
                <p className="mt-2 text-sm text-red-500" key={error}>
                  {error}
                </p>
              ))}
          </div>
        </div>

        {/* Invoice Status */}
        <fieldset>
          <legend className="mb-2 block text-sm font-medium">
            Set the invoice status
          </legend>
          <div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
            <div className="flex gap-4">
              <div className="flex items-center">
                <input
                  id="pending"
                  name="status"
                  type="radio"
                  value="pending"
                  defaultChecked={invoice.status === 'pending'}
                  className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
                  aria-describedby="status-error"
                />
                <label
                  htmlFor="pending"
                  className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600"
                >
                  Pending <ClockIcon className="h-4 w-4" />
                </label>
              </div>
              <div className="flex items-center">
                <input
                  id="paid"
                  name="status"
                  type="radio"
                  value="paid"
                  defaultChecked={invoice.status === 'paid'}
                  className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
                  aria-describedby="status-error"
                />
                <label
                  htmlFor="paid"
                  className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white"
                >
                  Paid <CheckIcon className="h-4 w-4" />
                </label>
              </div>
            </div>
          </div>
        </fieldset>
        <div id="status-error" aria-live="polite" aria-atomic="true">
          {state.errors?.status &&
            state.errors.status.map((error: string) => (
              <p className="mt-2 text-sm text-red-500" key={error}>
                {error}
              </p>
            ))}
        </div>
        <div id="update-error" aria-live="polite" aria-atomic="true">
          {state.message &&
            <p className="mt-2 text-sm text-red-500">
              {state.message}
            </p>}
        </div>
      </div>
      <div className="mt-6 flex justify-end gap-4">
        <Link
          href="/dashboard/invoices"
          className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
        >
          Cancel
        </Link>
        <Button type="submit">Edit Invoice</Button>
      </div>
    </form>
  );
}
action.ts
'use server';
 
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
 
const FormSchema = z.object({
  id: z.string(),
  customerId: z.string({
    invalid_type_error: 'Please select a customer.'
  }),
  amount: z.coerce
    .number()
    .gt(0, { message: 'Please enter an amount greater than $0.'}),
  status: z.enum(['pending', 'paid'], {
    invalid_type_error: 'Please select an invoice status.'
  }),
  date: z.string(),
});

export type State = {
  errors?: {
    customerId?: string[];
    amount?: string[];
    status?: string[];
  };
  message?: string | null;
};
 
const CreateInvoice = FormSchema.omit({ id: true, date: true }); 
const UpdateInvoice = FormSchema.omit({ id: true, date: true });

export async function createInvoice(prevState: State, formData: FormData) {
  // Zodを使用してフォームフィールドのバリデーションを行う
  const validatedFields = CreateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // バリデーションに失敗した場合はエラーをすぐに返す。
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Missing Fields. Failed to Create Invoice.',
    };
  }
 
  //  データベースに挿入するデータを準備する
  const { customerId, amount, status } = validatedFields.data;
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
 
  // データベースにデータを挿入
  try {
    await sql`
      INSERT INTO invoices (customer_id, amount, status, date)
      VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
    `;
  } catch (error) {
    return {
      // データベースエラーが発生した場合は具体的なエラーメッセージを返す
      message: 'Database Error: Failed to Create Invoice.',
    };
  }
 
  // 請求書ページのキャッシュをバリデーションし、ユーザーをリダイレクトする
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

export async function updateInvoice(
  id: string,
  prevState: State,
  formData: FormData
) {
  // Zodを使用してフォームフィールドのバリデーションを行う
  const validatedFields = UpdateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  // バリデーションに失敗した場合はエラーをすぐに返す。
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Missing Fields. Failed to Create Invoice.',
    };
  }
 
  //  データベースに挿入するデータを準備する
  const { customerId, amount, status } = validatedFields.data;
  const amountInCents = amount * 100;
 
  // データベースのデータを更新
  try {
    await sql`
        UPDATE invoices
        SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
        WHERE id = ${id}
      `;
  } catch (error) {
      // データベースエラーが発生した場合は具体的なエラーメッセージを返す
    return { message: 'Database Error: Failed to Update Invoice.' };
  }
 
  // 請求書ページのキャッシュをバリデーションし、ユーザーをリダイレクトする
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

export async function deleteInvoice(id: string) {
  try {
    await sql`DELETE FROM invoices WHERE id = ${id}`;
    revalidatePath('/dashboard/invoices');
    return { message: 'Deleted Invoice.' };
  } catch (error) {
    return { message: 'Database Error: Failed to Delete Invoice.' };
  }
}

まとめ

  • next lintコマンドで静的解析を行う。(今回はnpmのスクリプトで実行)
  • Zodのparse()でバリデーションを行うことができるが、safeParse()を使用することでエラーを取得することができる。
  • aria-describedbyidinput要素とメッセージとの関係を表すことができる。今回はエラーメッセージとして利用。

次回

Next.jsのチュートリアルをやってみる【認証の追加】

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です