Help us understand the problem. What is going on with this article?

Vue.jsとRailsでTODOアプリのチュートリアルみたいなものを作ってみた

More than 1 year has passed since last update.

はじめに

業務で使っているわけではないのですが、個人的にコツコツVue.jsの勉強をしています。

今回は今まで勉強したことを整理する意味合いも兼ねて、チュートリアルのようなものを作成したいと思います。
見ていただいた方の参考になれば嬉しいです。
また、JavaScriptに関しては書き慣れていないので、もしもっと良い書き方などありましたらご意見いただけると幸いです。

なお、テストコードはまだ書けておりません。。。
次回の記事で各コンポーネントのユニットテストを書きたいと思っています。
2017/09/20 追記
試しに書いたテストコードの差分を、記事の最後に追記しました。

作るもの

簡単なTODOアプリです。
(どっかのサービスに似ていると言わないでください。。。)

sample_todo_spa_2.gif

TODOの管理はRailsのAPIで実施します。

前提条件及び環境

Ruby、Rails、Node.jsの環境をご用意ください。
下記の記事などが分かりやすいです。

また、Webpack、およびVue.jsはyarnでインストールされるため、yarnもインストールしておいてください。
yarnを使ってみた

本記事を書くにあたり、下記の環境で実施しました。

  • Ruby 2.4.1
  • Ruby on Rails 5.1.4
  • Node.js 8.5.0
  • yarn 1.0.2
  • Webpacker 3.0.2
  • Vue.js 2.4.3

Webpackについて

複数のJSファイルをひとまとめにするモジュールバンドラーのことです。
複数ファイルに分割して管理しつつ、サーバーへのリクエスト数を削減することができるようです。
最新版で学ぶwebpack 3入門 – JavaScript開発で人気のバンドルツール

準備

まずはもろもろインストールをしていきます。
なお、こちらの記事が動画つきで非常に分かりやすいので、もし初めて実施される方はご覧になった方が良いかと思います。
【動画付き】Rails 5.1で作るVue.jsアプリケーション ~Herokuデプロイからシステムテストまで~

Rails + Vue.jsのプロジェクトを作成する

はじめから--webpackオプションを使用してプロジェクトを作成します。

$ rails new todo_sample --webpack=vue

rails sでRailsのウェルカムページが表示されれば大丈夫です。

Vue.jsの表示確認

基本的に、Railsで用意するビューファイルは1つのみで、そこを差し替えていきます。
まずは、以下のファイルを作成、編集します。

  • app/controllers/home_controller.rb
  • config/routes.rb
  • app/views/home/index.html.erb
app/controllers/home_controller.rb
class HomeController < ApplicationController
  def index
  end
end
config/routes.rb
Rails.application.routes.draw do
  root to: 'home#index'
end
app/views/home/index.html.erb
<%= javascript_pack_tag 'hello_vue' %>

javascript_pack_tagを使用することで、app/javascript/packs以下にあるJSファイルを探してくれます。
インストール時にhello_vue.jsというファイルが生成されているので、これをindexにて読み込ませます。
これでrails sして、「Hello Vue!」と表示されれば大丈夫です。

devサーバーを設定する

Vue.js(というよりWebpack)関連のファイルは変更したらコンパイルする必要があります。
コンパイルには、bin/webpackというコマンドを使用する必要があります。

ただ、毎回コンパイルするのは面倒なので、変更を検出して自動コンパイルするようにします。
こちらの記事でも紹介されております。
Introducing Webpacker

まずはforemanのGemをインストールします。
foremanProcfileから複数のプロセスを管理することができます。
https://github.com/ddollar/foreman

Gemfile
+ gem 'foreman'

これでbundle installします。
次に下記2点のファイルを作成します。

ファイル名 役割
bin/server Procfile.devのコマンドを実行する
Procfile.dev rails sbin/webpack-dev-serverを実行する
bin/server
#!/bin/bash -i
bundle install
bundle exec foreman start -f Procfile.dev
Procfile.dev
web: bundle exec rails s
# watcher: ./bin/webpack-watcher
webpacker: ./bin/webpack-dev-server

