スポンサーリンク

Nuxt.js(TypeScript)とDjango/RESTAPIでTodoアプリを作成する

Javascript
スポンサーリンク

本記事の対象者

  • Nuxt.jsで基礎的なアプリを作成したい方
  • Nuxt.jsでTypeScriptを使いたい方
  • 外部APIを使用したアプリを作成したい方

用意する環境

  • Nuxt.js
  • Django3.1

環境構築がまだ済んでいない方は下記を参考にしてください。

Docker×docker-composeを使用し簡単にVue/Nuxt.js開発環境を構築する
Docker,docker-composeを使用し、Vue/Nuxt.js環境を構築します。作成する環境はNode.js14.15.4になります。その上にNuxt.jsをインストールする内容になっています。Nuxtアプリケーションの設定方法も紹介しています。初心者の方でも簡単かつ最速で構築可能です。
Dockerとdocker-composeでDjango×MySQL×Nginx環境を構築する方法
Docker初心者でもわかるように解説しています。Django初学者向けの開発環境構築手順を記述しています。Dockerとdocker-composeでPython/Django×Nginx×MySQLの環境が簡単に作成できます。

また今回はDjangoで作成したAPIを使用するので下記を参考にそちらも作成しておきます。本記事ではNuxt側のフロント部分のみを紹介しています。

Python/Djangoで爆速でRESTAPI作成する方法(Swaggerも使用)
Django/PythonでRESTAPIを作成する方法を解説します。Swaggerも使用しています。ライブラリのインストール方法の記載もあります。

Nuxt側の開発前の準備

Nuxtプロジェクトで下記コマンドを実行します。どちらもVueでTypeScriptを使用するために必要なモジュールになります。

$ yarn add vue-class-component
$ yarn add vue-property-decorator

インストールが完了するとpackage.jsonに記述が追加されているはずですので確認してみてください。

次に.envファイルをプロジェクト直下に作成します。APIのURLを設定します。

# .env
API_URL = http://localhost:8000/api

nuxt.config.jsをnuxt.config.tsに修正します。

// nuxt.config.ts
import colors from 'vuetify/es5/util/colors'

