Vue.js v2でTODOアプリ(CRUDシステム)を作成する

Vue.js

Vue.jsでTODOアプリ(CRUDシステム)を作成する方法を紹介します。

Vue.jsでTODOアプリ(CRUDシステム)を作成する

プロジェクトの構成

プロジェクトの構成は次のようになっているとします。

Project
├─ data
│   └─ db.json
├─ src
   ├─ components
   │   ├─ FilterNav.vue
   │   ├─ Navbar.vue
   │   ├─ SingleProject.vue
   ├─ views
   │   ├─ AddProject.vue
   │   ├─ EditProject.vue
   │   ├─ Home.vue

db.json

db.jsonは次のようなデータとします。
JSON SERVERを使用します。

{
  "projects": [
    {
      "id": 1,
      "title": "タイトル1",
      "details": "タイトル1のlorem ipsum",
      "complete": false
    },
    {
      "id": 2,
      "title": "タイトル2",
      "details": "タイトル2のlorem ipsum",
      "complete": true
    }
  ]
}

まずはDELETEの場合です。

DELETE

SingleProject.vue

<template>
  <div class="project">
      <h3>{{ project.title }}</h3>
        <span @click="deleteProject" class="delete-icons">delete</span>
      </div>
  </div>
</template>

v-on:clickイベンドを作成して、deleteProject()メソッドを実行するようにします。
deleteProject()メソッドでは、アイコンをクリックするとそのh3タグのデータを消します。

<script>
export default {
  props: ['project'],
  data() {
    return {
      uri: 'http://localhost:3000/projects/' + this.project.id
    }
  },
  methods: {
    deleteProject() {
      fetch(this.uri, { method: 'DELETE' })
        .then(() => this.$emit('delete', this.project.id))
        .catch(err => console.log(err))
    }
  }
}
</script>

deleteProject()メソッドを作成します。
非同期で処理するので、fetchで第一引数にURIを指定します。第二引数にはmethodを指定します。今回はDELETEです。
Promiseが返されるので、then()メソッドでつなげます。
$emitでカスタムイベントをHome.vueに渡せるようにします。
$emitの第一引数で渡したいカスタムイベントの名前、第二引数では一緒に渡したい値を指定します。
今回は一意となるidとします。

then()メソッドが発動した時点でjsonデータからは削除されますが、
Home.vueからは削除されません。
Home.vueで一度fetchしたデータを基にDOMが作成されています。
そのデータはHome.vueのprojectsの配列にローカルに保存されている状態となります。
よって、そのローカルの情報も更新する必要があります。
Home.vueを次のようにします。

Home.vue

<template>
  <div class="home">
    <div v-if="projects.length">
      <div v-for="project in projects" :key="project.id">
        <SingleProject :project="project" @delete="handleDelete" />
      </div>
    </div>
  </div>
</template>

$emitで渡されたdeleteカスタムイベントをSingleProjectコンポーネントで使用します。
イベントなので@を付けて、deleteカスタムイベントが起きたときの処理を記述します。
今回は、handleDelete()メソッドとします。

methodsで、handleDelete()メソッドを作成します。
handleDelete()メソッドでは、$emitで渡されたidを引数として指定します。
そのidが入っていない配列を作成できればいいので、
filterを使って、プロジェクトのidとdeleteで指定されたidが一致しない値の配列を作成します。
そうすることで、プロジェクトのidとdeleteで指定されたidが一致しているもののみ排除された新しい配列ができ、表示上からも削除することができます。

Complete

CRUDと直接関係はありませんが、TODOリストなので必須のcompleteの実装をみていきます。
Jsonデータのcompleteの項目を取得・変更することでチェックマークやカードの色を変更します。

SingleProject.vue

<template>
<!-- :classで動的なクラスを作成、project.completeがtrueならcompleteクラスをつける -->
  <div class="project" :class="{complete: project.complete}">
    <div class="actions">
      <h3 @click="toggleShowDetails">{{ project.title }}</h3>
      <div class="icons">
        <span @click="toggleComplete" class="done-icons tick">done</span>
      </div>
    </div>
  </div>
</template>

:classで動的なクラスを作成します。project.completeがtrueならcompleteクラスをつける、falseならつけないという条件にすることで、色を変更することができます。
また、チェックマークも色を連動して変更したいので、tick(チェックマーク)というクラスをつけています。

そして、clickイベントを設定します。toggleComplete()メソッドを用意します。

<script>
export default {
  props: ['project'],
  data() {
    return {
      showDetails: false,
      uri: 'http://localhost:3000/projects/' + this.project.id
    }
  },
  methods: {
    toggleComplete() {
      fetch(this.uri, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ complete: !this.project.complete })
      })
        .then(() => {
          this.$emit('complete', this.project.id)
        })
        .catch(err => console.log(err))
    }
  }
}
</script>

PATCHリクエストは、一部の情報をアップデートしたいときに使うリクエストです。
PATCHなので一部変更したいものを選択します。今回はcompleteです。
リバースしたいので!つけます。