また、bin/serverのパーミッションを変更しておきます。

$ chmod 777 bin/server

これでbin/serverを実行してみると、http://localhost:5000で起動します。
(ポート番号が変わります。)
ここで、app.vueの下記の部分を変えて保存すると、コンパイル処理が走り、画面がリロードされるはずです。
適当に変えて遊んでみて、問題なさそうなら大丈夫です。

app/javascript/packs/app.vue
<script>
export default {
  data: function () {
    return {
      // この文字列が画面に表示されている
      message: "Hello Vue!"
    }
  }
}
</script>

APIの準備

サーバーサイドのAPI部分を実装していきます。

テーブルとモデルの生成

テーブル名はtasksとし、下記のようにします。

カラム
name VARCHAR(255) NULL: false
is_done BOOLEAN NULL: false, DEFAULT: false
created_at DATETIME NULL: false
updated_at DATETIME NULL: false

マイグレーションファイルとモデルはrails generateで作成してしまいます。

$ rails generate model Task name:string is_done:boolean

マイグレーションファイルにnull: falsedefault: falseを追記するため、下記のように編集します。

db/migrate/xxxxxxxxxxxxxx_create_tasks.rb
class CreateTasks < ActiveRecord::Migration[5.1]
  def change
    create_table :tasks do |t|
      t.string :name, null: false
      t.boolean :is_done, default: false, null: false

      t.timestamps
    end
  end
end

マイグレーションを実行します。

$ rails db:migrate

ついでに、モデルのnameプロパティにバリデーションをつけておきましょう。

app/models/task.rb
class Task < ApplicationRecord
+ validates :name, presence: true
end

コントローラーと返却するJSONファイルを生成

アクセスするURLは/api/tasksのように名前空間を切りたいと思います。
まずはルーティングからです。

config/routes.rb
Rails.application.routes.draw do
  root to: 'home#index'

+ namespace :api, format: 'json' do
+   resources :tasks, only: [:index, :create, :update]
+ end
end

次にコントローラーを作成します。
コントローラーはapp/controllersの中にapiというディレクトリを作成し、そこに作ります。

app/controllers/api/tasks_controller.rb
class Api::TasksController < ApplicationController

  # GET /tasks
  def index
    # 後々のため、更新順で返します
    @tasks = Task.order('updated_at DESC')
  end

  # POST /tasks
  def create
    @task = Task.new(task_params)

    if @task.save
      render :show, status: :created
    else
      render json: @task.errors, status: :unprocessable_entity
    end
  end

  # PATCH/PUT /tasks/1
  def update
    @task = Task.find(params[:id])
    if @task.update(task_params)
      render :show, status: :ok
    else
      render json: @task.errors, status: :unprocessable_entity
    end
  end

  private
    # Never trust parameters from the scary internet, only allow the white list through.
    def task_params
      params.fetch(:task, {}).permit(
          :name, :is_done
      )
    end
end

ビューのJSONファイルも同様に、views/api/tasks以下に作成します。

app/views/api/tasks/index.json.jbuilder
json.set! :tasks do
  json.array! @tasks do |task|
    json.extract! task, :id, :name, :is_done, :created_at, :updated_at
  end
end
app/views/api/tasks/show.json.jbuilder
json.set! :task do
  json.extract! @task, :id, :name, :is_done, :created_at, :updated_at
end

seeds.rbを作成し、curlコマンドで確認

seeds.rbを作成し初期データを作成できるようにします。
もしレコードが増えすぎてもこれでやり直しもできます。

db/seeds.rb
3.times { Task.create!(name: 'Sample Task') }
2.times { Task.create!(name: 'Sample Task', is_done: true) }

DBに適用します。

$ rails db:seed

なお、リセットする場合は、

$ rails db:setup

