2
5

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 3 years have passed since last update.

Vue.jsでTODOアプリを実装してみる

Last updated at Posted at 2020-05-27

はじめに

TODO アプリを作りながら Vue.js について学んでいこうと思います。

目次

  • プロトタイプ
  • 実装
  • Vuexの導入
  • Vuexのテスト
  • リファクタリング(執筆中)
  • コンポーネントのテスト(執筆中)

プロトタイプ

プロトタイプがないとイメージが湧かないので、Adobe XDで作成してみました。

スクリーンショット 2020-05-26 23.30.54.png

実装

create-nuxt-app

まず、以下のコマンドを叩きます。

$ npx create-nuxt-app todo

create-nuxt-app v2.15.0
✨  Generating Nuxt.js project in todo
? Project name todo
? Project description My impressive Nuxt.js project
? Author name shinoshu
? Choose programming language TypeScript
? Choose the package manager Yarn
? Choose UI framework Vuetify.js
? Choose custom server framework None (Recommended)
? Choose the runtime for TypeScript @nuxt/typescript-runtime
? Choose Nuxt.js modules Axios, DotEnv
? Choose linting tools ESLint, Prettier, Lint staged files, StyleLint
? Choose test framework Jest
? Choose rendering mode Single Page App
? Choose development tools jsconfig.json (Recommended for VS Code), Semantic Pull Requests
yarn run v1.22.4
$ eslint --ext .js,.vue --ignore-path .gitignore . --fix

/Users/shinozaki/workspace/todo/nuxt.config.js
  75:12  error  'config' is defined but never used. Allowed unused args must match /^_/u  @typescript-eslint/no-unused-vars
  75:20  error  'ctx' is defined but never used. Allowed unused args must match /^_/u     @typescript-eslint/no-unused-vars

✖ 2 problems (2 errors, 0 warnings)

error Command failed with exit code 1.

eslint に引っかかったので、 .eslintrc.js を修正。

.eslintrc.js
.eslintrc.js
module.exports = {
  root: true,
  env: {
    browser: true,
    node: true
  },
  extends: [
    '@nuxtjs/eslint-config-typescript',
    'prettier',
    'prettier/vue',
    'plugin:prettier/recommended',
    'plugin:nuxt/recommended'
  ],
  plugins: ['prettier'],
  // add your custom rules here
  rules: {
    '@typescript-eslint/no-unused-vars': 'warn'
  }
}

やーんでぶして、アプリを起動します。
ついでに initial commit も済ませておきます。

$ cd todo
$ git add .
$ git commit -m "initial commit"
$ yarn dev

ローカル環境のURLにアクセスすると、以下のような画面が表示されると思います。

スクリーンショット 2020-05-27 00.25.07.png

不要な要素を削除、ダークモードを無効にする

ヘッダーの不要な要素と、フッターを削除します。

default.vue
layouts/default.vue
<template>
  <!-- <v-app dark> -->
  <v-app>
    <v-navigation-drawer
      v-model="drawer"
      :mini-variant="miniVariant"
      :clipped="clipped"
      fixed
      app
    >
      <v-list>
        <v-list-item
          v-for="(item, i) in items"
          :key="i"
          :to="item.to"
          router
          exact
        >
          <v-list-item-action>
            <v-icon>{{ item.icon }}</v-icon>
          </v-list-item-action>
          <v-list-item-content>
            <v-list-item-title v-text="item.title" />
          </v-list-item-content>
        </v-list-item>
      </v-list>
    </v-navigation-drawer>
    <v-app-bar :clipped-left="clipped" fixed app>
      <!-- <v-app-bar-nav-icon @click.stop="drawer = !drawer" />
      <v-btn icon @click.stop="miniVariant = !miniVariant">
        <v-icon>mdi-{{ `chevron-${miniVariant ? 'right' : 'left'}` }}</v-icon>
      </v-btn>
      <v-btn icon @click.stop="clipped = !clipped">
        <v-icon>mdi-application</v-icon>
      </v-btn>
      <v-btn icon @click.stop="fixed = !fixed">
        <v-icon>mdi-minus</v-icon>
      </v-btn> -->
      <v-toolbar-title v-text="title" />
      <!-- <v-spacer />
      <v-btn icon @click.stop="rightDrawer = !rightDrawer">
        <v-icon>mdi-menu</v-icon>
      </v-btn> -->
    </v-app-bar>
    <v-content>
      <v-container>
        <nuxt />
      </v-container>
    </v-content>
    <v-navigation-drawer v-model="rightDrawer" :right="right" temporary fixed>
      <v-list>
        <v-list-item @click.native="right = !right">
          <v-list-item-action>
            <v-icon light>
              mdi-repeat
            </v-icon>
          </v-list-item-action>
          <v-list-item-title>Switch drawer (click me)</v-list-item-title>
        </v-list-item>
      </v-list>
    </v-navigation-drawer>
    <!-- <v-footer :fixed="fixed" app>
      <span>&copy; {{ new Date().getFullYear() }}</span>
    </v-footer> -->
  </v-app>
</template>