Headersは、content-typeやAuthorizationなど、リクエストヘッダーを指定します。
Bodyは、PostやPUTなどの場合に指定するリクエストボディーです。

JSON.stringifyでJSONデータの文字列に変換します。
JSON.stringifyは、次の記事を参考にしてみてください。


そして、deleteのときと同じく、これだけではjsonファイルのデータだけ変更されてローカルのHome.vueは変更されないので、
$emitでHome.vueにcompleteをidと一緒に渡します。

Home.vue

<template>
  <div class="home">
      <div v-for="project in projects" :key="project.id">
        <SingleProject
          :project="project"
          @complete="handleComplete"
        />
      </div>
  </div>
</template>

$emitで渡されたcompleteカスタムイベントを設定します。
handleComplete()メソッドが発火するようにします。

<script>
import SingleProject from '../components/SingleProject.vue'

export default {
  name: 'Home',
  components: { SingleProject },
  data() {
    return {
      projects: []
    }
  },
  mounted() {
    fetch('http://localhost:3000/projects')
      .then(res => res.json())
      .then(data => (this.projects = data))
      .catch(err => console.log(err.message))
  },
  methods: {
    handleComplete(id) {
      let p = this.projects.find(project => {
        return project.id === id //渡されるidとprojectのidがマッチしたら返す
      })
      p.complete = !p.complete
    }
  }
}
</script>

completeの場合は、find()メソッドを使って、該当のidのを探します。
それを変数pに格納して、そのcompleteをトグルすることで完了と未完了をスウィッチできるようにします。
あとは、CSSを設定すれば完了です。

Create

プロジェクトを新規作成できるようにします。
そのためには、新規作成のページが必要になるので、AddProject.vueを作成します。
その後、index.jsでimportとroutesの設定をします。

index.js

<script>
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import AddProject from '../views/AddProject.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/add',
    name: 'AddProject',
    component: AddProject
  },
]

...

</script>

AddProject.vue

<template>
  <!-- defaultでページ遷移が発生するのでpreventをつける -->
  <form @submit.prevent="handleSubmit">
    <label>Title:</label>
    <input type="text" v-model="title" required />
    <label>Details:</label>
    <textarea v-model="details" required></textarea>
    <button>Add Project</button>
  </form>
</template>

submitイベントでは、デフォルトでページ遷移が発生してしまうので、preventで無効にします。
そして、submitイベントはhandleSubmit()メソッドで処理するとします。
inputタグやtextareaタグは、動的に変化するように、v-modelを使います。

<script>
export default {
  data() {
    return {
      title: '',
      details: ''
    }
  },
  methods: {
    handleSubmit() {
      let project = {
        // idはJsonデータが作ってくれる
        title: this.title, //v-model="title"と紐づく
        details: this.details, //v-model="details"と紐づく
        complete: false
      }
      fetch('http://localhost:3000/projects', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' }, //jsonデータで送ると宣言
        body: JSON.stringify(project)
      }).then(() => {
        this.$router.push('/')
      }).catch((err) => console.log(err))
    }
  }
}
</script>

handleSubmit()メソッドでは、新規プロジェクトの内容を格納できるように、変数projectを用意します。
そして、そこにオブジェクトで必要事項を記述します。
idはjsonデータ作成時に自動的に割り振られるので今回の場合は不要です。

そして、fetchして、POSTリクエストを指定します。
JSON.stringify()でオブジェクトをJSON文字列に変換します。
その後、then()メソッドでつないで、$routerでHome.vueに遷移するようにします。

Navbar

Navbarは直接CRUDには関係ありませんが、TODOアプリではほぼ必須なので作り方を記述します。
Navbarは、viewに直接関わるものではないので、componentsフォルダに作成します。

App.vue

<template>
  <Navbar />
  <router-view />
</template>

template内でNavbarコンポーネントを記述します。

<script>

import Navbar from './components/Navbar.vue'

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

Navbarコンポーネントが使えるように、importして、componentsに登録します。

Navbar.vue

<template>
  <nav class="main-nav">
    <router-link :to="{ name: 'Home' }">プロジェクト一覧</router-link>
    <router-link :to="{ name: 'AddProject' }">新規作成</router-link>
  </nav>
</template>

linkはrouter-linkを使います。
:toを忘れずに設定します。
link先は、nameプロパティでの設定にすると、pathが変更されたときでも問題なく遷移できます。

あとは、CSSを設定します。

Update/Edit

まず、EditProject.vueをviewsフォルダに作成します。

index.js

<script>
import EditProject from '../views/EditProject.vue'

const routes = [
  {
    path: '/projects/:id',
    name: 'EditProject',
    component: EditProject,
    props: true //idをpropsとして使えるようになる
  },
]
</script>

EditProjectをimportして、routesを設定します。
pathはidで動的に取得したいので、:idをつけます。
また、propsをtrueにすることで、idをEditProject.vueで使えるようにします。

SingleProject.vue