curlコマンドを使用してAPIの確認をします。

$ curl localhost:5000/api/tasks
{"tasks":[{"id":1,"name":"Sample Task","is_done":false,"created_at":"2017-09-14T08:12:35.454Z","updated_at":"2017-09-14T08:12:35.454Z"},{"id":2,"name":"Sample Task","is_done":false,"created_at":"2017-09-14T08:12:35.460Z","updated_at":"2017-09-14T08:12:35.460Z"},{"id":3,"name":"Sample Task","is_done":false,"created_at":"2017-09-14T08:12:35.462Z","updated_at":"2017-09-14T08:12:35.462Z"},{"id":4,"name":"Sample Task","is_done":true,"created_at":"2017-09-14T08:12:35.468Z","updated_at":"2017-09-14T08:12:35.468Z"},{"id":5,"name":"Sample Task","is_done":true,"created_at":"2017-09-14T08:12:35.475Z","updated_at":"2017-09-14T08:12:35.475Z"}]}

次にPOSTで新規作成してみます。
ここで恐らくエラーになるかと思います。

$ curl -X POST localhost:5000/api/tasks -d 'task[name]=fugafuga'

エラー内容はCan't verify CSRF token authenticity.Completed 422 Unprocessable Entity ~~です。
CSRF対策のトークンがないため、Railsから怒られてしまいます。

application_controller.rbの下記をコメントアウトするとエラーが出なくなります。
本来であれば、API認証のようなものをつけた方が良いとは思いますが、今回は割愛します。
【Rails】RailsでAPIの簡単なトークン認証を実装する

app/controllers/application_controller.rb
-   protect_from_forgery with: :exception
+   # protect_from_forgery with: :exception

これで作成されたTODOが返却されるかと思います。

$ curl -X POST localhost:5000/api/tasks -d 'task[name]=fugafuga'
{"task":{"id":6,"name":"fugafuga","is_done":false,"created_at":"2017-09-14T08:31:17.100Z","updated_at":"2017-09-14T08:31:17.100Z"}}

Materializeの導入

CSSはMaterializeというフレームワークを使用しようと思います。
マテリアルデザインを意識したものになっており、個人的に使ってみたいと思っておりました。

Gemもすでにあるので、Rails側でインストールします。
https://github.com/mkhairi/materialize-sass

ドキュメントにもありますが、Turbolinksを使用する場合は、jquery-turbolinksを入れた方が良いそうです。
今回は画面遷移としてはないので、Turbolinksが影響することはなさそうですが、とりあえず入れておきます。

2018/11/9 追記
jquery-turbolinks は必要なさそうなので削除しました。

Gemfile
+ gem 'jquery-rails'
+ gem 'materialize-sass'
+ gem 'material_icons'

bundle installした後、下記の2ファイルに追記します。
cssscssに変更しておきます。

2018/11/9 追記
@ryo117n さんよりご指摘があり、 colorimport 文を修正しました。

app/assets/stylesheets/application.scss
+ /* Materialize */
+ @import "materialize/components/color-variables";
+ $primary-color: color("teal", "accent-3") !default;
+ $secondary-color: color("cyan", "base") !default;
+ @import 'materialize';
+ @import 'material_icons';

$primary-color$secondary-colorを設定しておくことで、よしなに色を合わせてくれます。
カラースキームはこちらを参照してください。

app/assets/javascripts/application.js
+ //= require jquery
+ //= require materialize
  //= require rails-ujs
  //= require turbolinks
  //= require_tree .

コンポーネントを使ってヘッダーを作成

ここからVue.jsを中心に画面を作っていきます。
まずはヘッダーを作成します。

元となるビューファイルはRailsのindex.html.erbになるので、こちらにVue.jsを載せられるようにします。

app/views/home/index.html.erb
<div id="app">
  <navbar></navbar>
</div>

<%= javascript_pack_tag 'todo' %>