<script>
export default {
  data() {
    return {
      clipped: false,
      drawer: false,
      fixed: false,
      items: [
        {
          icon: 'mdi-apps',
          title: 'Welcome',
          to: '/'
        },
        {
          icon: 'mdi-chart-bubble',
          title: 'Inspire',
          to: '/inspire'
        }
      ],
      miniVariant: false,
      right: true,
      rightDrawer: false,
      // title: 'Vuetify.js'
      title: 'TODO'
    }
  }
}
</script>
nuxt.config.js
nuxt.config.js
import colors from 'vuetify/es5/util/colors'

export default {
  mode: 'spa',
  /*
   ** Headers of the page
   */
  head: {
    titleTemplate: '%s - ' + process.env.npm_package_name,
    title: process.env.npm_package_name || '',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      {
        hid: 'description',
        name: 'description',
        content: process.env.npm_package_description || ''
      }
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }]
  },
  /*
   ** Customize the progress-bar color
   */
  loading: { color: '#fff' },
  /*
   ** Global CSS
   */
  css: [],
  /*
   ** Plugins to load before mounting the App
   */
  plugins: [],
  /*
   ** Nuxt.js dev-modules
   */
  buildModules: [
    '@nuxt/typescript-build',
    // Doc: https://github.com/nuxt-community/stylelint-module
    '@nuxtjs/stylelint-module',
    '@nuxtjs/vuetify'
  ],
  /*
   ** Nuxt.js modules
   */
  modules: [],
  /*
   ** vuetify module configuration
   ** https://github.com/nuxt-community/vuetify-module
   */
  vuetify: {
    customVariables: ['~/assets/variables.scss'],
    theme: {
      // dark: true,
      dark: false,
      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
   */
  build: {
    /*
     ** You can extend webpack config here
     */
    extend(config, ctx) {}
  }
}

こんな感じになると思います。

スクリーンショット 2020-05-27 00.49.07.png

アイコンのインストール

ドキュメントに記載されている通り、以下のコマンドを叩きます。

$ yarn add @mdi/font -D # もしかしたら不要かも
$ yarn add material-design-icons-iconfont -D

また、以下のファイルを作成します。

vuetify.js
src/plugins/vuetify.js
// src/plugins/vuetify.js

import 'material-design-icons-iconfont/dist/material-design-icons.css' // Ensure you are using css-loader
import Vue from 'vue'
import Vuetify from 'vuetify/lib'

Vue.use(Vuetify)

export default new Vuetify({
  icons: {
    iconfont: 'md',
  },
})

また、以下のファイルの plugins に vuetify.js を追加します。

nuxt.config.js
  /*
   ** Plugins to load before mounting the App
   */
  plugins: ['~/plugins/vuetify.js'],

TODOカードの実装

まず、default.vue をちょいと修正します。

layouts/default.vue
    <v-app-bar color="primary" dark :clipped-left="clipped" fixed app>
      <v-toolbar-title color="white" v-text="title" />
    </v-app-bar>

index.vue も修正。

index.vue
pages/index.vue
<template>
  <v-layout column justify-center align-center>
    <v-tabs>
      <v-tab>すべて</v-tab>
      <v-tab>未完了</v-tab>
      <v-tab>完了</v-tab>
    </v-tabs>
    <div class="mt-8">
      <v-card class="todo d-flex px-4 mb-4">
        <v-icon color="green">check_box_outline_blank</v-icon>
        <v-card-title>食器を洗う</v-card-title>
        <span class="mr-auto"></span>
        <v-icon color="red">favorite_border</v-icon>
      </v-card>
      <v-card class="todo d-flex px-4 mb-4">
        <v-icon color="green">check_box</v-icon>
        <v-card-title>洗濯物をする</v-card-title>
        <span class="mr-auto"></span>
        <v-icon color="red">favorite</v-icon>
      </v-card>
    </div>
  </v-layout>
</template>

<script>
export default {
  components: {}
}
</script>

<style scoped>
.todo {
  width: 960px;
}
</style>

こんな感じになります。

スクリーンショット 2020-05-27 17.46.17.png

タスクを追加する入力欄の実装

index.vue を修正する。

index.vue
```html:pages/index.vue すべて 未完了 完了
check_box_outline_blank 食器を洗う favorite_border check_box 洗濯物をする favorite
</div>
</details>

プロトタイプでは、「タスクを追加する」は画面の一番下にあったと思うのですが、ちょっとめんどくさいので一番上に持ってきました。

<img width="1552" alt="スクリーンショット 2020-05-27 18.35.51.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244226/6c7d9a3f-52cf-90ed-b0f6-237c9c9fff5c.png">

## default.vue 修正

ブラウザの横幅を大きくしたときに、ナビゲーションが表示されてしまったので、表示されないようにします。

<details>
<summary>default.vue</summary>
<div>

```html:pages/index.vue
<template>
  <v-app>
    <v-app-bar color="primary" dark :clipped-left="clipped" fixed app>
      <v-toolbar-title color="white" v-text="title" />
    </v-app-bar>
    <v-content>
      <v-container>
        <nuxt />
      </v-container>
    </v-content>
  </v-app>
</template>

<script>
export default {
  data() {
    return {
      clipped: false,
      drawer: false,
      fixed: false,
      items: [
        {
          icon: 'mdi-apps',
          title: 'Welcome',
          to: '/'
        },
        {
          icon: 'mdi-chart-bubble',
          title: 'Inspire',
          to: '/inspire'
        }
      ],
      miniVariant: false,
      right: true,
      rightDrawer: false,
      // title: 'Vuetify.js'
      title: 'TODO'
    }
  }
}
</script>

「タスクを追加する」機能を実装

index.vue
pages/index.vue
<template>
  <v-layout column justify-center align-center>
    <v-tabs>
      <v-tab>すべて</v-tab>
      <v-tab>未完了</v-tab>
      <v-tab>完了</v-tab>
    </v-tabs>
    <div class="mt-8">
      <v-card class="todo d-flex px-4 mb-4">
        <v-icon @click="addTask">add</v-icon>
        <v-text-field
          v-model="task"
          class="px-4"
          label="タスクを追加する"
          autocomplete="off"
          @keydown.enter="onEnter"
        ></v-text-field>
        <span class="mr-auto"></span>
      </v-card>

      <template v-for="(todo, index) in todoList">
        <v-card :key="index" class="todo d-flex px-4 mb-4">
          <v-icon color="green">check_box_outline_blank</v-icon>
          <v-card-title>{{ todo }}</v-card-title>
          <span class="mr-auto"></span>
          <v-icon color="red">favorite_border</v-icon>
        </v-card>
      </template>

      <!-- <v-card class="todo d-flex px-4 mb-4">
        <v-icon color="green">check_box</v-icon>
        <v-card-title>洗濯物をする</v-card-title>
        <span class="mr-auto"></span>
        <v-icon color="red">favorite</v-icon>
      </v-card> -->
    </div>
  </v-layout>
</template>

<script>
export default {
  components: {},
  data() {
    return {
      task: '',
      todoList: []
    }
  },
  methods: {
    onEnter(event) {
      if (event.keyCode === 13) {
        this.addTask()
      }
    },
    addTask() {
      this.todoList = this.todoList.concat(this.task)
      this.task = ''
    }
  }
}
</script>

<style scoped>
.todo {
  width: 960px;
}
</style>

実装しました。

画面収録 2020-05-28 11.50.44.gif

「タスクを完了にする」「タスクを重要なタスクにする」機能を実装する

todo.d.ts
interface Todo {
  name: string
  done: boolean
  important: boolean
  createdAt: Date
  updatedAt: Date
}
index.vue
pages/index.vue
<template>
  <v-layout column justify-center align-center>
    <v-tabs>
      <v-tab>すべて</v-tab>
      <v-tab>重要</v-tab>
      <v-tab>未完了</v-tab>
      <v-tab>完了</v-tab>
    </v-tabs>
    <div class="mt-8">
      <v-card class="todo d-flex px-4 mb-4">
        <v-icon @click="addTask">add</v-icon>
        <v-text-field
          v-model="task"
          class="px-4"
          label="タスクを追加する"
          autocomplete="off"
          @keydown.enter="onEnter"
        ></v-text-field>
        <span class="mr-auto"></span>
      </v-card>

      <template v-for="(todo, index) in todoList">
        <v-card :key="index" class="todo d-flex px-4 mb-4">
          <v-icon
            v-if="!todo.done"
            color="green"
            @click="todo.done = !todo.done"
            >radio_button_unchecked</v-icon
          >
          <v-icon v-if="todo.done" color="green" @click="todo.done = !todo.done"
            >check_circle</v-icon
          >

          <v-card-title>{{ todo.name }}</v-card-title>
          <span class="mr-auto"></span>

          <v-icon
            v-if="!todo.important"
            color="red"
            @click="todo.important = !todo.important"
            >favorite_border</v-icon
          >
          <v-icon
            v-if="todo.important"
            color="red"
            @click="todo.important = !todo.important"
            >favorite</v-icon
          >
        </v-card>
      </template>
    </div>
  </v-layout>
</template>

<script>
export default {
  components: {},
  data() {
    return {
      task: '',
      todoList: []
    }
  },
  methods: {
    onEnter(event) {
      if (event.keyCode === 13) {
        this.addTask()
      }
    },
    addTask() {
      const todo = {
        name: this.task,
        done: false,
        important: false,
        createdAt: new Date(),
        updatedAt: new Date()
      }
      this.todoList = this.todoList.concat(todo)
      this.task = ''
    }
  }
}
</script>

<style scoped>
.todo {
  width: 960px;
}
</style>

キャプチャ忘れてしまったので、かわりに猫貼っておきます。

eltuneko9V9A9721_TP_V.jpg

「タスクのフィルタ」機能を実装する

index.vue
pages/index.vue
<template>
  <v-layout column justify-center align-center>
    <v-tabs @change="setSelectedTab">
      <v-tab>すべて</v-tab>
      <v-tab>重要</v-tab>
      <v-tab>未完了</v-tab>
      <v-tab>完了</v-tab>
    </v-tabs>
    <div class="mt-8">
      <v-card class="todo d-flex px-4 mb-4">
        <v-icon @click="addTask">add</v-icon>
        <v-text-field
          v-model="task"
          class="px-4"
          label="タスクを追加する"
          autocomplete="off"
          @keydown.enter="onEnter"
        ></v-text-field>
        <span class="mr-auto"></span>
      </v-card>

      <template v-for="(todo, index) in filterTodoList">
        <v-card :key="index" class="todo d-flex px-4 mb-4">
          <v-icon v-if="!todo.done" color="green" @click="toggleDone(todo)"
            >radio_button_unchecked</v-icon
          >
          <v-icon v-if="todo.done" color="green" @click="toggleDone(todo)"
            >check_circle</v-icon
          >

          <v-card-title>{{ todo.name }}</v-card-title>
          <span class="mr-auto"></span>

          <v-icon
            v-if="!todo.important"
            color="red"
            @click="toggleImportant(todo)"
            >favorite_border</v-icon
          >
          <v-icon
            v-if="todo.important"
            color="red"
            @click="toggleImportant(todo)"
            >favorite</v-icon
          >
        </v-card>
      </template>
    </div>
  </v-layout>
</template>

<script>
export default {
  components: {},
  data() {
    return {
      task: '',
      todoList: [],
      selectedTab: 0
    }
  },
  computed: {
    filterTodoList() {
      if (this.selectedTab === 1) {
        return this.todoList.filter((todo) => todo.important)
      }
      if (this.selectedTab === 2) {
        return this.todoList.filter((todo) => !todo.done)
      }
      if (this.selectedTab === 3) {
        return this.todoList.filter((todo) => todo.done)
      }
      return this.todoList
    }
  },
  methods: {
    onEnter(event) {
      if (event.keyCode === 13) {
        this.addTask()
      }
    },
    addTask() {
      const todo = {
        name: this.task,
        done: false,
        important: false,
        createdAt: new Date(),
        updatedAt: new Date()
      }
      this.todoList = this.todoList.concat(todo)
      this.task = ''
    },
    toggleDone(todo) {
      todo.done = !todo.done
    },
    toggleImportant(todo) {
      todo.important = !todo.important
    },
    setSelectedTab(event) {
      this.selectedTab = event
    }
  }
}
</script>

<style scoped>
.todo {
  width: 960px;
}
</style>

こんな感じになりました。

画面収録 2020-05-28 14.18.39.gif

Vuexの導入

Nuxt.js には Store を実装する方法として

  • クラスベース
  • Vanilla

の2つがあるみたいです。

Vanilla の方で実装していたんですが、 Nuxt TypeScript と Vanilla はどうやら相性が悪いということが分かりました。
相性が悪いというのは、 Nuxt.js の モジュールモード を使って Store を実装すると、 TypeScript の恩恵が受けられないみたいです。

ただ、 Store の ファイルを分けた ときのみに限ります。
ファイルを分けないとテストが書きづらいんですよね…

クラスベースで実装することも考えたんですが、アノテーションを使うことになるみたいで、コンポーネントは クラススタイル で実装していないので、個人的に違和感があるので Vanilla で頑張ることにしました。

なので、 Store のファイルは分けないで進めていきます。

「タスクを追加する」機能を実装(Store)

タスクを追加すると、 Store に登録されるようにしました。

todo.ts
todo.ts
import { GetterTree, ActionTree, MutationTree } from 'vuex'
import { Todo } from '@/types/todo'

export const state = () => ({
  todoList: [] as Todo[]
})

export type TodoState = ReturnType<typeof state>

export const getters: GetterTree<TodoState, TodoState> = {
  getTodoList: (state) => state.todoList
}

export const actions: ActionTree<TodoState, TodoState> = {
  addTodo({ commit }, todo: Todo) {
    commit('ADD_TODO', todo)
  }
}

export const mutations: MutationTree<TodoState> = {
  ADD_TODO: (state, todo: Todo) => {
    state.todoList.push(todo)
  }
}
index.vue
pages/index.vue
<template>
  <v-layout column justify-center align-center>
    <v-tabs @change="setSelectedTab">
      <v-tab>すべて</v-tab>
      <v-tab>重要</v-tab>
      <v-tab>未完了</v-tab>
      <v-tab>完了</v-tab>
    </v-tabs>
    <div class="mt-8">
      <v-card class="todo d-flex px-4 mb-4">
        <v-icon @click="addTask">add</v-icon>
        <v-text-field
          v-model="task"
          class="px-4"
          label="タスクを追加する"
          autocomplete="off"
          @keydown.enter="onEnter"
        ></v-text-field>
        <span class="mr-auto"></span>
      </v-card>

      <template v-for="(todo, index) in getTodoList">
        <v-card :key="index" class="todo d-flex px-4 mb-4">
          <v-icon v-if="!todo.done" color="green" @click="toggleDone(todo)"
            >radio_button_unchecked</v-icon
          >
          <v-icon v-if="todo.done" color="green" @click="toggleDone(todo)"
            >check_circle</v-icon
          >

          <v-card-title>{{ todo.name }}</v-card-title>
          <span class="mr-auto"></span>

          <v-icon
            v-if="!todo.important"
            color="red"
            @click="toggleImportant(todo)"
            >favorite_border</v-icon
          >
          <v-icon
            v-if="todo.important"
            color="red"
            @click="toggleImportant(todo)"
            >favorite</v-icon
          >
        </v-card>
      </template>
    </div>
  </v-layout>
</template>

<script>
import { mapGetters, mapActions } from 'vuex'

export default {
  components: {},
  data() {
    return {
      task: '',
      todoList: [],
      selectedTab: 0
    }
  },
  computed: {
    ...mapGetters('todo', ['getTodoList']),
    filterTodoList() {
      if (this.selectedTab === 1) {
        return this.todoList.filter((todo) => todo.important)
      }
      if (this.selectedTab === 2) {
        return this.todoList.filter((todo) => !todo.done)
      }
      if (this.selectedTab === 3) {
        return this.todoList.filter((todo) => todo.done)
      }
      return this.todoList
    }
  },
  methods: {
    ...mapActions('todo', ['addTodo']),
    onEnter(event) {
      if (event.keyCode === 13) {
        this.addTask()
      }
    },
    addTask() {
      const todo = {
        name: this.task,
        done: false,
        important: false,
        createdAt: new Date(),
        updatedAt: new Date()
      }

      this.addTodo(todo)

      this.task = ''
    },
    toggleDone(todo) {
      todo.done = !todo.done
    },
    toggleImportant(todo) {
      todo.important = !todo.important
    },
    setSelectedTab(event) {
      this.selectedTab = event
    }
  }
}
</script>

<style scoped>
.todo {
  width: 960px;
}
</style>

Vue.js devtools で確認すると、 Store に登録されていることが分かります。

スクリーンショット 2020-05-28 19.47.22.png スクリーンショット 2020-05-28 19.46.36.png

「タスクのフィルタ」機能を実装する(Store)

$ yarn add uuid
todo.ts
todo.ts
import { GetterTree, ActionTree, MutationTree } from 'vuex'
import { Todo } from '@/types/todo'

export const state = () => ({
  todoList: [] as Todo[],
  selectedTab: 0
})

export type TodoState = ReturnType<typeof state>

export const getters: GetterTree<TodoState, TodoState> = {
  getTodoList: (state) => {
    if (state.selectedTab === 1) {
      return state.todoList.filter((todo) => todo.important)
    }
    if (state.selectedTab === 2) {
      return state.todoList.filter((todo) => !todo.done)
    }
    if (state.selectedTab === 3) {
      return state.todoList.filter((todo) => todo.done)
    }
    return state.todoList
  },
  getSelectedTab: (state) => state.selectedTab
}

export const actions: ActionTree<TodoState, TodoState> = {
  addTodo({ commit }, todo: Todo) {
    commit('ADD_TODO', todo)
  },
  updateTodo({ commit }, todo: Todo) {
    commit('UPDATE_TODO', todo)
  },
  setSelectedTab({ commit }, selectedTab: number) {
    commit('SET_SELECTED_TAB', selectedTab)
  }
}

export const mutations: MutationTree<TodoState> = {
  ADD_TODO: (state, todo: Todo) => {
    state.todoList.push(todo)
  },
  UPDATE_TODO: (state, todo: Todo) => {
    const index = state.todoList.findIndex((value) => value.id === todo.id)
    if (index >= 0) {
      state.todoList.splice(index, 1, todo)
    }
  },
  SET_SELECTED_TAB: (state, selectedTab: number) => {
    state.selectedTab = selectedTab
  }
}
index.vue
index.vue
<template>
  <v-layout column justify-center align-center>
    <v-tabs @change="setSelectedTab">
      <v-tab>すべて</v-tab>
      <v-tab>重要</v-tab>
      <v-tab>未完了</v-tab>
      <v-tab>完了</v-tab>
    </v-tabs>
    <div class="mt-8">
      <v-card class="todo d-flex px-4 mb-4">
        <v-icon @click="addTask">add</v-icon>
        <v-text-field
          v-model="task"
          class="px-4"
          label="タスクを追加する"
          autocomplete="off"
          @keydown.enter="onEnter"
        ></v-text-field>
        <span class="mr-auto"></span>
      </v-card>

      <template v-for="todo in getTodoList">
        <v-card :key="todo.id" class="todo d-flex px-4 mb-4">
          <v-icon v-if="!todo.done" color="green" @click="toggleDone(todo)"
            >radio_button_unchecked</v-icon
          >
          <v-icon v-if="todo.done" color="green" @click="toggleDone(todo)"
            >check_circle</v-icon
          >

          <v-card-title>{{ todo.name }}</v-card-title>
          <span class="mr-auto"></span>

          <v-icon
            v-if="!todo.important"
            color="red"
            @click="toggleImportant(todo)"
            >favorite_border</v-icon
          >
          <v-icon
            v-if="todo.important"
            color="red"
            @click="toggleImportant(todo)"
            >favorite</v-icon
          >
        </v-card>
      </template>
    </div>
  </v-layout>
</template>

<script>
import { mapGetters, mapActions } from 'vuex'
import { v4 as uuidv4 } from 'uuid'

export default {
  components: {},
  data() {
    return {
      task: '',
      todoList: [],
      selectedTab: 0
    }
  },
  computed: {
    ...mapGetters('todo', ['getTodoList'])
  },
  methods: {
    ...mapActions('todo', ['addTodo', 'updateTodo', 'setSelectedTab']),
    onEnter(event) {
      if (event.keyCode === 13) {
        this.addTask()
      }
    },
    addTask() {
      const todo = {
        id: uuidv4(),
        name: this.task,
        done: false,
        important: false,
        createdAt: new Date(),
        updatedAt: new Date()
      }

      this.addTodo(todo)

      this.task = ''
    },
    toggleDone(todo) {
      this.updateTodo({ ...todo, done: !todo.done })
    },
    toggleImportant(todo) {
      this.updateTodo({ ...todo, important: !todo.important })
    }
  }
}
</script>

<style scoped>
.todo {
  width: 960px;
}
</style>

API対応

API サーバーは json-server を使う。

$ npm install -g json-server

以下のファイルを追加、修正。

nuxt.config.js
  env: {
    baseUrl: 'http://localhost:8080'
  }
axios.js
import axios from 'axios'

export default axios.create({
  baseURL: process.env.baseUrl
})
db.json
{
  "todo": []
}
index.vue
pages/index.vue
<template>
  <v-layout column justify-center align-center>
    <v-tabs @change="setSelectedTab">
      <v-tab>すべて</v-tab>
      <v-tab>重要</v-tab>
      <v-tab>未完了</v-tab>
      <v-tab>完了</v-tab>
    </v-tabs>
    <div class="mt-8">
      <v-card class="todo d-flex px-4 mb-4">
        <v-icon @click="addTask">add</v-icon>
        <v-text-field
          v-model="task"
          class="px-4"
          label="タスクを追加する"
          autocomplete="off"
          @keydown.enter="onEnter"
        ></v-text-field>
        <span class="mr-auto"></span>
      </v-card>

      <template v-for="todo in getTodoList">
        <v-card :key="todo.id" class="todo d-flex px-4 mb-4">
          <v-icon v-if="!todo.done" color="green" @click="toggleDone(todo)"
            >radio_button_unchecked</v-icon
          >
          <v-icon v-if="todo.done" color="green" @click="toggleDone(todo)"
            >check_circle</v-icon
          >

          <v-card-title>{{ todo.name }}</v-card-title>
          <span class="mr-auto"></span>

          <v-icon
            v-if="!todo.important"
            color="red"
            @click="toggleImportant(todo)"
            >favorite_border</v-icon
          >
          <v-icon
            v-if="todo.important"
            color="red"
            @click="toggleImportant(todo)"
            >favorite</v-icon
          >
        </v-card>
      </template>
    </div>
  </v-layout>
</template>

<script>
import { mapGetters, mapActions } from 'vuex'
import { v4 as uuidv4 } from 'uuid'

export default {
  data() {
    return {
      task: '',
      todoList: [],
      selectedTab: 0
    }
  },
  computed: {
    ...mapGetters('todo', ['getTodoList'])
  },
  created() {
    this.fetchTodo()
  },
  methods: {
    ...mapActions('todo', [
      'fetchTodo',
      'addTodo',
      'updateTodo',
      'setSelectedTab'
    ]),
    onEnter(event) {
      if (event.keyCode === 13) {
        this.addTask()
      }
    },
    addTask() {
      if (!this.task) {
        return
      }

      const todo = {
        id: uuidv4(),
        name: this.task,
        done: false,
        important: false,
        createdAt: new Date(),
        updatedAt: new Date()
      }
      this.addTodo(todo)

      this.task = ''
    },
    toggleDone(todo) {
      this.updateTodo({ ...todo, done: !todo.done })
    },
    toggleImportant(todo) {
      this.updateTodo({ ...todo, important: !todo.important })
    }
  }
}
</script>

<style scoped>
.todo {
  width: 960px;
}
</style>
todo.ts
todo.ts
import { GetterTree, ActionTree, MutationTree } from 'vuex'
import axios from '@/plugins/axios'
import { Todo } from '@/types/todo'

export const state = () => ({
  todoList: [] as Todo[],
  selectedTab: 0
})

export type TodoState = ReturnType<typeof state>

export const getters: GetterTree<TodoState, TodoState> = {
  getTodoList: (state) => {
    if (state.selectedTab === 1) {
      return state.todoList.filter((todo) => todo.important)
    }
    if (state.selectedTab === 2) {
      return state.todoList.filter((todo) => !todo.done)
    }
    if (state.selectedTab === 3) {
      return state.todoList.filter((todo) => todo.done)
    }
    return state.todoList
  },
  getSelectedTab: (state) => state.selectedTab
}

export const actions: ActionTree<TodoState, TodoState> = {
  async fetchTodo({ commit }) {
    const response = await axios.get('/todo')
    commit('FETCH_TODO', response.data)
  },
  async addTodo({ commit }, todo: Todo) {
    const response = await axios.post('/todo', todo)
    commit('ADD_TODO', response.data)
  },
  async updateTodo({ commit }, todo: Todo) {
    const response = await axios.put(`/todo/${todo.id}`, todo)
    commit('UPDATE_TODO', response.data)
  },
  setSelectedTab({ commit }, selectedTab: number) {
    commit('SET_SELECTED_TAB', selectedTab)
  }
}

export const mutations: MutationTree<TodoState> = {
  FETCH_TODO: (state, todoList: Todo[]) => {
    state.todoList.push(...todoList)
  },
  ADD_TODO: (state, todo: Todo) => {
    state.todoList.push(todo)
  },
  UPDATE_TODO: (state, todo: Todo) => {
    const index = state.todoList.findIndex((value) => value.id === todo.id)
    if (index >= 0) {
      state.todoList.splice(index, 1, todo)
    }
  },
  SET_SELECTED_TAB: (state, selectedTab: number) => {
    state.selectedTab = selectedTab
  }
}

以下のコマンドを叩いて、 API を起動します。

$ json-server -w -p 8080 db.json

APIと通信ができていることを確認しました。

画面収録 2020-05-29 11.35.04.gif

修正

Axios Module を使うように修正。

todo.ts
todo.ts
import { GetterTree, ActionTree, MutationTree } from 'vuex'
import { Todo } from '@/types/todo'

export const state = () => ({
  todoList: [] as Todo[],
  selectedTab: 0
})

export type TodoState = ReturnType<typeof state>

export const getters: GetterTree<TodoState, TodoState> = {
  getTodoList: (state) => {
    if (state.selectedTab === 1) {
      return state.todoList.filter((todo) => todo.important)
    }
    if (state.selectedTab === 2) {
      return state.todoList.filter((todo) => !todo.done)
    }
    if (state.selectedTab === 3) {
      return state.todoList.filter((todo) => todo.done)
    }
    return state.todoList
  },
  getSelectedTab: (state) => state.selectedTab
}

export const actions: ActionTree<TodoState, TodoState> = {
  async fetchTodo({ commit }) {
    const response = await this.$axios.$get('/todo')
    commit('FETCH_TODO', response)
  },
  async addTodo({ commit }, todo: Todo) {
    const response = await this.$axios.$post('/todo', todo)
    commit('ADD_TODO', response)
  },
  async updateTodo({ commit }, todo: Todo) {
    const response = await this.$axios.$put(`/todo/${todo.id}`, todo)
    commit('UPDATE_TODO', response)
  },
  setSelectedTab({ commit }, selectedTab: number) {
    commit('SET_SELECTED_TAB', selectedTab)
  }
}

export const mutations: MutationTree<TodoState> = {
  FETCH_TODO: (state, todoList: Todo[]) => {
    state.todoList.push(...todoList)
  },
  ADD_TODO: (state, todo: Todo) => {
    state.todoList.push(todo)
  },
  UPDATE_TODO: (state, todo: Todo) => {
    const index = state.todoList.findIndex((value) => value.id === todo.id)
    if (index >= 0) {
      state.todoList.splice(index, 1, todo)
    }
  },
  SET_SELECTED_TAB: (state, selectedTab: number) => {
    state.selectedTab = selectedTab
  }
}
nuxt.config.js
  /*
   ** Axios module configuration
   ** See https://axios.nuxtjs.org/options
   */
  axios: {
    baseURL: 'http://localhost:8080'
  },

注釈

actions が $axios と密結合になっているため、 TodoService とか TodoRepository などに切り出してあげるのがよさそう。

テスト

準備

https://typescript-jp.gitbook.io/deep-dive/intro-1/jest
https://vue-test-utils.vuejs.org/ja/guides/using-with-typescript.html

$ yarn add @types/jest ts-jest --dev # もしかしたら不要かも

"types""jest" を追加する。

tsconfig.json
{
  "compilerOptions": {
    "target": "es2018",
    "module": "esnext",
    "moduleResolution": "node",
    "lib": ["esnext", "esnext.asynciterable", "dom"],
    "esModuleInterop": true,
    "allowJs": true,
    "sourceMap": true,
    "strict": true,
    "noEmit": true,
    "experimentalDecorators": true,
    "baseUrl": ".",
    "paths": {
      "~/*": ["./*"],
      "@/*": ["./*"]
    },
    "types": ["@types/node", "@nuxt/types", "jest"]
  },
  "exclude": ["node_modules", ".nuxt", "dist"]
}

'<rootDir>/store/**/*.ts' を追加。

jest.config.js
module.exports = {
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/$1',
    '^~/(.*)$': '<rootDir>/$1',
    '^vue$': 'vue/dist/vue.common.js'
  },
  moduleFileExtensions: ['ts', 'js', 'vue', 'json'],
  transform: {
    '^.+\\.ts$': 'ts-jest',
    '^.+\\.js$': 'babel-jest',
    '.*\\.(vue)$': 'vue-jest'
  },
  collectCoverage: true,
  collectCoverageFrom: [
    '<rootDir>/components/**/*.vue',
    '<rootDir>/pages/**/*.vue',
    '<rootDir>/store/**/*.ts'
  ]
}

