Amazon Web Services ブログ

Next.js と Amplify を活用した製品ロードマップアプリの構築

本記事では、私たちがロードマップを公開している自動車会社であると想像してみましょう。私たちには世界中のユーザーがいて、車載エンターテイメントシステムにどのような機能が提供されたかを定期的にチェックしています。

ここでは、プロダクトマネージャーがログインしてロードマップを更新し、ロードマップページに反映させるための管理ページを構築します。 ロードマップページは世界中の多くの読者が閲覧し、更新頻度が低いため、Incremental Static Regeneration (ISR) を用いた Static Site Generator (SSG) の最適な候補になります。

本記事では、「Deploy a Next.js 13 app with authentication to AWS Amplify」を基に開発し、プロジェクトの初期化、Amazon Cognito 認証の実装、Amplify Hosting へのデプロイを行います。

GraphQL API のデプロイ

プロジェクトのルートディレクトリで以下のコマンドを実行し、データを保存する GraphQL API を追加します。

amplify add api

Amplify CLI のプロンプトには以下のように答えます。

? Select from one of the below mentioned services: (Use arrow keys)
❯ GraphQL
  REST
? Here is the GraphQL API that we will create. Select a setting to edit or continue (Use arrow keys)
  Name: testamplify
  Authorization modes: API key (default, expiration time: 7 days from now)
  Conflict detection (required for DataStore): Disabled
❯ Continue
? Choose a schema template:
  Single object with fields (e.g., “Todo” with ID, name, description)
  One-to-many relationship (e.g., “Blogs” with “Posts” and “Comments”)
❯ Blank Schema
? Do you want to edit the schema now? (Y/n) › Y

次に、schema.graphql の内容を以下のように書き換えます。

type Feature @model @auth(rules: [{ allow: owner }, { allow: public, operations: [read] }]) {
  id: ID!
  title: String!
  released: Boolean!
  description: String
  internalDoc: String
}

最後に、以下のコマンドを実行し、API をデプロイします。

amplify push

次に、プロダクトマネージャがロードマップを作成、更新、削除するための管理画面を作成します。
pages/admin.js ファイルを作成し、以下のコードを追加して、このシリーズの最初の投稿で行ったように、Amplify の withAuthenticator コンポーネントを使用し、Cognito で認証を行うページを作成します。

// pages/admin.js
import {
  Button,
  Divider,
  Flex,
  Heading,
  View,
  withAuthenticator,
} from "@aws-amplify/ui-react";
import { Auth } from "aws-amplify";
import Link from "next/link";
import React, { useState } from "react";

function Admin() {
  // define state to be used later
  const [activeFeature, setActiveFeature] = useState(undefined);
  return (
    <View padding="2rem">
      <Flex justifyContent={"space-between"}>
        <Link href={"/admin"}>
          <Heading level={2}>AmpliCar Roadmap Admin</Heading>
        </Link>
        <Flex alignItems={"center"}>
          <Button type="button" onClick={() => Auth.signOut()}>
            Sign out
          </Button>
        </Flex>
      </Flex>
      <Divider marginTop={"medium"} marginBottom={"xxl"} />
      <Flex></Flex>
    </View>
  );
}

export default withAuthenticator(Admin);

ここでは、管理ページ用の React コンポーネント Admin を作成します。aws-amplify/ui-react パッケージの withAuthenticator コンポーネントを使用して、認証機能をコンポーネントに追加します。

また、このコンポーネントは Amplify UI の ButtonDividerFlexHeadingView コンポーネントをインポートして使用し、サインアウトボタン、ディバイダー、後で使用するレイアウト要素を含むトップナビゲーションバーをレンダリングします。 サインアウトボタンがクリックされると、Auth.signOut() メソッドが呼び出され、ユーザーがサインアウトします。

最後に、状態を管理するための React の useState フックを使って、activeFeaturesetActiveFeature で状態を追加します。

ブラウザで http://localhost:3000/admin にアクセスすると以下のような画面が表示されます。

アカウントを作成してサインインすると、ヘッダーにサインアウトボタンが表示されます。

ロードマップの機能を作成・更新するためのフォームを作成します。 プロジェクトのルートディレクトリに components ディレクトリを作成し、FeatureForm.js 作成し、以下のコードに置き換えます。

// components/FeatureForm.js

import {
  Button,
  Flex,
  Heading,
  SwitchField,
  Text,
  TextField,
  View,
} from "@aws-amplify/ui-react";
import { API } from "aws-amplify";
import React, { useEffect, useState } from "react";
import { createFeature, updateFeature } from "../src/graphql/mutations";