<navbar>というタグがありますが、Vue.js側でこのタグとコンポーネントを紐付け、表示します。
コンポーネント
また、新しくtodo.jsというファイルをapp/javascript/packsに作成します。
hello_vueを修正したも良いのですが。。。)

app/javascript/packs/todo.js
import Vue from 'vue/dist/vue.esm.js'

var app = new Vue({
  el: '#app',
});

ここで、import Vue from 'vue/dist/vue.esm.js'としています。
これは、後ほどコンポーネントを使用する際に完全ビルドする必要があるからだそうです。
(すいません、まだよく分かっていません。。。)
詳しくはこちらの方が解説記事を書いてくださっています。
Rails5.1でVue.jsで単一ファイルコンポーネントのエラーがでる

これでindex.html.erb内の<div id="app">にマウントされます。
このまま実行しても特に何もありません。

それではコンポーネントを作成します。
packsの下にcomponentsディレクトリを作成して、そこにheader.vueを作成します。
コンポーネントは.vueで作成します。

app/javascript/packs/components/header.vue
<template>
  <div>
    <ul id="dropdown" class="dropdown-content">
      <li><a href="#">Top</a></li>
      <li><a href="#">About</a></li>
      <li><a href="#">Contact</a></li>
    </ul>
    <nav>
      <div class="nav-wrapper container">
        <a href="/" class="brand-logo left">Todo Application</a>
        <ul class="right hide-on-med-and-down">
          <li><a href="#">Top</a></li>
          <li><a href="#">About</a></li>
          <li><a href="#">Contact</a></li>
        </ul>
        <ul class="right hide-on-large-only">
          <li>
            <a class="dropdown-button" href="#!" data-activates="dropdown">
              Menu<i class="material-icons right">arrow_drop_down</i>
            </a>
          </li>
        </ul>
      </div>
    </nav>
  </div>
</template>

これをtodo.jsに登録します。

app/javascript/packs/todo.js
  import Vue from 'vue/dist/vue.esm.js'
+ import Header from './components/header.vue'

 var app = new Vue({
   el: '#app',
+  components: {
+    'navbar': Header,
+  }
 });

navbarという名前でコンポーネントとして登録します。
headerだと<header>タグがすでにHTML5に存在しているため。)
これで<navbar>タグが使用できるようになりました。

サーバーを再起動してアクセスすると下図のようなヘッダーができているのではないでしょうか?

スクリーンショット 2017-09-14 19.33.37.png

Vue-Routerを使用してSPAっぽく

Vue-Routerを使用することで、登録されたパスとコンポーネントで画面内を差し替えることができます。
Vue-Router

yarnを使ってvue-routerを追加します。

$ yarn add vue-router

今回は「TODO一覧(メイン画面)」、「アバウト(おまけ)」、「コンタクト(おまけ)」を用意します。
正直、メイン画面でTODO管理はできるので、あと2つはおまけです。

まずはコンポーネントを作成します。

app/javascript/packs/components/index.vue
<template>
  <div>
    <p>Index</p>
  </div>
</template>
app/javascript/packs/components/about.vue
<template>
  <div>
    <!-- 内容はお好みで -->
    <p>This is a sample of TODO application with Vue.js and Ruby on Rails.</p>
    <p>Sample code is <a href="https://github.com/naoki85/todo_app_with_vue_and_rails" target="_blank">here.</a></p>
  </div>
</template>
app/javascript/packs/components/contact.vue
<template>
  <div>
    <!-- 内容はお好みで -->
    <p>If you want to contact me, you send mail to below address.</p>
    <p>test@example.com</p>
  </div>
</template>

さて、このコンポーネントとパスを登録するrouter.jsを作成します。
こちらもrouterディレクトリを作成してそちらに作成します。

app/javascript/packs/router/router.js
import Vue from 'vue/dist/vue.esm.js'
import VueRouter from 'vue-router'
import Index from '../components/index.vue'
import About from '../components/about.vue'
import Contact from '../components/contact.vue'

