The 3rd The Twelve-Factor App on AWS & Django(Let’s create a frontend SPA)

DevOps

Introduction

In the previous article (Part 2), we implemented an API to retrieve and create todo stored in the DB.

This time, we want to create a frontend SAP (single page application) that shows the todo retrieved from the API.

I want to implement the frontend using Nuxt and Tailwind CSS. The main frameworks and library versions to be used are as follows.

Language/FW/LibraryVersion
macOS12.3.1
Nuxt2.15.8
vue2.7.8
tailwindcss2.2.19
axios0.21.4

Create frontend SPA

Let’s implement the frontend application.

// サンプルプロジェクトに移動
% 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

Nuxt setup is complete when you go to http://localhost:3000/ and see the below page.

Now that we have the Nuxt project, we would like to implement the Todo app. The Todo app we will create is based on guillaumebriday/todolist-frontend-vuejs.

First, delete the unnecessary files for implementing the Todo app.

% rm components/NuxtLogo.vue

% rm components/Tutorial.vue

% rm frontend/test/NuxtLogo.spec.js

Once you have deleted the unnecessary files, create and edit the following files.

Change the Nuxt settings to proxy with axios.

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

Create a loading button.

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>

Create a form to create a todo.

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>

Create a todo item component for the todo list.

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>

Create a page where you can edit todos.

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>

Create an action to access the backend 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())
+   }
+ }

Define the colors you use in the app.

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

Once you have implemented the above, use python manage.py runserver to start the back-end development server.

You can now add, complete, and delete tasks from the page.

Since this is an excellent opportunity, we will implement automated tests using jest in Nuxt.

We will implement a test to verify that the TaskItem component can show the title and 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')
+   })
+ })

Run yarn test and confirm that the automated test passes as follows.

% 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

You have implemented a test. I have pushed the above changes to Github.

Now that the app is complete, we want to build the infrastructure with AWS in the following article.

コメント

タイトルとURLをコピーしました