GLoves Blog
Amplify CLIで作成する簡素なTodoアプリ

Amplify CLIで作成する簡素なTodoアプリ

タグ
投稿日
2023-09-03

作成するアプリのイメージ

Amplify CLIを使用し、CRUD機能を備えた簡単なtodoアプリを制作します。

技術スタック

  • フロントエンド:Next.js 13.4.19
  • バックエンド:AppSync (今回はLambda Functionの実装はしていません)

その他パッケージのバージョン

  • npm version 9.8.1
  • amplify cli 12.3.0
  • aws-amplify/cli@10.6.2 ※12.3.0の場合mock apiでスキーマが生成されなかったため、ダウングレードしました。
  • aws-amplify/cli@12.3.0 ※mock apiでスキーマが生成されなかったため、mock apiでの動作確認時のみ10.6.2にダウングレードしました。
  • Recoil 0.7.7
  • React-hook-form 7.45.4
  • Material-UI 5.14.6

Amplify CLIのセットアップ

公式サイトを参考に実施してください。

https://docs.amplify.aws/cli/start/install/

Amplify CLIのインストール

インストール/インストールしたバージョンを確認
$ npm install -g @aws-amplify/cli 
$ amplify -v 

IAMユーザーの作成

ポリシー:AdministratorAccess-Amplifyをアタッチ
$ amplify configure

Next.jsの初期設定

https://nextjs.org/docs/getting-started/installation

公式のインストールガイドに沿って初期プロジェクトを作成する

