目次
- 第1回 The Twelve-Factor App on AWS & Django(The Twelve-Factor Appとは)
- 第2回 The Twelve-Factor App on AWS & Django(バックエンドAPIを作ろう)
- 第3回 The Twelve-Factor App on AWS & Django(フロントエンドSPAを作ろう) ← 今回
はじめに
前回(第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のセットアップは完了になります。
Nuxtプロジェクトのセットアップが完了したので、実際にTodoアプリを実装したいと思います。今回作成するTodoアプリはguillaumebriday/todolist-frontend-vuejsを参考にしました。
まずTodoアプリを実装するのに不要なファイルを削除します。
% rm components/NuxtLogo.vue
% rm components/Tutorial.vue
% rm frontend/test/NuxtLogo.spec.js
不要なファイルを削除できたら、以下のファイルを作成・編集します。
プロキシするようNuxtの設定を変更します。
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',
+ },
}
ローディングボタンを作成します。
+ <template>
+ <button :type="type" :disabled="disabled">
+ <slot />
+ </button>
+ </template>
+
+ <script>
+ export default {
+ props: {
+ disabled: {
+ type: Boolean,
+ default: false
+ },
+ type: {
+ type: String,
+ default: 'submit'
+ },
+ }
+ }
+ </script>
+
タスクを作成するフォームを作成します。
+ <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>
タスクリストで表示するタスクアイテムを作成します。
+ <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>
タスクを編集できるページを作成します。
<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にアクセスする処理を作成します。
+ 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())
+ }
+ }
アプリで使用するカラーを定義します。
+ 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
でバックエンドの開発サーバーも起動しましょう。
画面からタスクを追加したり、完了にしたり、削除したりできるようになりました。
折角なので、Nuxtでもjestを使って自動テストを実装します。
TaskItemコンポーネントでtitleとdescriptionが表示できることを検証するテストを実装します。
+ 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でインフラ構築を行いたいと思います。