とことんDevOps | 日本仮想化技術のDevOps技術情報メディア

DevOpsに関連する技術情報を幅広く提供していきます。

日本仮想化技術がお届けする「とことんDevOps」では、DevOpsに関する技術情報や、日々のDevOps業務の中での検証結果、TipsなどDevOpsのお役立ち情報をお届けします。
主なテーマ: DevOps、CI/CD、コンテナ開発、IaCなど

開催予定の勉強会

読者登録と各種SNSのフォローもよろしくお願いいたします。

Amplifyを使ってWebアプリを作ろう(書籍更新編)

こんにちは。 アドベントカレンダー23日目です。終盤に差し掛かってきました。 今年はフロントエンドまわりであれこれ開発することが多かったので、振り返りを兼ねてこれからフロントエンド開発を始める方向けに入門編としてお送りしています。 最終日の25日には何かアプリケーションができていることが目標です。

↓最初から読み始めたい方はこちらか↓

devops-blog.virtualtech.jp

おさらい

今回のはなし

第18回からAmplifyシリーズでお送りしています。「書籍管理アプリ」を作りながらAPI機能の基本的な使い方を学んでいます。前回は、書籍取得の処理を作ってみました。今回は登録した書籍情報を編集できるように更新機能を追加していきましょう。

画面UIの作成

まずは画面UIから作成します。既存ファイルの編集といくつかファイルを作成しますが、順番に説明します。

  pages
  ├── app.css
  ├── _app.tsx
  ├── book
  │   ├── createBookData.ts
+ │   ├── [id]
+ │   │   ├── edit.tsx
+ │   │   ├── fetchbook.ts
+ │   │   └── updateBookData.ts
  │   └── resister.tsx
  ├── fetchBooks.ts
  └── index.tsx

編集画面

最初に編集画面を作成します。先に編集対象を取得するための関数と変更した内容を更新する関数の2種類を作成します。

1つ目の取得する関数です。前回の記事でリスト取得は扱いましたが、今回はgetの方です。getでは、idをvariablesに渡してあげることで取得することができます。それ以外のコードは毎度同じようなことをやっているため、省略します。

import { Book, GetBookQuery } from "@/src/API";
import { getBook } from "@/src/graphql/queries";
import { GraphQLResult, generateClient } from "aws-amplify/api";

export default async function fetchBook(id: string) {
  const client = generateClient();
  const response = await client.graphql({
    query: getBook,
    variables: { id, },
  }) as GraphQLResult<GetBookQuery>;

  if (response.errors) {
    throw new Error(response.errors[0].message);
  }

  if (!response.data?.getBook) {
    throw new Error("Book not found");
  }

  const book: Book = response.data?.getBook;
  return book;
}

次に更新する処理です。更新する処理はupdateBookをクエリに渡します。細かい説明は省略します。

import { UpdateBookInput, UpdateBookMutation } from "@/src/API";
import { updateBook } from "@/src/graphql/mutations";
import { GraphQLResult, generateClient } from "aws-amplify/api";

export default async function updateBookData(input: UpdateBookInput) {
  const client = generateClient();
  const response = await client.graphql({
    query: updateBook,
    variables: { input },
  }) as GraphQLResult<UpdateBookMutation>;

  if (response.errors) {
    throw new Error(response.errors[0].message);
  }

  if (!response.data?.updateBook) {
    throw new Error("Book not updated");
  }

  const book = response.data?.updateBook;
  return book;
}

ここまで準備できたら編集画面です。次のコードをコピー貼り付けてください。

サンプルコードはこちら

"use client";

import { CreateBookInput } from "@/src/API";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import fetchBook from "./fetchbook";
import updateBookData from "./updateBookData";

type AutherInputs = {
  name: CreateBookInput["name"];
}

type Inputs = {
  id: string;
  name: CreateBookInput["name"];
  authers: AutherInputs[];
  publisher: CreateBookInput["publisher"];
};

export default function Edit() {
  const router = useRouter();
  const { id } = router.query;

  const {
    register,
    control,
    handleSubmit,
    setValue,
    formState: {
      isValid,
      isDirty,
      isSubmitting,
    }
  } = useForm<Inputs>({
    mode: "onChange",
    defaultValues: {
      name: "",
      authers: [],
      publisher: "",
    },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: "authers",
  });

  useEffect(() => {
    if (!id || typeof id === "object") {
      return;
    }

    fetchBook(id as string).then((book) => {
      setValue("id", book.id);
      setValue("name", book.name);
      setValue("authers", book.auther
        ?.filter((item): item is NonNullable<typeof item> => !!item)
        .map((name) => ({ name })) || []);
      setValue("publisher", book.publisher);
    }).catch((error) => {
      console.error(error);
    });
  }, [id]);

  const onSubmit = (data: Inputs) => {
    updateBookData({
      id: data.id,
      name: data.name,
      auther: data.authers
        .map((auther) => auther.name)
        .filter((item): item is NonNullable<typeof item> => !!item),
      publisher: data.publisher,

    })
      .then(() => {
        alert("登録しました");
        router.push("/");
      }).catch((error) => {
        alert("登録に失敗しました");
      });
  }

  return (
    <div className="space-y-5">
      <h3 className="text-3xl font-bold dark:text-white">書籍編集</h3>
      <div className="space-y-3">
        <div>
          <label
            htmlFor="book_name"
            className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
          >
            書籍名
          </label>
          <input
            id="book_name"
            type="text"
            className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
            placeholder="サンプルの本"
            {...register("name", { required: true })}
          />
        </div>
        <div className="space-y-2">
          <label
            htmlFor="author"
            className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
          >
            著者
          </label>
          {fields.map((field, index) => (
            <div
              key={index}
              className="flex space-x-2"
            >
              <input
                id="author"
                type="text"
                className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
                placeholder="著者"
                {...register(`authers.${index}.name`, { required: true })}
              />
              <button
                type="button"
                className="text-red-500 hover:text-red-700 whitespace-nowrap"
                onClick={() => remove(index)}
              >
                削除
              </button>
            </div>
          ))}
          <button
            type="button"
            className="bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded"
            onClick={() => append({ name: "" })}
          >
            著者を追加
          </button>

        </div>
        <div>
          <label
            htmlFor="publisher"
            className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
          >
            出版社
          </label>
          <input
            id="publisher"
            type="text"
            className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
            placeholder="出版社"
            {...register("publisher", { required: true })}
          />
        </div>
        <div>
          <button
            type="submit"
            disabled={!isValid || !isDirty || isSubmitting}
            className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg disabled:opacity-50"
            onClick={handleSubmit(onSubmit)}
          >
            更新
          </button>
        </div>
      </div>
    </div>
  )
}