Gettersのテスト

todo.spec.ts
import { getters } from '@/store/todo'
import { Todo } from '@/types/todo'

const todoList: Todo[] = [
  {
    id: '1',
    name: 'name',
    done: false,
    important: false,
    createdAt: new Date(),
    updatedAt: new Date()
  },
  {
    id: '2',
    name: 'name',
    done: true,
    important: false,
    createdAt: new Date(),
    updatedAt: new Date()
  },
  {
    id: '3',
    name: 'name',
    done: false,
    important: true,
    createdAt: new Date(),
    updatedAt: new Date()
  },
  {
    id: '4',
    name: 'name',
    done: true,
    important: true,
    createdAt: new Date(),
    updatedAt: new Date()
  }
]

describe('getters', () => {
  test('getTodoList', () => {
    let result

    result = (getters as any).getTodoList({ todoList, selectedTab: 0 })
    expect(result).toEqual(todoList)

    result = (getters as any).getTodoList({ todoList, selectedTab: 1 })
    expect(result).toEqual(todoList.filter((todo) => todo.important))

    result = (getters as any).getTodoList({ todoList, selectedTab: 2 })
    expect(result).toEqual(todoList.filter((todo) => !todo.done))

    result = (getters as any).getTodoList({ todoList, selectedTab: 3 })
    expect(result).toEqual(todoList.filter((todo) => todo.done))
  })

  test('getSelectedTab', () => {
    const result = (getters as any).getSelectedTab({ todoList, selectedTab: 0 })
    expect(result).toEqual(0)
  })
})

