0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-08-13

目次

はじめに

前回(第2回)では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

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

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

% rm components/NuxtLogo.vue

% rm components/Tutorial.vue

% rm frontend/test/NuxtLogo.spec.js

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

プロキシするようNuxtの設定を変更します。

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',
+    },
}

ローディングボタンを作成します。

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>
+ 

タスクを作成するフォームを作成します。

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>

タスクリストで表示するタスクアイテムを作成します。

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>

タスクを編集できるページを作成します。

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にアクセスする処理を作成します。

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())
+   }
+ }

アプリで使用するカラーを定義します。

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

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

TaskItemコンポーネントでtitleとdescriptionが表示できることを検証するテストを実装します。

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にプッシュしています。

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?