Vue.use(VueRouter)

export default new VueRouter({
  mode: 'history',
  routes: [
    { path: '/', component: Index },
    { path: '/about', component: About },
    { path: '/contact', component: Contact },
  ],
})

パスとコンポーネントを結びつけます。
また、mode: 'history'とすることで、HTMLのhistory APIを使用して、一見同じビュー内ですがURLを書き換えることができます。
HTML5 Historyモード

また、VueRouterを使用すると、<router-link><router-view>というタグが使用できます。
<router-link>は、<a>タグとして変換されますが、画面遷移ではなくVueRouterに登録されたパスからコンポーネントを探します。
そして<router-view>の部分に表示します。

ヘッダーの各リンクを修正します。

app/javascript/packs/components/header.vue
- <li><a href="/">Top</a></li>
- <li><a href="/about">About</a></li>
- <li><a href="/contact">Contact</a></li>
+ <li><router-link to="/">Top</router-link></li>
+ <li><router-link to="/about">About</router-link></li>
+ <li><router-link to="/contact">Contact</router-link></li>

それぞれのコンポーネントが表示される部分をindex.html.erbに作ります。

app/views/home/index.html.erb
 <div id="app">
   <navbar></navbar>
+  <div class="container">
+    <router-view></router-view>
+  </div>
 </div>

 <%= javascript_pack_tag 'todo' %>

最後に、todo.jsに追加します。

app/javascript/packs/todo.js
  import Vue from 'vue/dist/vue.esm.js'
+ import Router from './router/router'
  import Header from './components/header.vue'

  var app = new Vue({
+   router: Router,
    el: '#app',
    components: {
      'navbar': Header,
    }
  });

これで、ヘッダーの各リンクを押すと本文が切り替わるのではないでしょうか?
URLもHistoryモードのおかげで書き換わっています。

ただ、例えばhttp://localhost:5000/aboutでリロードすると、Rails側でエラーになってしまいます。
たしかにroutes.rbで登録していません。
とりあえず、/aboutでも/contactでもHome#indexにとぶよう記述します。

config/routes.rb
 Rails.application.routes.draw do
   root to: 'home#index'
+  get '/about',   to: 'home#index'
+  get '/contact', to: 'home#index'

これでhttp://localhost:5000/aboutにアクセスするとわかりますが、ちゃんとURLからAboutのコンポーネントを表示してくれます。

Axiosを使ってAPI通信

axiosは、Ajax通信ライブラリです。
まずはこれをインストールします。

$ yarn add axios

Axiosを使用してindex.vueにてAPI通信してタスク管理したいと思います。

まずは完成イメージをindex.vuetemplate内に記載します。
これを書き換えていきます。

2019/01/12 追記
Materialize の最新バージョンを使用する場合、本記事時点の文法とは異なる場合があります。
@tktcorporation さんより、チェックボックスの修正方法のコメントをいただきました。
もしスタイルやJSがうまく当たっていないと感じた場合は、 Materialize のドキュメントを参考にしていただきますようお願いします。

app/javascript/packs/components/index.vue
<template>
  <div>
    <!-- 新規作成部分 -->
    <div class="row">
      <div class="col s10 m11">
        <input class="form-control" placeholder="Add your task!!">
      </div>
      <div class="col s2 m1">
        <div class="btn-floating waves-effect waves-light red">
          <i class="material-icons">add</i>
        </div>
      </div>
    </div>
    <!-- リスト表示部分 -->
    <div>
      <ul class="collection">
        <li id="row_task_1" class="collection-item">
          <input type="checkbox" id="task_1" />
          <label for="task_1">Sample Task</label>
        </li>
        <li id="row_task_2" class="collection-item">
          <input type="checkbox" id="task_2" />
          <label for="task_2">Sample Task</label>
        </li>
        <li id="row_task_3" class="collection-item">
          <input type="checkbox" id="task_3" />
          <label for="task_3">Sample Task</label>
        </li>
      </ul>
    </div>
    <!-- 完了済みタスク表示ボタン -->
    <div class="btn">Display finished tasks</div>
    <!-- 完了済みタスク一覧 -->
    <div id="finished-tasks" class="display_none">
      <ul class="collection">
        <li id="row_task_4" class="collection-item">
          <input type="checkbox" id="'task_4" checked="checked" />
          <label v-bind:for="task_4" class="line-through">Done Task</label>
        </li>
        <li id="row_task_5" class="collection-item">
          <input type="checkbox" id="'task_5" checked="checked" />
          <label v-bind:for="task_5" class="line-through">Done Task</label>
        </li>
      </ul>
    </div>
  </div>