Actionsのテスト

todo.spec.ts
import { Todo } from '@/types/todo'

import { getters, actions } from '@/store/todo'

const todo = {
  id: '1',
  name: 'name',
  done: false,
  important: false,
  createdAt: new Date(),
  updatedAt: new Date()
}

const todoList: Todo[] = [
  {
    id: '1',
    name: 'name',
    done: false,
    important: false,
    createdAt: new Date(),
    updatedAt: new Date()
  },
  {
    id: '2',
    name: 'name',
    done: true,
    important: false,
    createdAt: new Date(),
    updatedAt: new Date()
  },
  {
    id: '3',
    name: 'name',
    done: false,
    important: true,
    createdAt: new Date(),
    updatedAt: new Date()
  },
  {
    id: '4',
    name: 'name',
    done: true,
    important: true,
    createdAt: new Date(),
    updatedAt: new Date()
  }
]

// getters

describe('actions', () => {
  test('fetchTodo', async () => {
    const $axios = { $get: jest.fn(() => todoList) }
    const commit = jest.fn()

    const fetchTodo = (actions as any).fetchTodo.bind({ $axios })
    await fetchTodo({ commit })

    expect(commit).toHaveBeenCalledWith('FETCH_TODO', todoList)
  })

  test('addTodo', async () => {
    const $axios = { $post: jest.fn(() => todo) }
    const commit = jest.fn()

    const addTodo = (actions as any).addTodo.bind({ $axios })
    await addTodo({ commit })

    expect(commit).toHaveBeenCalledWith('ADD_TODO', todo)
  })

  test('updateTodo', async () => {
    const $axios = { $put: jest.fn(() => todo) }
    const commit = jest.fn()

    const addTodo = (actions as any).updateTodo.bind({ $axios })
    await addTodo({ commit }, todo)

    expect(commit).toHaveBeenCalledWith('UPDATE_TODO', todo)
  })

  test('setSelectedTab', async () => {
    const commit = jest.fn()

    const setSelectedTab = (actions as any).setSelectedTab
    await setSelectedTab({ commit }, 1)

    expect(commit).toHaveBeenCalledWith('SET_SELECTED_TAB', 1)
  })
})