$ npx create-next-app@latest
$ npx create-next-app@latest
Need to install the following packages:
create-next-app@13.4.19
Ok to proceed? (y) y
✔ What is your project named? … amplify-cli-todo-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias? … No / Yes
✔ What import alias would you like configured? … @/*
Creating a new Next.js app in /Users/aguru/Desktop/amplify-cli-todo-app.

Amplifyの初期設定

Next.jsプロジェクトのルートディレクトリで実行する

$ amplify init

初期設定の際に先に設定したProfile名を指定する。


Hostingの設定

$ amplify add hosting                                                     
✔ Select the plugin module to execute · Hosting with Amplify Console (Managed hosting with custom domains, Continuous deployment)
? Choose a type Continuous deployment (Git-based deployments)
? Continuous deployment is configured in the Amplify Console. Please hit enter once you connect your repository 
Amplify hosting urls: 
┌──────────────┬────────────────────────────────────────────┐
│ FrontEnd Env │ Domain                                     │
├──────────────┼────────────────────────────────────────────┤
│ main         │ https://main.d1i7g92hfrd9va.amplifyapp.com │
└──────────────┴────────────────────────────────────────────┘

アプリケーション構築の前に先に設定をしておく。

また、この時点でデプロイができることを確認しておくと良いです。

https://docs.aws.amazon.com/ja_jp/amplify/latest/userguide/deploy-nextjs-app.html


認証の設定

$ amplify add auth
Using service: Cognito, provided by: awscloudformation
 
 The current configured provider is Amazon Cognito. 
 
 Do you want to use the default authentication and security configuration? Default configuration
 Warning: you will not be able to edit these selections. 
 How do you want users to be able to sign in? Username
 Do you want to configure advanced settings? No, I am done.
✅ Successfully added auth resource amplifyclitodoappc6058fff locally

✅ Some next steps:
"amplify push" will build all your local backend resources and provision it in the cloud
"amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud


$ amplify push
✔ Successfully pulled backend environment dev from the cloud.

    Current Environment: dev
    
┌──────────┬───────────────────────────┬───────────┬───────────────────┐
│ Category │ Resource name             │ Operation │ Provider plugin   │
├──────────┼───────────────────────────┼───────────┼───────────────────┤
│ Auth     │ amplifyclitodoappc6058fff │ Create    │ awscloudformation │
├──────────┼───────────────────────────┼───────────┼───────────────────┤
│ Hosting  │ amplifyhosting            │ Update    │                   │
└──────────┴───────────────────────────┴───────────┴───────────────────┘
✔ Are you sure you want to continue? (Y/n) · yes

Deployment completed.
Deploying root stack amplifyclitodoapp [ =============--------------------------- ] 1/3
	amplify-amplifyclitodoapp-dev… AWS::CloudFormation::Stack     UPDATE_IN_PROGRESS             Sat Aug ~~~~~~~~~~~~~~~ 
	authamplifyclitodoappc6058fff  AWS::CloudFormation::Stack     CREATE_COMPLETE                Sat Aug ~~~~~~~~~~~~~~~     
Deployed auth amplifyclitodoappc6058fff [ ======================================== ] 6/6
	UserPool                       AWS::Cognito::UserPool         CREATE_COMPLETE                Sat Aug ~~~~~~~~~~~~~~~     
	UserPoolClientRole             AWS::IAM::Role                 CREATE_IN_PROGRESS             Sat Aug ~~~~~~~~~~~~~~~    
	UserPoolClientWeb              AWS::Cognito::UserPoolClient   CREATE_COMPLETE                Sat Aug ~~~~~~~~~~~~~~~   
	UserPoolClient                 AWS::Cognito::UserPoolClient   CREATE_COMPLETE                Sat Aug ~~~~~~~~~~~~~~~    
	IdentityPool                   AWS::Cognito::IdentityPool     CREATE_COMPLETE                Sat Aug ~~~~~~~~~~~~~~~    
	IdentityPoolRoleMap            AWS::Cognito::IdentityPoolRol… CREATE_COMPLETE                Sat Aug ~~~~~~~~~~~~~~~     

Deployment state saved successfully.

APIの設定

GraphQL APIを追加

$ amplify add api

https://docs.amplify.aws/lib/graphqlapi/getting-started/q/platform/js/


モックサーバーの設定

javaがローカル環境にインストールされていない場合は、javaをインストールする

javaをインストールしてpathを通す/openjdk@20のバージョン指定はインストールしたjavaを指定すること
$ brew install java
$ sudo ln -s /usr/local/opt/openjdk@20/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk.jd
$ java -version

先にAmazon Cognitoでユーザーとグループを作成する。

グループを作成したユーザーと紐付けをする。


スキーマ定義

このスキーマ定義では認証ルールとして、[”Users”]グループに属したアカウントのみ、CRUDができるように設定しています。

amplify/backend/api/[プロジェクト名]/schema.graphql
type Todo
  @model
  @auth(
    rules: [
      {
        provider: userPools
        allow: groups
        groups: ["Users"]
        operations: [create, update, read, delete]
      }
    ]
  ) {
  id: ID!
  name: String!
  completed: Boolean!
}
$ amplify mock api

ローカル環境で検証できることを確認


必要ライブラリーのインストール

material UIのインストール
$ npm install @mui/material @emotion/react @emotion/styled aws-amplify @aws-amplify/ui-react react-hook-form recoil

https://mui.com/material-ui/getting-started/installation/


Build時にエラーが出たため追加したライブラリー

$ npm i -d encoding
$ npm i aws-crt

aws-crt関連でcompileに失敗するのでwebpackの設定を修正する。

/next.config.js
/** @type {import('next').NextConfig} */
const webpack = require("webpack");
const nextConfig = {
  webpack: (config, { isServer, nextRuntime }) => {
    // Avoid AWS SDK Node.js require issue
    if (isServer && nextRuntime === "nodejs")
      config.plugins.push(
        new webpack.IgnorePlugin({ resourceRegExp: /^aws-crt$/ })
      );
    return config;
  },
};

module.exports = nextConfig;

実装方法(主要な実装を記載)

URL設計(作成したページ)は下記になります。

  • トップページ:todo一覧
  • /todos/new:todo新規登録ページ
  • /todos/:id:編集ページ (/todos/:id/edit でも良い。その場合はpage.tsxではなく、edit.tsxファイル上で編集ページを生成する)

共通ファイル

src/app/layout.tsx
import "@/app/globals.css";
import { theme } from "@/app/theme";
import type { Metadata } from "next";
import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider } from "@mui/material/styles";
import RecoilProvider from "@/store/Provider";
import { Container } from "@mui/material";
import "@aws-amplify/ui-react/styles.css";
import { Header } from "@/components/Header";
import { AuthListener } from "@/components/AuthListener";