</template>

こんな感じになります。

スクリーンショット 2017-09-14 21.11.55.png

一覧表示

コンポーネントの中でHTML、JS、CSSをまとめて記載することを単一コンポーネントというようです。
.vueファイルの中で、そのコンポーネントで使うJSも記述することができます。
一覧をAPIで取得するために必要なものを追記します。

app/javascript/packs/components/index.vue
    <!-- 省略 -->
  </template>

+ <script>
+   import axios from 'axios';
+ 
+   export default {
+     data: function () {
+       return {
+         tasks: [],
+         newTask: ''
+       }
+     },
+     mounted: function () {
+       this.fetchTasks();
+     },
+     methods: {
+       fetchTasks: function () {
+         axios.get('/api/tasks').then((response) => {
+           for(var i = 0; i < response.data.tasks.length; i++) {
+             this.tasks.push(response.data.tasks[i]);
+           }
+         }, (error) => {
+           console.log(error);
+         });
+       },
+     }
+   }
+ </script>

インスタンスにプロパティとしてtasksnewTaskを与えます。
メソッドとしてfetchTasksを登録し、APIで取得してきた値をループさせてtasksに格納します。
AxiosはJQueryと同じ感じで使えるので、使用しやすいかと思います。

mountedはVueインスタンスがマウントされたタイミングで実行されるライフサイクルフックです。
createdもあって今回の場合、あまり違いはありませんが、ライフサイクルダイアグラムについては、ちゃんと理解する必要がありそうです。

template内の下記の部分を書き換えます。
(未完了と完了済みを両方変えます。)
v-forv-ifを使ってtasksプロパティの中で条件に合うものを表示しています。
条件付きレンダリング
また、v-bindで強引にid名を作っています。

app/javascript/packs/components/index.vue
     <!-- リスト表示部分 -->
     <div>
       <ul class="collection">
-        <li id="row_task_1" class="collection-item">
-          <input type="checkbox" id="task_1" />
-          <label for="task_1">Sample Task</label>
-        </li>
-        <li id="row_task_2" class="collection-item">
-          <input type="checkbox" id="task_2" />
-          <label for="task_2">Sample Task</label>
-        </li>
-        <li id="row_task_3" class="collection-item">
-          <input type="checkbox" id="task_3" />
-          <label for="task_3">Sample Task</label>
-        </li>
+        <li v-for="task in tasks" v-if="!task.is_done" v-bind:id="'row_task_' + task.id" class="collection-item">
+          <input type="checkbox" v-bind:id="'task_' + task.id" />
+          <label v-bind:for="'task_' + task.id">{{ task.name }}</label>
+        </li>
       </ul>
     </div>

      <!-- 完了済みタスク一覧 -->
      <div id="finished-tasks" class="display_none">
        <ul class="collection">
-         <li id="row_task_4" class="collection-item">
-           <input type="checkbox" id="'task_4" checked="checked" />
-           <label v-bind:for="task_4" class="line-through">Done Task</label>
-         </li>
-         <li id="row_task_5" class="collection-item">
-           <input type="checkbox" id="'task_5" checked="checked" />
-           <label v-bind:for="task_5" class="line-through">Done Task</label>
-         </li>
+         <li v-for="task in tasks" v-if="task.is_done"v-bind:id="'row_task_' + task.id" class="collection-item">
+           <input type="checkbox" v-bind:id="'task_' + task.id" checked="checked" />
+           <label v-bind:for="'task_' + task.id"  class="line-through">{{ task.name }}</label>
+         </li>
        </ul>
      </div>

