第3回 The Twelve-Factor App on AWS & Django（フロントエンドSPAを作ろう）

# 目次
- [第1回 The Twelve-Factor App on AWS & Django（The Twelve-Factor Appとは）](https://qiita.com/satsuma0711/items/3b928d0e5670a633f9d8)
- [第2回 The Twelve-Factor App on AWS & Django（バックエンドAPIを作ろう）](https://qiita.com/satsuma0711/items/850d57ad1557d0f8dddb)
- 第3回 The Twelve-Factor App on AWS & Django（フロントエンドSPAを作ろう）　←　今回

# はじめに
[前回（第2回）](https://qiita.com/satsuma0711/items/850d57ad1557d0f8dddb)ではDBに保存したTodoを取得するAPIを実装しました。

今回はAPIから取得したTodoのデータを表示するフロントエンドのSAP（シングルページアプリケーション）を作成したいと思います。

フロントエンドはNuxtとTailwind CSSを使って実装したいと思います。使用する主要なフレームワークとライブラリのバージョンは以下になります。

| フレームワーク/ライブラリ | バージョン |
|:-:|:-:|
| macOS | 12.3.1 |
| Nuxt | 2.15.8 |
| vue | 2.7.8 |
| tailwindcss | 2.2.19 |
| axios | 0.21.4 |


# アプリの実装
早速、フロントエンドアプリを実装しましょう。

```
// サンプルプロジェクトに移動
% cd sample-ecs-todo-app

// 以下の設定でNuxtプロジェクトを作成する
% npx create-nuxt-app frontend

create-nuxt-app v4.0.0
✨  Generating Nuxt.js project in frontend
? Project name: frontend
? Programming language: JavaScript
? Package manager: Yarn
? UI framework: Tailwind CSS
? Nuxt.js modules: Axios - Promise based HTTP client
? Linting tools: ESLint, Prettier
? Testing framework: Jest
? Rendering mode: Single Page App
? Deployment target: Static (Static/Jamstack hosting)
? Development tools: jsconfig.json (Recommended for VS Code if you're not using typescript), Dependabot (For auto-updating dependencies, GitHub only)
? Continuous integration: None
? What is your GitHub username? yamada
? Version control system: None

// 作成したNuxtプロジェクトに移動
% cd frontend

// 開発サーバーを起動
% yarn dev
```

`http://localhost:3000/`にアクセスして下図の画面が表示できればNuxtのセットアップは完了になります。

![スクリーンショット 2022-08-13 3.31.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76958/cce2aee3-8954-62b3-563d-2269297bd18b.png)

Nuxtプロジェクトのセットアップが完了したので、実際にTodoアプリを実装したいと思います。今回作成するTodoアプリは[guillaumebriday/todolist-frontend-vuejs](https://github.com/guillaumebriday/todolist-frontend-vuejs)を参考にしました。

まずTodoアプリを実装するのに不要なファイルを削除します。

```
% rm components/NuxtLogo.vue

% rm components/Tutorial.vue

% rm frontend/test/NuxtLogo.spec.js
```

不要なファイルを削除できたら、以下のファイルを作成・編集します。

プロキシするようNuxtの設定を変更します。
```diff_javascript:frontend/nuxt.config.js

export default {
     head: {
-        title: 'frontend',
+        title: 'Todo App',
         htmlAttrs: {
-          lang: 'en',
+          lang: 'ja',
         },
・・・（中略）・・・
     axios: {
+        proxy: true,
         // Workaround to avoid enforcing hard-coded localhost:3000: https://github.com/nuxt-community/axios-module/issues/308
         baseURL: '/',
     },
    
+    proxy: {
+        '/api/': 'http://localhost:8000',
+    },
}
```

ローディングボタンを作成します。
```diff_javascript:frontend/components/LoadingButton.vue
+ <template>
+   <button :type="type" :disabled="disabled">
+     <slot />
+   </button>
+ </template>
+ 
+ <script>
+ export default {
+   props: {
+     disabled: {
+       type: Boolean,
+       default: false
+     },
+     type: {
+       type: String,
+       default: 'submit'
+     },
+   }
+ }
+ </script>
+ 
```

タスクを作成するフォームを作成します。
```diff_javascript:frontend/components/NewTask.vue
+ <template>
+   <div class="mb-4">
+     <div class="shadow mb-4 px-4 py-4">
+       <input v-model="task.title" v-focus class="w-full mb-2  focus:outline-none font-semibold"
+              placeholder="What title is your task?">
+ 
+       <div class="flex items-center border-t"></div>
+ 
+       <input v-model="task.description" v-focus class="w-full mt-2 focus:outline-none font-semibold"
+              placeholder="What description is your task?">
+     </div>
+ 
+     <div class="text-right">
+       <button class="bg-indigo text-white font-bold py-2 px-4 rounded" @click="add">
+         Add task
+       </button>
+     </div>
+   </div>
+ </template>
+ 
+ <script>
+ export default {
+   name: 'NewTask',
+   data() {
+     return {
+       task: {
+         title: '',
+         description: '',
+       },
+     }
+   },
+   methods: {
+     add() {
+       this.$emit('add', this.task)
+       this.task = {
+         title: '',
+         description: '',
+       }
+     },
+   }
+ }
+ </script>
+ 
+ <style scoped></style>
```

タスクリストで表示するタスクアイテムを作成します。
```diff_javascript:frontend/components/TaskItem.vue
+ <template>
+   <div class="flex justify-between bg-white leading-none rounded-lg shadow overflow-hidden p-3 mb-4">
+     <div class="flex items-center">
+       <div class="rounded-full bg-white h-6 cursor-pointer flex items-center justify-center mr-2">
+         <input :value="task.status" :checked="task.status === 1" type="checkbox"
+                class="w-4 h-4 text-blue-600 bg-gray-100 rounded border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
+                @click="toggleStatus"
+         >
+       </div>
+       <div class="py-2">
+         <p class="font-semibold text-lg mx-2 mb-2 text-left cursor-pointer"
+            :class="{'line-through text-grey' : task.status === 1}">
+           {{ task.title }}
+         </p>
+         <span class="mx-2" :class="{'line-through text-grey' : task.status === 1}">
+           {{ task.description }}
+         </span>
+       </div>
+     </div>
+ 
+     <div class="flex items-center">
+       <loading-button
+         type="button"
+         icon="trash"
+         class="bg-red-light text-white font-bold py-2 px-4 rounded text-grey-darker text-sm"
+         @click.native="remove"
+       >
+         Remove
+       </loading-button>
+     </div>
+   </div>
+ </template>
+ 
+ <script>
+ export default {
+   name: 'TaskItem',
+   props: {
+     task: {
+       type: Object,
+       required: true
+     }
+   },
+   methods: {
+     toggleStatus(){
+       let status = 0
+       if (this.task.status === 0) {
+         status = 1
+       }
+       const task = {...this.task, status}
+       this.$emit('update', task)
+     },
+     remove() {
+       this.$emit('remove', this.task)
+     }
+   }
+ }
+ </script>
+ 
+ <style scoped></style>
```

タスクを編集できるページを作成します。
```diff_javascript:frontend/pages/index.vue
<template>
-   <Tutorial />
+   <div class="">
+     <nav class="bg-indigo">
+       <div class="container mx-auto px-4 py-4">
+         <div class="flex justify-between">
+           <div>
+             <span class="text-white no-underline font-bold text-3xl">
+               Todo App
+             </span>
+           </div>
+         </div>
+       </div>
+     </nav>
+ 
+     <div v-if="status === 'loading'"
+          class="flex items-top justify-center min-h-screen bg-gray-100 items-center"
+     >
+       <div class="flex text-xl my-6 justify-center text-grey-darker">
+         <div class="animate-spin h-10 w-10 border-4 border-blue rounded-full border-t-transparent mr-2"></div>
+         <span class="flex items-center">Loading</span>
+       </div>
+     </div>
+ 
+     <div v-else class="container mx-auto px-16 py-4">
+       <new-task @add="addTask" @cancel="status = ''"></new-task>
+       <h2 class="w-full text-3xl font-bold border-b mb-4">Task List</h2>
+       <task-item v-for="task in tasks"
+             :key="task.id"
+             :task="task"
+             class=""
+             @update="updateTask"
+             @remove="removeTask"
+       />
+     </div>
+   </div>
</template>
+ 
<script>
export default {
+   name: 'TodoHome',
+   data() {
+     return {
+       status: 'loading',
+       tasks: [],
+     }
+   },
+   async fetch() {
+     const {store} = this.$nuxt.context
+     this.tasks = await store.dispatch('fetchTasks')
+     this.status = ''
+   },
+   methods: {
+     async addTask(task) {
+       await this.$store.dispatch('addTask', task)
+       this.tasks = await this.$store.dispatch('fetchTasks')
+       this.status = ''
+     },
+     async updateTask(task) {
+       await this.$store.dispatch('updateTask', task)
+       this.tasks = await this.$store.dispatch('fetchTasks')
+       this.status = ''
+     },
+     async removeTask(task) {
+       await this.$store.dispatch('removeTask', task)
+       this.tasks = await this.$store.dispatch('fetchTasks')
+       this.status = ''
+     },
+   }
}
</script>
```

バックエンドAPIにアクセスする処理を作成します。
```diff_javascript:frontend/store/index.js
+ const defaultState = () => {
+   return {
+     errorMessages: null,
+   }
+ }
+ 
+ export const state = defaultState()
+ 
+ export const actions = {
+   async fetchTasks({commit}) {
+     try {
+       return await this.$axios.$get(
+         `/api/todos/`
+       )
+     } catch (err) {
+       const errorMessages = ['エラーが発生しました。エラーを確認して下さい。']
+       if (err.response && err.response.data.error) {
+         errorMessages.push(err.response.data.error)
+       }
+ 
+       commit('setErrorMessages', errorMessages)
+       console.log(err)
+     }
+   },
+   async addTask({commit}, task) {
+     try {
+       return await this.$axios.$post(
+         `/api/todos/`, task
+       )
+     } catch (err) {
+       const errorMessages = ['エラーが発生しました。エラーを確認して下さい。']
+       if (err.response && err.response.data.error) {
+         errorMessages.push(err.response.data.error)
+       }
+ 
+       commit('setErrorMessages', errorMessages)
+       console.log(err)
+     }
+   },
+   async updateTask({commit}, task) {
+     try {
+       return await this.$axios.$put(
+         `/api/todos/${task.id}/`, task
+       )
+     } catch (err) {
+       const errorMessages = ['エラーが発生しました。エラーを確認して下さい。']
+       if (err.response && err.response.data.error) {
+         errorMessages.push(err.response.data.error)
+       }
+ 
+       commit('setErrorMessages', errorMessages)
+       console.log(err)
+     }
+   },
+   async removeTask({commit}, task) {
+     try {
+       return await this.$axios.$delete(
+         `/api/todos/${task.id}/`,
+       )
+     } catch (err) {
+       const errorMessages = ['エラーが発生しました。エラーを確認して下さい。']
+       if (err.response && err.response.data.error) {
+         errorMessages.push(err.response.data.error)
+       }
+ 
+       commit('setErrorMessages', errorMessages)
+       console.log(err)
+     }
+   }
+ }
+ 
+ export const mutations = {
+   setErrorMessages(state, errorMessages) {
+     state.errorMessages = errorMessages
+   },
+   resetState(state) {
+     Object.assign(state, defaultState())
+   }
+ }
```

アプリで使用するカラーを定義します。
```diff_javascript:frontend/tailwind.config.js
+ const colors = {
+   'transparent': 'transparent',
+ 
+   'black': '#22292f',
+   'grey-darkest': '#3d4852',
+   'grey-darker': '#606f7b',
+   'grey-dark': '#8795a1',
+   'grey': '#b8c2cc',
+   'grey-light': '#dae1e7',
+   'grey-lighter': '#f1f5f8',
+   'grey-lightest': '#f8fafc',
+   'white': '#ffffff',
+ 
+   'red-darkest': '#3b0d0c',
+   'red-darker': '#621b18',
+   'red-dark': '#cc1f1a',
+   'red': '#e3342f',
+   'red-light': '#ef5753',
+   'red-lighter': '#f9acaa',
+   'red-lightest': '#fcebea',
+ 
+   'orange-darkest': '#462a16',
+   'orange-darker': '#613b1f',
+   'orange-dark': '#de751f',
+   'orange': '#f6993f',
+   'orange-light': '#faad63',
+   'orange-lighter': '#fcd9b6',
+   'orange-lightest': '#fff5eb',
+ 
+   'yellow-darkest': '#453411',
+   'yellow-darker': '#684f1d',
+   'yellow-dark': '#f2d024',
+   'yellow': '#ffed4a',
+   'yellow-light': '#fff382',
+   'yellow-lighter': '#fff9c2',
+   'yellow-lightest': '#fcfbeb',
+ 
+   'green-darkest': '#0f2f21',
+   'green-darker': '#1a4731',
+   'green-dark': '#1f9d55',
+   'green': '#38c172',
+   'green-light': '#51d88a',
+   'green-lighter': '#a2f5bf',
+   'green-lightest': '#e3fcec',
+ 
+   'teal-darkest': '#0d3331',
+   'teal-darker': '#20504f',
+   'teal-dark': '#38a89d',
+   'teal': '#4dc0b5',
+   'teal-light': '#64d5ca',
+   'teal-lighter': '#a0f0ed',
+   'teal-lightest': '#e8fffe',
+ 
+   'blue-darkest': '#12283a',
+   'blue-darker': '#1c3d5a',
+   'blue-dark': '#2779bd',
+   'blue': '#3490dc',
+   'blue-light': '#6cb2eb',
+   'blue-lighter': '#bcdefa',
+   'blue-lightest': '#eff8ff',
+ 
+   'indigo-darkest': '#191e38',
+   'indigo-darker': '#2f365f',
+   'indigo-dark': '#5661b3',
+   'indigo': '#6574cd',
+   'indigo-light': '#7886d7',
+   'indigo-lighter': '#b2b7ff',
+   'indigo-lightest': '#e6e8ff',
+ 
+   'purple-darkest': '#21183c',
+   'purple-darker': '#382b5f',
+   'purple-dark': '#794acf',
+   'purple': '#9561e2',
+   'purple-light': '#a779e9',
+   'purple-lighter': '#d6bbfc',
+   'purple-lightest': '#f3ebff',
+ 
+   'pink-darkest': '#451225',
+   'pink-darker': '#6f213f',
+   'pink-dark': '#eb5286',
+   'pink': '#f66d9b',
+   'pink-light': '#fa7ea8',
+   'pink-lighter': '#ffbbca',
+   'pink-lightest': '#ffebef',
+ }
+ 
+ module.exports = {
+   theme: {
+     colors,
+     textColors: colors,
+     backgroundColors: colors,
+   },
+ }
```

上記実装できたら、`python manage.py runserver`でバックエンドの開発サーバーも起動しましょう。

画面からタスクを追加したり、完了にしたり、削除したりできるようになりました。

![Todo App.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76958/464fcf29-a5c5-87dc-fc18-130bee0d546a.gif)

折角なので、Nuxtでもjestを使って自動テストを実装します。

TaskItemコンポーネントでtitleとdescriptionが表示できることを検証するテストを実装します。
```diff_javascript:frontend/test/TaskItem.spec.js
+ import { shallowMount } from '@vue/test-utils'
+ import TaskItem from '@/components/TaskItem.vue'
+ 
+ describe('TaskItem', () => {
+   test('should show task title and description', () => {
+     const props = {
+       task: {
+         id: 1,
+         title: 'Implement API',
+         description: 'Implement an API to retrieve Todo',
+         status: 0,
+         created_at: "2022-08-13T23:59:39.057161+09:00",
+       }
+     }
+ 
+     const wrapper = shallowMount(TaskItem, {
+       propsData: props
+     })
+ 
+     expect(wrapper.find('.title').text()).toBe('Implement API')
+     expect(wrapper.find('.description').text()).toBe('Implement an API to retrieve Todo')
+   })
+ })
```

`yarn test`を実行して下記のように自動テストがpassすることを確認して下さい。

```
% yarn test
yarn run v1.22.17
$ jest
 PASS  test/TaskItem.spec.js
  Task
    ✓ should show task title and description (22 ms)

--------------|---------|----------|---------|---------|-------------------
File          | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
--------------|---------|----------|---------|---------|-------------------
All files     |     100 |      100 |     100 |     100 |                   
 TaskItem.vue |     100 |      100 |     100 |     100 |                   
--------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.382 s
Ran all test suites.
✨  Done in 3.23s.

Process finished with exit code 0
```

簡単ですがテストも実装できました。上記の変更は[Github](https://github.com/satsuma0711/sample-ecs-todo-app)にプッシュしています。

以上でアプリが完成しましたので、次回はAWSでインフラ構築を行いたいと思います。