Mutationのテスト

todo.spec.ts
import { Todo } from '@/types/todo'

import { state, getters, actions, mutations } from '@/store/todo'

const todo = {
  id: '1',
  name: 'name',
  done: true,
  important: true,
  createdAt: new Date(),
  updatedAt: new Date()
}

const todoList: Todo[] = [
  {
    id: '1',
    name: 'name',
    done: false,
    important: false,
    createdAt: new Date(),
    updatedAt: new Date()
  },
  {
    id: '2',
    name: 'name',
    done: true,
    important: false,
    createdAt: new Date(),
    updatedAt: new Date()
  },
  {
    id: '3',
    name: 'name',
    done: false,
    important: true,
    createdAt: new Date(),
    updatedAt: new Date()
  },
  {
    id: '4',
    name: 'name',
    done: true,
    important: true,
    createdAt: new Date(),
    updatedAt: new Date()
  }
]

const expected: Todo[] = [
  {
    id: '1',
    name: 'name',
    done: true,
    important: true,
    createdAt: new Date(),
    updatedAt: new Date()
  },
  {
    id: '2',
    name: 'name',
    done: true,
    important: false,
    createdAt: new Date(),
    updatedAt: new Date()
  },
  {
    id: '3',
    name: 'name',
    done: false,
    important: true,
    createdAt: new Date(),
    updatedAt: new Date()
  },
  {
    id: '4',
    name: 'name',
    done: true,
    important: true,
    createdAt: new Date(),
    updatedAt: new Date()
  }
]