export const metadata: Metadata = {
  title: "Todo App",
  description: "Created by Next.js",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      {/* ①RecoilのProviderで全体を囲む */}
      <RecoilProvider>
       {/* ②ログイン、ログアウト、アクセス時に */}
       {/* 認証情報をRecoilに保存するためのリスナー */}
        <AuthListener />
        <ThemeProvider theme={theme}>
          <CssBaseline />
          <body>
            <Header />
            <Container>{children}</Container>
          </body>
        </ThemeProvider>
      </RecoilProvider>
    </html>
  );
}

【①RecoilのProviderで全体を囲む】

RecoilRootコンポーネントは“user client”(CSR)をする必要があるため、別ファイルとして切り出しています。

src/store/Provider.tsx
"use client";
import { RecoilRoot } from "recoil";

const RecoilProvider = ({ children }: { children: React.ReactNode }) => {
  return <RecoilRoot>{children}</RecoilRoot>;
};

export default RecoilProvider;
src/store/authState.ts
import { atom } from "recoil";

export const authState = atom<boolean>({
  key: "authState",
  default: false,
});
src/store/todoState.ts
import { atom } from "recoil";
type Todo = {
  id: string;
  name: string;
  completed: boolean;
  createdAt: string;
  updatedAt: string;
} | null;

const todosState = atom<Todo[]>({
  key: "todos",
  default: [],
});

export { todosState };

【②ログイン、ログアウト、アクセス時に認証情報をRecoilに保存するためのリスナー】

  • ログイン時にはヘッダーの「ログアウト」ボタンを表示
  • ログアウト時にはヘッダーの「ログアウト」ボタンを非表示

など、認証に合わせた表示切り替えや機能実装に認証情報が必要なため実装しました。

hooks/userAuthHook.ts
"use client";
import { useSetRecoilState } from "recoil";
import { authState } from "@/store/authState";
import { Auth, Hub } from "aws-amplify";
import { useEffect } from "react";

const useAuthHook = () => {
  const setAuth = useSetRecoilState(authState);

  useEffect(() => {
    const checkAuthState = async () => {
      try {
        // ページがロードまたはリロードされたとき、アプリは現在の認証状態を確認する。
        await Auth.currentAuthenticatedUser();
        setAuth(true);
      } catch (error) {
        setAuth(false);
      }
    };

    checkAuthState();

    const unsubscribe = Hub.listen("auth", ({ payload: { event } }) => {
      // アプリのライフサイクル中(ログイン、ログアウト等)に、ユーザーの認証状態の変更を検知する。
      switch (event) {
        case "signIn":
          setAuth(true);
          break;
        case "signOut":
          setAuth(false);
          break;
      }
    });

    // useEffectのクリーンアップ関数
    return () => unsubscribe();
  }, [setAuth]);
};

export { useAuthHook };
src/components/AuthListener.tsx
"use client";
import { useAuthHook } from "@/hooks/useAuthHook";

const AuthListener = () => {
  useAuthHook();

  return null;
};

export { AuthListener };

todo一覧

src/app/page.tsx
"use client";
import React from "react";
import { useEffect } from "react";
import Link from "next/link";
import { Amplify, API, graphqlOperation } from "aws-amplify";
import awsconfig from "@/aws-exports";
import { GraphQLResult } from "@aws-amplify/api";
import { withAuthenticator } from "@aws-amplify/ui-react";
import { Button, Grid } from "@mui/material";
import { ListTodosQuery } from "@/API";
import { useRecoilState } from "recoil";
import { todosState } from "@/store/todoState";
import { listTodos } from "@/graphql/queries";
import { Todo } from "@/components/todo";

Amplify.configure(awsconfig);