<template>
        <router-link :to="{ name: 'EditProject', params: {id: project.id}}">
          <span class="edit-icons">edit</span>
        </router-link>
</template>

templateにeditを追加します。
router-linkで:toで飛び先を指定します。

EditProject.vue

<template>
  <!-- AddProject.vueのtemplateとほぼ同じ -->
  <form @submit.prevent="handleSubmit">
    <label>Title:</label>
    <input type="text" v-model="title" required />
    <label>Details:</label>
    <textarea v-model="details" required></textarea>
    <button>更新する</button>
  </form>
</template>

templateの中身はほとんどAddProject.vueと同じです。
submitイベントをデフォルトの反応をpreventして、handleSubmitを設定します。
titleもdetailsもv-modelで変換できるようにします。

<script>

export default {
  props: ['id'], //index.jsでpropsを設定したため使える
  data() {
    return {
      //元々のデータが編集画面でみえるようにしたい
      title: '',
      details: '',
      uri: 'http://localhost:3000/projects/' + this.id
    }
  },
  mounted() {
    fetch(this.uri)
      .then(res => res.json())
      .then(data => {
        // dataはクリックしたidのプロジェクトデータ
        this.title = data.title //titleにクリックしたdataのtitleを入れる
        this.details = data.details //上に同じ、編集画面で表示できるように。
      })
  },
  methods: {
    handleSubmit() {
      fetch(this.uri, {
        method: 'PATCH', //変更部分だけほしいのでPATCH
        headers: { 'Content-Type': 'application/json' },
        // 変更部分titleとdetailsをオブジェクトからJSONへ変換
        body: JSON.stringify({ title: this.title, details: this.details })
      })
        .then(() => {
          // Home.vueにリダイレクト
          this.$router.push('/')
        })
        .catch(err => console.log(err))
    }
  }
}
</script>

props: ['id’]は、URLを決めるときにも必要なので、index.jsでpropsの設定しているかを確認しましょう。
dataは、uriにidを付けたものとしたいです。
mounted()では、クリックしたidのプロジェクトデータがdataとして渡ってくる設定にしています。
titleにクリックしたdataのtitleを入れて、編集画面で元の入力内容を確認できるようにします。

送信には、PATCHを使います。なぜなら、ほしいのはtitleとdetailsだけだからです。
headersを設定したら、bodyで、オブジェクトをJSONデータに変換します。
そして、then()メソッドでHome.vueに設定します。

Filter Nav

完了のみ、未完了のみ、全てを表示できるようにfilterを設定する方法です。

FilterNav.vue

<template>
  <nav class="filter-nav">
    <!-- currentをHome.vueから受け取る -->
    <!-- activeクラスをそれぞれつくように設定する -->
    <button @click="updateFilter('all')" :class="{active: current === 'all'}">すべて表示</button>
    <button @click="updateFilter('completed')"  :class="{active: current === 'completed'}">完了</button>
    <button @click="updateFilter('ongoing')" :class="{active: current === 'ongoing'}">未完了</button>
  </nav>
</template>

navタグにボタンを3つ用意します。
そして、クリックイベントでfilterを設定します。
それぞれ引数を渡せるようにします。
そして、動的なクラスでactiveがつくようにします。

<script>
export default {
  props: ['current'],
  methods: {
    updateFilter(by) {
      this.$emit('filterChange', by)
    }
  }
}
</script>

propsでHome.vueからcurrentを受け取ります。
そして、$emitでfilterChangeと一緒に渡したい値を指定します。

Home.vue

<template>
  <div class="home">
    <!-- $eventにはFilterNav.vueのbyが渡される -->
    <!-- currentをFilterNav.vueで使えるようにする -->
    <FilterNav @filterChange="current = $event" :current="current" />
    <div v-if="projects.length">
      <div v-for="project in filteredProjects" :key="project.id">
        <SingleProject
          :project="project"
          @delete="handleDelete"
          @complete="handleComplete"
        />
      </div>
    </div>
  </div>
</template>

FilterNavをimportして、componentsに登録して、templateタグ内で使用します。

FilterNav.vueから渡されたfilterChangeカスタムイベントが発火したら、currentに$event、つまり渡されたbyを代入します。

<script>
import FilterNav from '../components/FilterNav.vue'

export default {
  name: 'Home',
  components: { SingleProject, FilterNav },
  data() {
    return {
      projects: [],
      current: 'all' //ここでcurrentをトラッキングする
    }
  },
  computed: {
    filteredProjects() {
      if (this.current === 'completed') {
        return this.projects.filter(project => project.complete)
      }
      if (this.current === 'ongoing') {
        return this.projects.filter(project => !project.complete)
      }
      return this.projects //すべて表示
    }
  },
}
</script>

computedを使って、filterした配列を取得します。
dataのcurrentをデフォルトでallにしておいて、currentを変化させることで、完了と未完了のみを表示できるようにします。
computedでは、それぞれのケースの配列を取得します。

templeteのv-forのループ部分をcomputedで作成したfilteredProjectsに変更すればOKです。

Vue.js

Posted by devsakaso