// getters

// actions

describe('mutations', () => {
  test('FETCH_TODO', () => {
    const _state = state()

    const FETCH_TODO = (mutations as any).FETCH_TODO
    FETCH_TODO(_state, todoList)

    expect(_state).toEqual({ selectedTab: 0, todoList })
  })

  test('ADD_TODO', () => {
    const _state = state()

    const ADD_TODO = (mutations as any).ADD_TODO
    ADD_TODO(_state, todo)

    expect(_state).toEqual({ selectedTab: 0, todoList: [todo] })
  })

  test('UPDATE_TODO', () => {
    const _state = { ...state(), todoList: todoList.concat() }

    const UPDATE_TODO = (mutations as any).UPDATE_TODO
    UPDATE_TODO(_state, todo)

    expect(_state).toEqual({ selectedTab: 0, todoList: expected })
  })

  test('SET_SELECTED_TAB', () => {
    const _state = state()

    const SET_SELECTED_TAB = (mutations as any).SET_SELECTED_TAB
    SET_SELECTED_TAB(_state, 1)

    expect(_state).toEqual({ selectedTab: 1, todoList: [] })
  })
})

vue-shim.d.ts の追加

import でエラーが出るので以下のファイルを rootDir に追加する

vue-shim.d.ts
declare module '*.vue' {
  import Vue from 'vue'
  export default Vue
}

リファクタリング

コンポーネントを分割

68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3234343232362f33393033316536362d343961312d373439392d303036352d3535383264333430383861372e706e67.png
  • TodoTabs
  • AddTask
  • TodoItem

Storybook

(導入しんどい)

ソースコード

refactoring
refactoring

2
5
2

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
2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?