はじめに
TODO アプリを作りながら Vue.js について学んでいこうと思います。
目次
- プロトタイプ
- 実装
- Vuexの導入
- Vuexのテスト
- リファクタリング(執筆中)
- コンポーネントのテスト(執筆中)
プロトタイプ
プロトタイプがないとイメージが湧かないので、Adobe XDで作成してみました。
実装
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
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にアクセスすると、以下のような画面が表示されると思います。
不要な要素を削除、ダークモードを無効にする
ヘッダーの不要な要素と、フッターを削除します。
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>© {{ 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
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) {}
}
}
こんな感じになると思います。
アイコンのインストール
ドキュメントに記載されている通り、以下のコマンドを叩きます。
$ yarn add @mdi/font -D # もしかしたら不要かも
$ yarn add material-design-icons-iconfont -D
また、以下のファイルを作成します。
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 を追加します。
/*
** Plugins to load before mounting the App
*/
plugins: ['~/plugins/vuetify.js'],
TODOカードの実装
まず、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
<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>
こんな感じになります。
タスクを追加する入力欄の実装
index.vue を修正する。
index.vue
</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
<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>
実装しました。
「タスクを完了にする」「タスクを重要なタスクにする」機能を実装する
interface Todo {
name: string
done: boolean
important: boolean
createdAt: Date
updatedAt: Date
}
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>
キャプチャ忘れてしまったので、かわりに猫貼っておきます。
「タスクのフィルタ」機能を実装する
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>
こんな感じになりました。
Vuexの導入
Nuxt.js には Store を実装する方法として
- クラスベース
- Vanilla
の2つがあるみたいです。
Vanilla の方で実装していたんですが、 Nuxt TypeScript と Vanilla はどうやら相性が悪いということが分かりました。
相性が悪いというのは、 Nuxt.js の モジュールモード を使って Store を実装すると、 TypeScript の恩恵が受けられないみたいです。
ただ、 Store の ファイルを分けた ときのみに限ります。
ファイルを分けないとテストが書きづらいんですよね…
クラスベースで実装することも考えたんですが、アノテーションを使うことになるみたいで、コンポーネントは クラススタイル で実装していないので、個人的に違和感があるので Vanilla で頑張ることにしました。
なので、 Store のファイルは分けないで進めていきます。
「タスクを追加する」機能を実装(Store)
タスクを追加すると、 Store に登録されるようにしました。
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
<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 に登録されていることが分かります。
「タスクのフィルタ」機能を実装する(Store)
$ yarn add uuid
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
<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
以下のファイルを追加、修正。
env: {
baseUrl: 'http://localhost:8080'
}
import axios from 'axios'
export default axios.create({
baseURL: process.env.baseUrl
})
{
"todo": []
}
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
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と通信ができていることを確認しました。
修正
Axios Module
を使うように修正。
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
}
}
/*
** 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"
を追加する。
{
"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'
を追加。
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のテスト
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のテスト
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のテスト
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 に追加する
declare module '*.vue' {
import Vue from 'vue'
export default Vue
}
リファクタリング
コンポーネントを分割
- TodoTabs
- AddTask
- TodoItem
Storybook
(導入しんどい)