const NuxtConfig = {
  // Disable server-side rendering: https://go.nuxtjs.dev/ssr-mode
  ssr: false,
  // Target: https://go.nuxtjs.dev/config-target
  target: 'static',
  // Global page headers: https://go.nuxtjs.dev/config-head
  head: {
    titleTemplate: '%s - docker_nuxt',
    title: 'docker_nuxt',
    meta: [
      { charset: 'utf-8' },
      {
        name: 'viewport',
        content: 'width=device-width, initial-scale=1',
      },
      { hid: 'description', name: 'description', content: '' },
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
  },
  // Global CSS: https://go.nuxtjs.dev/config-css
  css: [],
  // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
  plugins: [],
  // Auto import components: https://go.nuxtjs.dev/config-components
  components: true,
  // Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
  buildModules: [
    // https://go.nuxtjs.dev/typescript
    '@nuxt/typescript-build',
    // https://go.nuxtjs.dev/vuetify
    '@nuxtjs/vuetify',
  ],
  // Modules: https://go.nuxtjs.dev/config-modules
  modules: [
    // https://go.nuxtjs.dev/axios
    '@nuxtjs/axios',
    // https://go.nuxtjs.dev/pwa
    '@nuxtjs/pwa',
  ],
  // Axios module configuration: https://go.nuxtjs.dev/config-axios
  axios: {},
  // PWA module configuration: https://go.nuxtjs.dev/pwa
  pwa: {
    manifest: {
      lang: 'ja',
    },
  },
  // Vuetify module configuration: https://go.nuxtjs.dev/config-vuetify
  vuetify: {
    customVariables: ['~/assets/variables.scss'],
    theme: {
      dark: true,
      themes: {
        dark: {
          primary: colors.blue.darken2,
          accent: colors.grey.darken3,
          secondary: colors.amber.darken3,
          info: colors.teal.lighten1,
          warning: colors.amber.base,
          error: colors.deepOrange.accent4,
          success: colors.green.accent3,
        },
      },
    },
  },
  // Build Configuration: htts://go.nuxtjs.dev/config-build
  build: {},
  publicRuntimeConfig: {
    apiURL: process.env.API_URL,
  },
}

export default NuxtConfig

作成する画面と機能

  • TodoList画面
    • 機能
      • 未完了のタスク一覧を表示する機能
      • タスクを削除する機能
      • タスクを完了にする機能
      • タスク編集画面に遷移する機能
      • 新規タスクを作成する機能
  • Todo完了List画面
    • 機能
      • 完了のタスク一覧を表示する機能
      • タスクを削除する機能
      • タスクを未完了にする機能
  • Todo編集画面
    • 機能
      • 既存のタスクを編集する機能

こんな感じで画面を作成していきます。
図にするとこんな関係性になります。

コンポーネントの作成

まずはコンポーネントを作成していきます。
components配下にForm.vueというファイルを作成します。
こちらはファイル名からもわかりそうですが、タスクを作成するformになります。
TodoList画面の新規タスク作成とTodo編集画面の既存のタスクを編集する際に使用します。
下記がソースです。

// Form.vue
<template>
  <v-container>
    <v-row>
      <v-card class="d-inline-block mx-auto text-center">
        <v-form>
          <v-container>
            <v-row>
              <v-col cols="12" sm="12">
                <v-text-field
                  v-model="todo"
                  label="やること"
                  :error="errors.todo.flag"
                  :error_count="errors.todo.count"
                  :messages="errors.todo.message"
                  clearable
                ></v-text-field>
              </v-col>
              <v-col cols="12" sm="6">
                <v-select
                  v-model="selectedPriority"
                  label="優先度"
                  dense
                  :items="priority"
                  :error="errors.priority.flag"
                  :error_count="errors.priority.count"
                  :messages="errors.priority.message"
                  item-text="label"
                  item-value="value"
                ></v-select>
              </v-col>
              <v-col cols="12" sm="6" class="text-right">
                <v-btn class="mr-4" type="button" @click="submit()">
                  submit
                </v-btn>
              </v-col>
            </v-row>
          </v-container>
        </v-form>
      </v-card>
    </v-row>
  </v-container>
</template>

<script lang="ts">
import { Component, Vue, Prop, Watch, Emit } from 'vue-property-decorator'
import axios from 'axios'
interface Priority {
  label: string
  value: number
}

interface ErrorInfo {
  flag: boolean
  count: number
  message: string[]
}

interface ErrorInfoes {
  [key: string]: ErrorInfo
}

interface ErrorResponse {
  [key: string]: string[]
}

@Component
export default class FormVue extends Vue {
  @Prop({ type: Number, required: false }) initPriority!: number
  @Prop({ type: String, required: false }) initTodo!: string
  @Prop({ type: String, required: false }) paramId?: string
  selectedPriority?: number | null = null
  labelList: string[] = ['todo', 'priority']
  todo?: string = ''
  errors: ErrorInfoes = {
    todo: {
      flag: false,
      count: 0,
      message: [],
    },
    priority: {
      flag: false,
      count: 0,
      message: [],
    },
  }

  priority: Priority[] = [
    { label: '小', value: 0 },
    { label: '中', value: 1 },
    { label: '大', value: 2 },
  ]

  @Emit()
  public initTodoList() {} // 親のgetTodo()を実行する要素として用意

  submit(): void {
    this.initErrorInfo()
    if (this.paramId) {
      this.putSubmit(this.paramId)
    } else {
      this.postSubmit()
    }
  }

  postSubmit(): void {
    try {
      axios
        .post(process.env.API + '/tasks/', {
          todo: this.todo,
          priority: this.selectedPriority,
        })
        .then(() => {
          // リストとformの値を初期化
          this.initFormValue()
          this.initTodoList()
        })
        .catch((error) => {
          const errorList = error.response.data
          this.displayError(errorList)
        })
    } catch (e) {
      return e
    }
  }

  putSubmit(id: string): void {
    try {
      axios
        .put(process.env.API + '/tasks/' + id + '/', {
          todo: this.todo,
          priority: this.selectedPriority,
        })
        .then(() => {
          // リストとformの値を初期化
          this.initFormValue()
          this.initTodoList()
        })
        .catch((error) => {
          const errorList = error.response.data
          this.displayError(errorList)
        })
    } catch (e) {
      return e
    }
  }

  initErrorInfo(): void {
    for (const key of this.labelList) {
      this.errors[key].flag = false
      this.errors[key].count = 0
      this.errors[key].message = []
    }
  }

  initFormValue() {
    this.selectedPriority = null
    this.todo = ''
  }

  displayError(errorList: ErrorResponse): void {
    for (const key in errorList) {
      this.errors[key].count = errorList[key].length
      this.errors[key].message = errorList[key]
      this.errors[key].flag = true
    }
  }

  @Watch('initPriority')
  onChangeInitPriority(newVal: number): void {
    this.selectedPriority = newVal
  }

  @Watch('initTodo')
  onChangeInitTodo(newVal: string): void {
    this.todo = newVal
  }
}
</script>

次にcomponents配下にTaskList.vueというファイルを作成します。
こちらのコンポーネントは作成したタスク一覧を表示するに使用します。
TodoList画面とTodo完了List画面に使用します。

下記がソースです。
<template>
  <div>
    <template v-if="!completeFlag">
      <FormVue @init-todo-list="getTodoList()" />
    </template>
    <v-container fluid>
      <v-simple-table>
        <thead>
          <tr>
            <th class="text-left">ID</th>
            <th class="text-left">やること</th>
            <th class="text-left">優先度</th>
            <th class="text-center">ステータス更新</th>
            <!-- 未完了画面のみ -->
            <th v-if="!completeFlag" class="text-center">編集</th>
            <th class="text-center">削除</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="task in taskList" :key="task.id">
            <td>{{ task.id }}</td>
            <td>{{ task.todo }}</td>
            <td>{{ stringPriority(task.priority) }}</td>
            <td v-if="!completeFlag" class="text-center">
              <v-btn
                class="ma-2"
                color="primary"
                dark
                @click="completeClick(task.id)"
              >
                Complete
                <v-icon dark right> mdi-checkbox-marked-circle </v-icon>
              </v-btn>
            </td>
            <td v-else class="text-center">
              <v-btn
                class="ma-2"
                color="secondary"
                dark
                @click="completeClick(task.id)"
              >
                Incomplete
                <v-icon dark right> mdi-checkbox-marked-circle </v-icon>
              </v-btn>
            </td>
            <!-- 未完了画面のみ -->
            <td v-if="!completeFlag" class="text-center">
              <v-btn :to="'/task/' + task.id" class="ma-2" color="green" dark>
                Edit
                <v-icon dark right> mdi-file-document-edit </v-icon>
              </v-btn>
            </td>
            <td class="text-center">
              <v-btn
                class="ma-2"
                color="red"
                dark
                @click="deleteClick(task.id)"
              >
                Delete
                <v-icon dark right>mdi-cancel</v-icon>
              </v-btn>
            </td>
          </tr>
        </tbody>
      </v-simple-table>
    </v-container>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
import axios from 'axios'
import FormVue from '~/components/Form.vue'

interface Task {
  id: number
  todo: string
  priority: number
  completeFlag: boolean
}

interface Priority {
  [key: number]: string
}

axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = 'X-CSRFTOKEN'

@Component({
  components: {
    FormVue,
  },
})

export default class TaskList extends Vue {
  taskList: Task[] = []
  priority: Priority = { 0: '小', 1: '中', 2: '大' }
  @Prop({ type: Boolean, required: true })
  completeFlag!: boolean
  created() {
    this.getTodoList()
  }

  getTodoList() {
    try {
      axios
        .get(process.env.API + '/tasks/', {
          responseType: 'json',
          params: { complete_flag: this.completeFlag },
        })
        .then((response) => {
          this.taskList = response.data
        })
        .catch((error) => {
          return error
        })
    } catch (e) {
      return e
    }
  }

  completeClick(id: string) {
    try {
      axios
        .patch(process.env.API + '/tasks/' + id + '/', {
          complete_flag: !this.completeFlag,
        })
        .then(() => {
          // リストを初期化
          this.getTodoList()
        })
    } catch (e) {
      return e
    }
  }

  deleteClick(id: string) {
    try {
      axios.delete(process.env.API + '/tasks/' + id + '/').then(() => {
        // リストを初期化
        this.getTodoList()
      })
    } catch (e) {
      return e
    }
  }

  stringPriority(priority: number): string {
    return this.priority[priority]
  }
}
</script>

これでコンポーネントの作成は完了です。

TodoList画面の作成

まずはtaskディレクトリを作成します。その後taskディレクトリ配下にindex.vueというファイルを作成します。
このファイルがTodoList画面のテンプレートファイルになります。
このindex.vueで先ほど作成したForm.vueとTaskList.vueコンポーネントをインポートしていきます。具体的なソースは下記になります。

// index.vue
<template>
  <div>
    <h2>Task List</h2>
    <TaskList :complete-flag="myCompleteFlag" />
  </div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import TaskList from '~/components/TaskList.vue'

@Component({
  components: {
    TaskList,
  },
})

export default class IndexVue extends Vue {
  myCompleteFlag: boolean = false
}
</script>

では http://localhost:9000/task アクセスしてみましょう。

※ポート9000を指定していますがこれは本記事で紹介した環境構築した方を対象にしています。ご自身で環境構築された方は環境に合わせて指定してください。
うまくいっていれば下記のような画面が表示されるはずです。
Djangoを起動していればAPIでデータの作成が可能ですのでフォームに入力してみましょう。
うまくいくとデータがフォームの下の表示されます。
ちなみにcompleteボタンを押すとタスクのcomplete_flagが更新されてこの画面に表示されなくなります。これはデータが削除されたのでなくこの画面では未完了(complete_flag=false)のものを取得しているためです。詳しくはDjango側のAPIを確認してみてください。
deleteボタンを押すとデータが完全に削除されるためDjango側を確認してもデータが存在していないことがわかると思います。editボタンは現段階ではまだ画面作成していないので変化なしです。

Todo完了List画面の作成

ほぼTodoList画面と同じです。taskディレクトリ配下にcomplete.vueというファイルを作成します。
機能としては完了したタスクを表示するのみを想定しているのでTaskList.vueコンポーネントのみをインポートしていきます。
具体的なソースは下記になります。
// complete.vue
<template>
  <div>
    <h2>Task List Complete</h2>
    <TaskList :complete-flag="myCompleteFlag" />
  </div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import TaskList from '~/components/TaskList.vue'

@Component({
  components: {
    TaskList,
  },
})

export default class CompleteVue extends Vue {
  myCompleteFlag: boolean = true
}
</script>

では http://localhost:9000/task/complete アクセスしてみましょう。

うまくいっていれば下記のような画面が表示されます。
現段階では何も表示されていない画面ですがTaskList画面で作成したデータのcompleteボタンを押すとデータが表示されるようになります。incompleteボタンを押すとこの画面で表示されなくなりますがTaskList画面に再表示されます。completeボタンと逆の役割と考えてください。deleteボタンはTaskList画面と同じ機能でデータを完全に削除します。

Todo編集画面

taskディレクトリ配下に_id.vueを作成します。アンダースコアをつけると動的にURLとなります。
/task/1 や/task/10 のようなURLを想定しています。この1や10といったパラメータを使用してどのタスクをAPIで取得するかを判断する仕様です。
既存のデータを修正するフォームが必要なので、この画面ではForm.vueコンポーネントをインポートしていきます。
具体的なソースは下記になります。

// _id.vue
<template>
  <div>
    <h2>Task Edit - ID {{ $route.params.id }}</h2>
    <FormVue
      :init-priority="task.priority"
      :init-todo="task.todo"
      :param-id="$route.params.id"
      @init-todo-list="redirectTodo()"
    />
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import axios from 'axios'
import FormVue from '~/components/Form.vue'

axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = 'X-CSRFTOKEN'

interface Task {
  id: Number | null
  todo: String | ''
  priority: Number | null
  completeFlag: Boolean | null
}

@Component({
  components: {
    FormVue,
  },
  validate({ params }) {
    // 数値以外は404にする
    return /^\d+$/.test(params.id)
  },
})

export default class IdVue extends Vue {
  task?: Task = {
    id: null,
    todo: '',
    priority: null,
    completeFlag: null,
  }

  created() {
    this.getTodo()
  }

  getTodo() {
    try {
      axios
        .get(this.$config.apiURL + '/tasks/' + this.$route.params.id + '/', {
          responseType: 'json',
        })
        .then((response) => {
          this.task = response.data
        })
        .catch((error) => {
          return error
        })
    } catch (e) {
      return e
    }
  }

  redirectTodo() {
    this.$router.push('/task')
  }
}

</script>

TaskList画面で作成したデータのeditボタンをクリックしてみると下記のような画像が表示されます。

フォームの中にクリックしたデータが入った状態の画面が表示されています。この値を変更して送信するとTaskList画面に戻り先ほど入力した値に変更されたデータが表示されているはずです。
これで予定していたすべての機能が実装されました。お疲れさまでした。
今回ご紹介した内容は例外系の機能などの作りが甘かったり改善の余地はたくさんあると思いますのであくまで一例として参考にしていただければ幸いです。

コメント