const TodosIndex = () => {
  const [todos, setTodos] = useRecoilState(todosState);

  useEffect(() => {
    const asyncFunc = async () => {
      const result = (await API.graphql(
        graphqlOperation(listTodos)
      )) as GraphQLResult<ListTodosQuery>;

      setTodos(result?.data?.listTodos?.items || []);
    };
    asyncFunc();
  }, [setTodos]);

  return (
    <>
      <Grid container direction="column" spacing={2} sx={{ marginTop: 1 }}>
        <Grid item md={6}>
          <h1>Todos</h1>
        </Grid>
        <Grid item md={6}>
          <Link href="/todos/new">
            <Button variant="contained" color="primary">
              New
            </Button>
          </Link>
        </Grid>
      </Grid>
      <Grid container direction="column" spacing={2} sx={{ marginTop: 1 }}>
        {todos.map((todo) =>
          todo ? <Todo key={todo.id} todo={todo} /> : null
        )}
      </Grid>
    </>
  );
};

export default withAuthenticator(TodosIndex);
src/components/Todo.tsx
import Link from "next/link";
import { API, graphqlOperation, GraphQLResult } from "@aws-amplify/api";
import { DeleteTodoMutation, UpdateTodoMutation } from "@/API";
import { deleteTodo, updateTodo } from "@/graphql/mutations";
import {
  Card,
  CardActions,
  CardContent,
  Typography,
  Button,
  Grid,
  FormControlLabel,
  Checkbox,
} from "@mui/material";
import { useRecoilState } from "recoil";
import { todosState } from "@/store/todoState";
import { FC } from "react";

type TodoType = {
  id: string;
  name: string;
  completed: boolean;
  createdAt: string;
  updatedAt: string;
} | null;

type TodoProps = {
  todo: TodoType;
};

const Todo: FC<TodoProps> = ({ todo }) => {
  const [todos, setTodos] = useRecoilState(todosState);

  const onArchive = async () => {
    if (!todo) return;

    (await API.graphql(
      graphqlOperation(deleteTodo, {
        input: {
          id: todo.id,
        },
      })
    )) as GraphQLResult<DeleteTodoMutation>;
    setTodos(todos.filter((item) => item && item.id !== todo.id));
  };

  const handleChangeDone = async (e: { target: { checked: boolean } }) => {
    if (!todo) return;

    const isChecked = e.target.checked;

    try {
      const result = (await API.graphql(
        graphqlOperation(updateTodo, {
          input: {
            id: todo.id,
            completed: isChecked,
          },
        })
      )) as GraphQLResult<UpdateTodoMutation>;
      const updatedTodo = result?.data?.updateTodo;
      setTodos(
        todos.map((item) =>
          item && item.id === todo.id && updatedTodo ? updatedTodo : item
        )
      );
    } catch (error) {
      console.error("Error updating todo:", error);
    }
  };

  if (!todo) {
    return null;
  }

  return (
    <Grid item md={6}>
      <Card>
        <CardContent>
          <Typography variant="h5" component="h2">
            <Link href={`/todos/${todo.id}`}>{todo.name}</Link>
          </Typography>
          <Typography color="textSecondary">
            created at {new Date(todo.createdAt).toLocaleString()}
          </Typography>
        </CardContent>
        <CardActions>
          <FormControlLabel
            control={
              <Checkbox
                color="primary"
                onChange={handleChangeDone}
                checked={todo.completed}
              />
            }
            label="Done"
          />
          <Button variant="contained" color="secondary" onClick={onArchive}>
            Archive
          </Button>
        </CardActions>
      </Card>
    </Grid>
  );
};

export { Todo };

todo新規登録ページ

src/app/todos/new/page.tsx
"use client";
import { useRouter } from "next/navigation";
import { Typography } from "@mui/material";
import { Grid } from "@mui/material";
import { Amplify } from "aws-amplify";
import awsconfig from "@/aws-exports";
import { API, graphqlOperation, GraphQLResult } from "@aws-amplify/api";
import { withAuthenticator } from "@aws-amplify/ui-react";
import { CreateTodoMutation } from "@/API";
import { createTodo } from "@/graphql/mutations";
import Form from "@/components/Form";

Amplify.configure(awsconfig);

const TodoNewPage = () => {
  const router = useRouter();

  const onSubmit = async (name: string) => {
    try {
      const result = (await API.graphql(
        graphqlOperation(createTodo, {
          input: {
            name,
            completed: false,
          },
        })
      )) as GraphQLResult<CreateTodoMutation>;
      router.push("/");
    } catch (error) {
      console.error("Error creating todo:", error);
    }
  };

  return (
    <>
      <Typography variant="h5" sx={{ marginTop: 2 }}>
        Todoの新規登録
      </Typography>
      <Grid sx={{ marginTop: 1 }}>
        <Form onSubmit={onSubmit} />
      </Grid>
    </>
  );
};

