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

# はじめに

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

https://github.com/shinoshu/todo-app

# 目次

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

# プロトタイプ

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

<img width="1392" alt="スクリーンショット 2020-05-26 23.30.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244226/97f48eb8-958a-a4d7-a3d1-eb0449b27383.png">

# 実装

## create-nuxt-app

https://ja.nuxtjs.org/guide/installation/

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

```
$ 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` を修正。

<details>
<summary>.eslintrc.js</summary>
<div>

```JavaScript:.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'
  }
}
```
</div>
</details>

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

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

http://localhost:3000/

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

<img width="1552" alt="スクリーンショット 2020-05-27 00.25.07.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244226/28bc741a-47bf-b585-869e-874c9f63d30a.png">

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

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

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

```html: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>
```
</div>
</details>

<details>
<summary>nuxt.config.js</summary>
<div>

```JavaScript: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) {}
  }
}
```
</div>
</details>

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

<img width="1552" alt="スクリーンショット 2020-05-27 00.49.07.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244226/291e7a9d-da52-d14b-801c-dce91bda55b1.png">

## アイコンのインストール

https://vuetifyjs.com/ja/customization/icons/

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

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

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

<details>
<summary> vuetify.js</summary>
<div>

```JavaScript: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',
  },
})
```
</div>
</details>

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

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

## TODOカードの実装

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

```html: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 も修正。


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

```html: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>
```
</div>
</details>

こんな感じになります。

<img width="1552" alt="スクリーンショット 2020-05-27 17.46.17.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244226/0af294fc-b3a7-3764-80e4-ce4483971296.png">

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

index.vue を修正する。

<details>
<summary>index.vue</summary>
<div>
```html: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>
```
</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>
```
</div>
</details>

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

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

```html: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>
```
</div>
</details>

実装しました。

![画面収録 2020-05-28 11.50.44.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244226/67998466-a7ef-06c0-26b5-457ec28cc957.gif)

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

```typescript:todo.d.ts
interface Todo {
  name: string
  done: boolean
  important: boolean
  createdAt: Date
  updatedAt: Date
}
```

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

```html: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>
```
</div>
</details>

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

![eltuneko9V9A9721_TP_V.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244226/2bd32f7d-cd80-74a2-afb1-bc18bdc38baf.jpeg)

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

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

```html: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>
```
</div>
</details>

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

![画面収録 2020-05-28 14.18.39.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244226/6d93aa31-87f2-bab9-93eb-33e594192275.gif)

# Vuexの導入

https://vuex.vuejs.org/ja/

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

- クラスベース
- Vanilla

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

https://typescript.nuxtjs.org/ja/cookbook/store.html

Vanilla の方で実装していたんですが、 Nuxt TypeScript と Vanilla はどうやら相性が悪いということが分かりました。
相性が悪いというのは、 Nuxt.js の [モジュールモード](https://ja.nuxtjs.org/guide/vuex-store/#%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB) を使って Store を実装すると、 TypeScript の恩恵が受けられないみたいです。

ただ、 Store の [ファイルを分けた](https://ja.nuxtjs.org/guide/vuex-store/#%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB) ときのみに限ります。
ファイルを分けないとテストが書きづらいんですよね…

クラスベースで実装することも考えたんですが、アノテーションを使うことになるみたいで、コンポーネントは [クラススタイル](https://jp.vuejs.org/v2/guide/typescript.html#%E3%82%AF%E3%83%A9%E3%82%B9%E3%82%B9%E3%82%BF%E3%82%A4%E3%83%AB-Vue-%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88) で実装していないので、個人的に違和感があるので Vanilla で頑張ることにしました。

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

## 「タスクを追加する」機能を実装（Store）

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

<details>
<summary>todo.ts</summary>
<div>

```typescript: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)
  }
}
```
</div>
</details>

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

```html: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>
```
</div>
</details>

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

<img width="1552" alt="スクリーンショット 2020-05-28 19.47.22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244226/39031e66-49a1-7499-0065-5582d34088a7.png">

<img width="1552" alt="スクリーンショット 2020-05-28 19.46.36.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244226/de3a01dc-41ca-0c5c-7117-165f87e829b6.png">

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

```
$ yarn add uuid
```

<details>
<summary>todo.ts</summary>
<div>

```typescript: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
  }
}
```
</div>
</details>

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

```typescript: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>
```
</div>
</details>

# API対応

https://ja.nuxtjs.org/guide/async-data/

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

```
$ npm install -g json-server
```

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

```javascript:nuxt.config.js
  env: {
    baseUrl: 'http://localhost:8080'
  }
```

```javascript:axios.js
import axios from 'axios'

export default axios.create({
  baseURL: process.env.baseUrl
})
```

```json:db.json
{
  "todo": []
}
```

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

```html: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>
```
</div>
</details>

<details>
<summary>todo.ts</summary>
<div>

```typescript: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
  }
}
```
</div>
</details>

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

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

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

![画面収録 2020-05-29 11.35.04.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244226/055e0fa1-7e21-7b35-7e8a-747913a4e9c8.gif)

## 修正

`Axios Module` を使うように修正。

<details>
<summary>todo.ts</summary>
<div>

```typescript: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
  }
}
```
</div>
</details>

```javascript: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"` を追加する。

```json: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'` を追加。

```javascript: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のテスト

```typescript: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のテスト

```typescript: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のテスト

```typescript: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 に追加する

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

# リファクタリング

## コンポーネントを分割

<img width="1552" alt="68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3234343232362f33393033316536362d343961312d373439392d303036352d3535383264333430383861372e706e67.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/244226/5047e680-2b7f-7271-4224-d996431e4190.png">

- TodoTabs
- AddTask
- TodoItem

## Storybook

https://github.com/vuetifyjs/vue-cli-plugins/tree/master/packages/vue-cli-plugin-vuetify-storybook

（導入しんどい）

## ソースコード

[refactoring](https://github.com/shinoshu/todo-app/commit/da07caeeac90c201f1246e36e8024e27f42e7d65)
[refactoring](https://github.com/shinoshu/todo-app/commit/6a61eeafe9c479f50b7a9ad21c0df6da5aa51714)