function FeatureForm({ feature = null, setActiveFeature }) {
  const [id, setId] = useState(undefined);
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [isReleased, setReleased] = useState(false);

  useEffect(() => {
    if (feature) {
      setId(feature.id);
      setTitle(feature.title);
      setDescription(feature.description);
      setReleased(feature.released);
    }
  }, [feature]);

  function resetFormFields() {
    setId(undefined);
    setTitle("");
    setDescription("");
    setReleased(false);
  }

  async function handleSaveFeature() {
    try {
      await API.graphql({
        authMode: "AMAZON_COGNITO_USER_POOLS",
        query: feature ? updateFeature : createFeature,
        variables: {
          input: {
            id: feature ? id : undefined,
            title,
            description,
            released: isReleased
          },
        },
      });

      feature && setActiveFeature(undefined);
      resetFormFields();
    } catch ({ errors }) {
      console.error(...errors);
      throw new Error(errors[0].message);
    }
  }

  return (
    <View>
      <Heading marginBottom="medium" level={5}>
        {feature ? "Edit" : "New"} Feature
      </Heading>
      <Flex direction={"column"} basis={"max-content"}>
        <TextField
          value={title}
          label="Title"
          errorMessage="There is an error"
          name="title"
          onChange={(e) => setTitle(e.target.value)}
        />

        <TextField
          value={description}
          name="description"
          label="Description"
          errorMessage="There is an error"
          onChange={(e) => setDescription(e.target.value)}
        />

        <SwitchField
          isChecked={isReleased}
          isDisabled={false}
          label="Released?"
          labelPosition="start"
          onChange={() => setReleased(!isReleased)}
        />

        <Flex marginTop="large">
          <Button
            onClick={() => {
              setActiveFeature(undefined);
              resetFormFields();
            }}
          >
            Cancel
          </Button>
          <Button onClick={() => handleSaveFeature()}>Save</Button>
        </Flex>
      </Flex>
    </View>
  );
}

export default FeatureForm;

このコードでは、FeatureForm という React コンポーネントを定義します。FeatureForm コンポーネントは、feature props と setActiveFeature props を受け取ります。これらは、フォームフィールドにデータを入力し、保存された後にフォームをリセットするために使用されます。コンポーネントは useState フックを使用して、機能のタイトル、説明、リリース済みかどうかを含むフォームフィールドの状態を追跡します。

コンポーネントがレンダリングされると、feature props が渡されたかどうかをチェックし、渡された場合はuseEffect フックを使ってフォームフィールドに機能を入力します。そうでなければ、フォームフィールドは空のままです。

このコンポーネントには、機能のタイトル、説明、リリースステータスを入力するためのいくつかのフォームと、保存ボタンとキャンセルボタンがあります。save ボタンがクリックされると、コンポーネントは API.graphql を介して createFeature mutation を発行し、その機能を GraphQL API を使用して保存します。コンポーネントが feature props でレンダリングされた場合、updateFeature mutation を使用してデータベース内の既存の機能を更新します。

機能が保存された後、コンポーネントはフォームフィールドをリセットし、オプションで setActiveFeature コールバック props を呼び出して Admin コンポーネントの activeFeature をリセットします。

pages/adminAdmin コンポーネントを更新して FeatureForm コンポーネントを追加し、ロードマップ管理画面に表示されるようにします。また、機能を編集しているかどうかを追跡するステート変数を追加します。

// pages/admin.js
import {
  Button,
  Divider,
  Flex,
  Heading,
  View,
  withAuthenticator,
} from "@aws-amplify/ui-react";
import { Auth } from "aws-amplify";
import Link from "next/link";
import React, { useState } from "react";
import FeatureForm from "../components/FeatureForm";

function Admin() {
  const [activeFeature, setActiveFeature] = useState(undefined);
  return (
    <View padding="2rem">
      // ...
      <Divider marginTop={"medium"} marginBottom={"xxl"} />
      <Flex>
        <FeatureForm feature={activeFeature} setActiveFeature**={setActiveFeature} />
      </Flex>
    </View>
  );
}

export default withAuthenticator(Admin);

ページを更新すると、新機能フォームが表示されます。

次に、機能の一覧を表示し、編集や削除を可能にするテーブルを作成しましょう。

components ディレクトリに新しく FeaturesTable コンポーネントを作成し、以下のコードを貼り付けます。

// components/FeaturesTable.js
import {
  Button,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableRow,
  View,
} from "@aws-amplify/ui-react";
import { API, graphqlOperation } from "aws-amplify";
import React, { useEffect, useState } from "react";
import { deleteFeature } from "../src/graphql/mutations";
import { listFeatures } from "../src/graphql/queries";