export default withAuthenticator(TodoNewPage);

【新規登録・編集で共有のFormコンポーネント】

React-Hook-Formを使用しています。入力値の状態管理やバリデーション設定の管理がし易い印象です。

src/components/Form.tsx
import { Controller, SubmitHandler, useForm } from "react-hook-form";
import { TextField, Button, Grid } from "@mui/material";
import { FC } from "react";

type Inputs = {
  name: string;
};

type FormProps = {
  onSubmit: (name: string) => void;
  name?: string;
};

const Form: FC<FormProps> = (props) => {
  const { control, handleSubmit, setValue } = useForm<Inputs>({
    defaultValues: { name: props.name || "" },
  });

  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
    props.onSubmit(data.name);
  };

  const validationRules = {
    name: {
      required: "タイトルを入力してください。",
      maxLength: { value: 30, message: "30文字以内で入力をしてください。" },
    },
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Grid container direction="column" spacing={2}>
        <Grid item md={6}>
          <Controller
            name="name"
            control={control}
            rules={validationRules.name}
            render={({ field, fieldState }) => (
              <TextField
                {...field}
                type="text"
                label="TODOのタイトル"
                error={fieldState.invalid}
                helperText={fieldState.error?.message}
              />
            )}
          />
        </Grid>
        <Grid item md={6}>
          <Button type="submit" variant="contained" color="primary">
            Save
          </Button>
        </Grid>
      </Grid>
    </form>
  );
};

export default Form;

todo編集ページ

src/app/todos/[id]/page.tsx
"use client";
import { useParams, useRouter } from "next/navigation";
import { Typography, Grid } from "@mui/material";
import { Amplify } from "aws-amplify";
import awsconfig from "@/aws-exports";
import { API, graphqlOperation, GraphQLResult } from "@aws-amplify/api";
import { withAuthenticator } from "@aws-amplify/ui-react";
import { GetTodoQuery, UpdateTodoMutation } from "@/API";
import { updateTodo } from "@/graphql/mutations";
import Form from "@/components/Form";
import { useEffect, useState } from "react";
import { getTodo } from "@/graphql/queries";

Amplify.configure(awsconfig);

const TodoEditPage = () => {
  const [todoName, setTodoName] = useState<string | null>(null);
  const id = useParams().id;
  const router = useRouter();

  useEffect(() => {
    if (id) {
      fetchTodoDetails(id as string);
    }
  }, [id]);

  const fetchTodoDetails = async (todoId: string) => {
    try {
      const result = (await API.graphql(
        graphqlOperation(getTodo, { id: todoId })
      )) as GraphQLResult<GetTodoQuery>;
      if (result.data && result.data.getTodo) {
        setTodoName(result.data.getTodo.name);
      }
    } catch (error) {
      console.error("Error fetching todo details:", error);
    }
  };

  const onSubmit = async (updatedName: string) => {
    try {
      const result = (await API.graphql(
        graphqlOperation(updateTodo, {
          input: {
            id,
            name: updatedName,
          },
        })
      )) as GraphQLResult<UpdateTodoMutation>;
      router.push("/");
    } catch (error) {
      console.error("Error updating todo:", error);
    }
  };

  if (todoName === null) return null;

  return (
    <>
      <Typography variant="h5" sx={{ marginTop: 2 }}>
        Todoの編集
      </Typography>
      <Grid sx={{ marginTop: 1 }}>
        <Form onSubmit={onSubmit} name={todoName} />
      </Grid>
    </>
  );
};

export default withAuthenticator(TodoEditPage);

スキーマを定義すれば、基本的なCRUDに必要なQuery, Mutationを自動生成してくれる。もちろん、複雑なことをするにはLamdba Functionの活用や、DynamoDBのインデックス設計等が必要になるが、比較的すぐにアプリを作成できたことに衝撃!!!