完了済みタスクを常に表示させておく必要はないと思います。
display_noneというクラスをつけているので、ここに非表示のスタイルをあてたいと思います。
単一コンポーネントではCSSも管理できます。

ただ、デフォルトの設定だと、コンパイル時に別でスタイルシートを出力してしまうので、これを無しにします。
RailsとVue.js の設計覚書

スタイルシートをまとめて出力

config/webpack/environment.js
+ environment.loaders.get('vue').options.extractCSS = false

2018/03/24 追記

@trandaison さんよりご指摘がありました。
webpacker 3+から、loaders/vue.jsの中でextractCSS = falseと修正すれば良いようです。
(devServerを見直せば良さそうですが。。)

config/webpack/loaders/vue.js
//const extractCSS = !(inDevServer && (devServer && devServer.hmr)) || isProduction
const extractCSS = false

ただ、私が作成した時にこのファイルはなかったので、バージョンアップの際に追加されたのかもしれません。
(そこまでは追っていませんでした。。。)
loaders/vue.jsがないバージョンをお使いの方は @kawadumax さんからもご指摘がありましたが、下記のように extractCSS を指定すれば良いようです。

config/webpack/environment.js
+ environment.loaders.get('vue').use[0].options.extractCSS = false

スタイルの追加

index.vueにスタイルを追記します。
<style>タグ中にscopedという属性をつけておくと、そのファイルのみで有効なスタイルとして認識してくれます。
そのコンポーネントでしか使わないようなクラスはここで定義してしまえば良さそうです。

また、APIの返り値を使用してレンダリングする場合、API処理が終わるまでは、{{ task.name }}がそのまま文字列としてレンダリングされてしまいます。
(インスタンスに値がセットされたら、その値が表示されます。)
このとき、v-cloakというディレクティブをCSSのdisplay: none;と組み合わせて使うと、インスタンスが生成されたタイミングで表示してくれます。

app/javascript/packs/components/index.vue
    // 省略
  </script>

+ <style scoped>
+   [v-cloak] {
+     display: none;
+   }
+   .display_none {
+     display:none;
+   }
+   // 打ち消し線を引く
+   .line-through {
+     text-decoration: line-through;
+   }
+ </style>

これで完了済みの方は非表示になったかと思います。

ボタンを押すと、完了済みエリアが表示される

まずはVueのmethodsにボタンを押されたときのメソッドを登録します。

app/javascript/packs/components/index.vue
  methods: {
    // 省略
    },
+   displayFinishedTasks: function() {
+     document.querySelector('#finished-tasks').classList.toggle('display_none');
+   },
  }
}

「ボタンを押されたとき」と記載するのはかなり簡潔にかけます。
テンプレートの方でv-onを使用してクリックされたタイミングでdisplayFinishedTasksを呼んでもらいます。

app/javascript/packs/components/index.vue
    <!-- 完了済みタスク表示ボタン -->
-   <div class="btn">Display finished tasks</div>
+   <div class="btn" v-on:click="displayFinishedTasks">Display finished tasks</div>

新規作成フォームをつくる

v-modelを利用することで、双方向バインディングさせることができます。
これで<input>タグで入力された値とインスタンスのnewTaskプロパティをバインドさせます。

app/javascript/packs/components/index.vue
- <input class="form-control" placeholder="Add your task!!">
+ <input v-model="newTask" class="form-control" placeholder="Add your task!!">

次に新規作成のメソッドを追加します。
APIで新規作成できた場合は、tasksプロパティの先頭に追加するようにします。

app/javascript/packs/components/index.vue
    methods: {
        // 省略
      },