function FeaturesTable({ initialFeatures = [], setActiveFeature }) {
  const [features, setFeatures] = useState(initialFeatures);
  
  useEffect(() => {
    const fetchFeatures = async () => {
      const result = await API.graphql(graphqlOperation(listFeatures));
      setFeatures(result.data.listFeatures.items);
    };

    fetchFeatures();
  }, []);

  async function handleDeleteFeature(id) {
    try {
      await API.graphql({
        authMode: "AMAZON_COGNITO_USER_POOLS",
        query: deleteFeature,
        variables: {
          input: {
            id,
          },
        },
      });
    } catch ({ errors }) {
      console.error(...errors);
    }
  }

  if (features.length === 0) {
    return <View>No features</View>;
  }

  return (
    <Table>
      <TableHead>
        <TableRow>
          <TableCell as="th">Feature</TableCell>
          <TableCell as="th">Released</TableCell>
          <TableCell></TableCell>
        </TableRow>
      </TableHead>
      <TableBody>
        {features.map((feature) => (
          <TableRow key={feature.id}>
            <TableCell>{feature.title}</TableCell>
            <TableCell>{feature.released ? "Yes" : "No"}</TableCell>
            <TableCell>
              <Button size="small" onClick={() => setActiveFeature(feature)}>
                Edit
              </Button>
              <Button
                size="small"
                onClick={() => handleDeleteFeature(feature.id)}
              >
                Delete
              </Button>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

export default FeaturesTable;

このコードでは、FeaturesTable という React コンポーネントを定義し、機能のテーブルをレンダリングします。テーブルには、各機能のタイトルとリリース済みかどうかが表示され、各機能を編集および削除するためのボタンが用意されています。 このテーブルは、Amplify UI の TableTableBodyTableCellTableRow コンポーネントを使用してレンダリングされます。

FeaturesTable コンポーネントは、オプションで initialFeatures の配列と setActiveFeature 関数を props として受け取ります。useState フックを使用して features ステート変数に features リストを格納し、useEffect フックを使用してコンポーネントのマウント時に GraphQL API から features リストを取得します。

handleDeleteFeature 関数は、deleteFeature mutation を呼び出して機能を削除するために使用します。handleDeleteFeature 関数は、テーブル内の各機能の削除ボタンに props として渡され、ボタンがクリックされると、対応する機能が削除されます。

編集ボタンがクリックされると、クリックされた機能を引数として setActiveFeature 関数が呼び出されます。これにより、Admin コンポーネントの activeFeature ステート変数が更新され、FeatureForm コンポーネントが再レンダリングされます。これにより、ユーザーはフォームを使って選択した機能を編集できるようになります。

次に、Pages/admin.js を更新して、FeaturesTable コンポーネントを追加します。

// pages/admin.js
import {
  // ...
  withAuthenticator,
} from "@aws-amplify/ui-react";
import { Auth } from "aws-amplify";
import Link from "next/link";
import React, { useState } from "react";
import FeatureForm from "../components/FeatureForm";
import FeaturesTable from "../components/FeaturesTable";

function Admin() {
  const [activeFeature, setActiveFeature] = useState(undefined);
  return (
    <View padding="2rem">
      // ...
      <Divider marginTop={"medium"} marginBottom={"xxl"} />
      <Flex>
        <FeatureForm
          feature={activeFeature}
          setActiveFeature={setActiveFeature}
        />
        <FeaturesTable setActiveFeature={setActiveFeature} />
      </Flex>
    </View>
  );
}

export default withAuthenticator(Admin);

このコンポーネントを追加してリフレッシュすると、まだ機能を追加していないため、No features と表示されるはずです。

Server-Side Rendering(SSR)によるユーザー体験の向上

優れたユーザー体験を提供するために、Server-Side Rendering (SSR) を使って機能を取得するように管理ページを更新しましょう。SSR を使うことで、データなしでページをレンダリングし、別のネットワークリクエストが完了し、データが入力されるのを待つよりも優れたユーザー体験を提供することが出来ます。

// pages/admin.js
import {
  Button,
  Divider,
  Flex,
  Heading,
  View,
  withAuthenticator,
} from "@aws-amplify/ui-react";
import { Auth, withSSRContext } from "aws-amplify";
import Link from "next/link";
import React, { useState } from "react";
import FeatureForm from "../components/FeatureForm";
import FeaturesTable from "../components/FeaturesTable";
import { listFeatures } from "../src/graphql/queries";

export async function getServerSideProps({ req }) {
  const SSR = withSSRContext({ req });
  const response = await SSR.API.graphql({ query: listFeatures });

  return {
    props: {
      initialFeatures: response.data.listFeatures.items,
    },
  };
}

function Admin({ initialFeatures }) {
  const [activeFeature, setActiveFeature] = useState(undefined);
  return (
    <View padding="2rem">
      // ...
      <Divider marginTop={"medium"} marginBottom={"xxl"} />
      <Flex>
        <FeatureForm
          feature={activeFeature}
          setActiveFeature={setActiveFeature}
        />
        <FeaturesTable
          initialFeatures={initialFeatures}
          setActiveFeature={setActiveFeature}
        />
      </Flex>
    </View>
  );
}

export default withAuthenticator(Admin);

このコードでは、getServerSideProps を使用して、listFeatures query で Amplify API.graphql を呼び出し、サーバー上の React コンポーネントに注入される props オブジェクトで初期の機能を返します。 Admin コンポーネントは initialFeatures を受け取り、FeaturesTable コンポーネントに渡します。

新機能フォームで、機能を追加してページをリフレッシュすると、右側のテーブルに表示されます。

リアルタイム更新によるユーザー体験の向上

これまで構築したものは動作しますが、プロダクトマネージャーが最新の機能を見るためにページを更新する必要があり、最高のユーザー体験を提供するものではありません。

以下のコードでは、この機能を実装するために Amplify の GraphQL Subscription を使ってデータをサブスクライブします。

// components/FeaturesTable.js
import {
  Button,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableRow,
  View,
} from "@aws-amplify/ui-react";
import { API, graphqlOperation } from "aws-amplify";
import React, { useEffect, useState } from "react";
import { deleteFeature } from "../src/graphql/mutations";
import { listFeatures } from "../src/graphql/queries";
import { onCreateFeature, onDeleteFeature, onUpdateFeature, } from "../src/graphql/subscriptions";


function FeaturesTable({ initialFeatures = [], setActiveFeature }) {
  const [features, setFeatures] = useState(initialFeatures);

  useEffect(() => {
    const fetchFeatures = async () => {
      const result = await API.graphql(graphqlOperation(listFeatures));
      setFeatures(result.data.listFeatures.items);
    };

    fetchFeatures();
    
    const createSub = API.graphql(graphqlOperation(onCreateFeature)).subscribe({
      next: ({ value }) => {
        setFeatures((features) => [...features, value.data.onCreateFeature]);
      },
    });

    const updateSub = API.graphql(graphqlOperation(onUpdateFeature)).subscribe({
      next: ({ value }) => {
        setFeatures((features) => {
          const toUpdateIndex = features.findIndex(
            (item) => item.id === value.data.onUpdateFeature.id
          );
          if (toUpdateIndex === -1) {
            return [...features, value.data.onUpdateFeature];
          }
          return [
            ...features.slice(0, toUpdateIndex),
            value.data.onUpdateFeature,
            ...features.slice(toUpdateIndex + 1),
          ];
        });
      },
    });

    const deleteSub = API.graphql(graphqlOperation(onDeleteFeature)).subscribe({
      next: ({ value }) => {
        setFeatures((features) => {
          const toDeleteIndex = features.findIndex(
            (item) => item.id === value.data.onDeleteFeature.id
          );
          return [
            ...features.slice(0, toDeleteIndex),
            ...features.slice(toDeleteIndex + 1),
          ];
        });
      },
    });

    return () => {
      createSub.unsubscribe();
      updateSub.unsubscribe();
      deleteSub.unsubscribe();
    };
  }, []);

  // remainder of FeaturesTable component unmodified
}

export default FeaturesTable;

ここでは、createSubupdateSubdeleteSub Subscription を useEffect に追加し、AppSync から onCreateFeatureonUpdateFeatureonDeleteFeature のいずれかの Subscription に対してプッシュされたデータの変更をリッスンします。

それぞれのクエリに対して、subscribe に渡されたオブジェクトの次の関数を介して、アプリケーションを更新するロジックを実装しなければなりません。createSub は、setFeatures を呼び出し、Subscription 経由で受け取ったレコードを渡すことで、features に新しいレコードを追加します。 updateSub は、変更されたレコードを検索し、features 内のレコードを Subscription から返されたバージョンに置き換えるコールバックを実装します。deleteSub は、変更されたレコードを検索し、features から削除するコールバックを実装します。 最後に、Subscription をクリーンアップするために、それぞれの Subscription の unsubscribe メソッドへの呼び出しを返します。

管理画面でアイテムを作成したり編集したりすると、機能リストがすぐに更新されることがわかります。

本記事では、プロダクトマネージャーがロードマップに機能を追加するための管理インターフェイスを構築しました。次の記事では、ユーザー向けのページを実装し、その機能に関連する内部ドキュメントを保存する機能を追加します。

本記事は「Build a Product Roadmap with Next.js and Amplify」を翻訳したものです。