基本的には登録画面と同じような内容ですので、コピーして使いまわせるところが多いです。違う点だけ説明します。

type Inputs = {
+ id: string;
  name: CreateBookInput["name"];
  authers: AutherInputs[];
  publisher: CreateBookInput["publisher"];
};

登録以外はIDを使って更新処理を実行するため、idを保持できるように項目を追加しています。

export default function Edit() {
+ const router = useRouter();
+ const { id } = router.query;

...
}

一覧画面から遷移する際に書籍のIDをURLのパラメーターとして渡します。それを受け取るための処理がuseRouter()です。

...
const {
    register,
    control,
    handleSubmit,
+  setValue,
    formState: {
      isValid,
      isDirty,
      isSubmitting,
    }
  } = useForm<Inputs>({
    mode: "onChange",
    defaultValues: {
      name: "",
      authers: [],
      publisher: "",
    },
  });
...

編集対象のデータを取得後にフォームに内容を反させるために、渡す用の関数です。後で使用します。

  useEffect(() => {
    if (!id || typeof id === "object") {
      return;
    }

    fetchBook(id as string).then((book) => {
      setValue("id", book.id);
      setValue("name", book.name);
      setValue("authers", book.auther
        ?.filter((item): item is NonNullable<typeof item> => !!item)
        .map((name) => ({ name })) || []);
      setValue("publisher", book.publisher);
    }).catch((error) => {
      console.error(error);
    });
  }, [id]);

前回軽く触れたuseEffect()を使っています。第2引数の配列に変数名を渡すことで、その変数が変更されたときに第1引数に渡している関数が実行されます。関数の中では、クエリパラメーターターから取得したIDを用いて書籍情報を取得する処理を実行しています。

  const onSubmit = (data: Inputs) => {
    updateBookData({
      id: data.id,
      name: data.name,
      auther: data.authers
        .map((auther) => auther.name)
        .filter((item): item is NonNullable<typeof item> => !!item),
      publisher: data.publisher,

    })
      .then(() => {
        alert("登録しました");
        router.push("/");
      }).catch((error) => {
        alert("登録に失敗しました");
      });
  }

登録処理では、更新処理を呼び出しています。今回は、アラートを表示したり、成功時に一覧画面に遷移する処理を追加しました。

これで編集画面の準備はおわりです。

一覧画面

一覧画面から編集画面に遷移するためのリンクを追加します。pages/index.tsxを開き次のように変更します。

"use client";

import useBook from "@/hooks/useBook";
import Link from "next/link";

export default function Home() {
...
  return (
    <div className="space-y-5">
      ...
      <div>
        <table className="border-collapse border border-slate-400 table-auto">
          <thead>
            <tr>
              <td className="border border-slate-300 p-2 text-center font-bold bg-slate-50">ID</td>
              <td className="border border-slate-300 p-2 text-center font-bold bg-slate-50">ISBN</td>
              <td className="border border-slate-300 p-2 text-center font-bold bg-slate-50">書籍名</td>
              <td className="border border-slate-300 p-2 text-center font-bold bg-slate-50">著者</td>
              <td className="border border-slate-300 p-2 text-center font-bold bg-slate-50">出版社</td>
+            <td className="border border-slate-300 p-2 text-center font-bold bg-slate-50">操作</td>
            </tr>
          </thead>
          <tbody>
            {books.map((book) => (
              <tr>
                <td className="border border-slate-300 p-2">{book.id}</td>
                <td className="border border-slate-300 p-2">{book.isbn || "(なし)"}</td>
                <td className="border border-slate-300 p-2">{book.name}</td>
                <td className="border border-slate-300 p-2">
                  {book.auther?.filter((item): item is NonNullable<typeof item> => !!item).join(", ")}
                </td>
                <td className="border border-slate-300 p-2">{book.publisher}</td>
+               <td className="border border-slate-300 p-2">
+                 <Link
+                   href={`/book/${encodeURIComponent(book.id)}/edit`}
+                   className="text-blue-500 hover:text-blue-700"
+                 >
+                   編集
+                 </Link>
+               </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

これで一覧画面から編集画面へ遷移できるようになりました。

おわりに

今回までで、登録、取得、変更までできるようになりました。CRUDという意味では最後に削除が残っていますが、次回に続く。ということで今回は以上です。