+     createTask: function () {
+       if (!this.newTask) return;
+       
+       axios.post('/api/tasks', { task: { name: this.newTask } }).then((response) => {
+         this.tasks.unshift(response.data.task);
+         this.newTask = '';
+       }, (error) => {
+         console.log(error);
+       });
+     }
    }

作成ボタンを押した場合にこのメソッドを呼びたいので、v-onで登録します。

app/javascript/packs/components/index.vue
- <div class="btn-floating waves-effect waves-light red">
+ <div v-on:click="createTask" class="btn-floating waves-effect waves-light red">
    <i class="material-icons">add</i>
  </div>

チェックをつけたら完了済みに移す

まずはチェックボックスにチェックがついたら更新メソッドを呼ぶようにしたいと思います。
(この辺りは毎回呼ばずに、一定時間で同期させても良いかもしれません。)

app/javascript/packs/components/index.vue
<!-- リスト表示部分 -->
<div>
  <ul class="collection">
    <li v-bind:id="'row_task_' + task.id" class="collection-item" v-for="task in tasks" v-if="!task.is_done">
-     <input type="checkbox" v-bind:id="'task_' + task.id" />
+     <input type="checkbox" v-on:change="doneTask(task.id)" v-bind:id="'task_' + task.id" />
      <label v-bind:for="'task_' + task.id" class="word-color-black">{{ task.name }}</label>
    </li>
  </ul>
</div>

更新用のメソッドを追加します。
更新したタイミングで、未完了部分から消し、完了済みの方に追加します。
(このあたりの処理はもっと良い書き方がある気がします。。。)

app/javascript/packs/components/index.vue
    methods: {
        // 省略
      },
+     doneTask: function (task_id) {
+       axios.put('/api/tasks/' + task_id, { task: { is_done: 1 } }).then((response) => {
+         this.moveFinishedTask(task_id);
+       }, (error) => {
+         console.log(error);
+       });
+     },
+     moveFinishedTask: function(task_id) {
+       var el = document.querySelector('#row_task_' + task_id);
+       // DOMをクローンしておく
+       var el_clone = el.cloneNode(true);
+       // 未完了の方を先に非表示にする
+       el.classList.add('display_none');
+       // もろもろスタイルなどをたして完了済みに追加
+       el_clone.getElementsByTagName('input')[0].checked = 'checked';
+       el_clone.getElementsByTagName('label')[0].classList.add('line-through');
+       el_clone.getElementsByTagName('label')[0].classList.remove('word-color-black');
+       var li = document.querySelector('#finished-tasks > ul > li:first-child');
+       document.querySelector('#finished-tasks > ul').insertBefore(el_clone, li);
+     }
    }

さいごに

ここまでで、下図のような動きができるかと思います。
(いくつかスタイルはたしました。)

sample_todo_spa_2.gif

ただ、テスト、および状態管理に便利なVuexは今回できなかったため、機会があればまた書きたいと思います。
もしよろしければご意見いただけると嬉しいです。

最後に、今回のソースコードはこちらになります。
https://github.com/naoki85/todo_app_with_vue_and_rails

2017/09/20 追記

試しにテストコードを書いてみました。
RailsのSystemTestCaseで書いております。
理由は、Node.jsのテストフレームワークは(私にとって)学習コストが高いためです。
本当はフロントエンドと分けるのであれば、テストも分けた方が良いのではないかと思っています。
https://github.com/naoki85/todo_app_with_vue_and_rails/commit/4a5225395fa2fe6ba9c7a3f8a421648ab28c897c

テストの中で、withinを使用していますが、今回の場合は使用する必要はないと思います。
今後、さらに詳細なテストをする際に使用するかもと思い、残したままにしてあります。

まだ記事にできるほど試していないため、参考程度にご覧ください。
参考にさせていただいたドキュメント、記事などは下記になります。

naoki85
Web系のエンジニアです。 RubyやPHPを主に書いています。
https://scrapbox.io/naoki85/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした