はじめに
Vuejs と Rails API を使って Todo アプリを作りました。
まずは、ローカル環境で動かし
最終的に Heroku へデプロイするところまで書きました。
最初に作ったものを載せておきます。
デモ
コード
ディレクトリ構成
frontend ディレクトリに Vue のファイルをまとめてあります
vue-rails-api-todo/
├── app
│ ├── channels
│ ├── controllers
│ ├── jobs
│ ├── mailers
│ ├── models
│ └── views
├── bin
├── config
├── db
├── docs
├── frontend
│ ├── dist
│ ├── node_modules
│ ├── public
│ └── src
├── lib
├── log
├── public
├── storage
├── test
├── tmp
└── vendor
対象読者(こんな方に読んでいただけたら)
Rails と Vue のチュートリアルを勉強してなにか作ってみたい方
事前準備
Rails と VueCLI3 のインストールを行なってください
自分の環境です
Mac MoJava
ruby 2.6.1
Rails 5.2.3
Vue 3.7.0
【Rails】サーバーサイドの作成
Rails プロジェクトを API モードで作る
rails new vue-rails-api-todo --api
Gemfile を修正
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '2.6.1'
gem 'bootsnap', '>= 1.1.0', require: false
gem 'puma', '~> 3.11'
gem 'rack-cors'
gem 'rails', '~> 5.2.3'
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
group :development, :test do
gem 'byebug', platforms: %i[mri mingw x64_mingw]
gem 'sqlite3'
end
group :development do
gem 'listen', '>= 3.0.5', '< 3.2'
gem 'pry-byebug'
gem 'pry-doc'
gem 'pry-rails'
gem 'pry-stack_explorer'
gem 'spring'
gem 'spring-watcher-listen', '~> 2.0.0'
end
group :production do
gem 'pg'
end
Gem をインストール
bundle install
Model を作る
フィールドは2つだけです
- title: タスクの内容
- completed: 完了・未完了
rails g model Todo title:string completed:boolean
migration ファイルの修正
Not Null 制約 と デフォルト値を追記してます
class CreateTodos < ActiveRecord::Migration[5.2]
def change
create_table :todos do |t|
t.string :title, null: false
t.boolean :completed, default: false, null: false
t.timestamps
end
end
end
マイグレーション
rails g model Task title:string completed:boolean
Model にバリデーションを追加
class Todo < ApplicationRecord
validates :title, presence: true
end
ルーティングの修正
resources :todos, except: :show
以外に2つルーティングを追加しました
-
patch 'check_all', to: 'todos#check_all'
: タスクの完了・未完了 -
delete 'delete_completed', to: 'todos#delete_completed'
: 完了タスクを全削除
Rails.application.routes.draw do
root 'api/v1/todos#index'
namespace :api do
namespace :v1, format: :json do
patch 'check_all', to: 'todos#check_all'
delete 'delete_completed', to: 'todos#delete_completed'
resources :todos, except: :show
end
end
end
ルーティングは詳細は、こんな感じです
Prefix Verb URI Pattern Controller#Action
root GET / api/v1/todos#index
api_v1_check_all PATCH /api/v1/check_all(.:format) api/v1/todos#check_all
api_v1_delete_completed DELETE /api/v1/delete_completed(.:format) api/v1/todos#delete_completed
api_v1_todos GET /api/v1/todos(.:format) api/v1/todos#index
POST /api/v1/todos(.:format) api/v1/todos#create
api_v1_todo PATCH /api/v1/todos/:id(.:format) api/v1/todos#update
PUT /api/v1/todos/:id(.:format) api/v1/todos#update
DELETE /api/v1/todos/:id(.:format) api/v1/todos#destroy
rails_service_blob GET /rails/active_storage/blobs/:signed_id/*filename(.:format) active_storage/blobs#show
rails_blob_representation GET /rails/active_storage/representations/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations#show
rails_disk_service GET /rails/active_storage/disk/:encoded_key/*filename(.:format) active_storage/disk#show
update_rails_disk_service PUT /rails/active_storage/disk/:encoded_token(.:format) active_storage/disk#update
rails_direct_uploads POST /rails/active_storage/direct_uploads(.:format) active_storage/direct_uploads#create
```
### controller の用意
```bash:terminal
rails g controller api::v1::todos
controller を修正
class Api::V1::TodosController < ApplicationController
before_action :set_todo, only: %i[show update destroy]
# GET api/vi/todos/
def index
@todos = Todo.all.order(created_at: :asc)
render json: @todos
end
# Post api/vi/todos
def create
@todo = Todo.new(todo_params)
if @todo.save
render json: @todo
else
render json: { status: 'error', data: @todo.errors }
end
end
# Put api/vi/todos/:id
def update
if @todo.update(todo_params)
render json: @todo
else
render json: { status: 'error', data: @todo.errors }
end
end
# Delete api/vi/todos/:id
def destroy
@todo.destroy
render json: @todo
end
# Delete api/vi/delete_completed
def delete_completed
todo = Todo.where(completed: true).delete_all
render json: todo
end
# Put api/vi/check_all
def check_all
todo = Todo.update_all(completed: params['checked'])
render json: todo
end
private
def todo_params
params.require(:todo).permit(:title, :completed)
end
def set_todo
@todo = Todo.find(params[:id])
end
end
cors の設定ファイルを修正
Vue 側からのアクセスを許可するため追記
origins 'http://localhost:8080'
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'http://localhost:8080'
resource '*',
headers: :any,
methods: %i[get post put patch delete options head]
end
end
seed ファイルを修正
テストデータ作成用
10.times do |i|
Todo.create(title: "title No#{i + 1}", completed: i.even?)
end
postman で確認してみる
rails s
rails db:seed
Postman をインストールされていない方は、こちらからインストールしてください
- プルダウンから GET を選択し http://localhost:3000/api/v1/todos を入力
- Send をクリック
- テストデータの json が返ってくることを確認
時間がある方はその他のアクションも試してみてください
やり方は、ここでは割愛します
【Vue】フロントエンドの作成
プロジェクトを作成
Rails プロジェクトの直下に frontend という名前で Vue プロジェクトを作成します
vue create frontend
いくつか質問がでてくるので
- Manually select features を選択肢し
- Vuex を追加してください
その他はお好みでどうぞ
? Please pick a preset:
default (babel, eslint)
❯ Manually select features
? Check the features needed for your project:
◉ Babel
◯ TypeScript
◯ Progressive Web App (PWA) Support
◯ Router
❯◉ Vuex
◯ CSS Pre-processors
◉ Linter / Formatter
◯ Unit Testing
◯ E2E Testing
インストールが完了したら fronend へ移動しサーバを起動してみましょう
cd frontend && yarn serve
ブラウザから http://localhost:8080/ へアクセスし
こんな画面が表示されたら成功です。
Bootstrap と axios を追加
- BootstrapVue: Vue 用の Bootstrap モジュール
- axios: 今時の ajax モジュール
yarn add bootstrap-vue bootstrap axios
bootstrap の設定を追加
bootstrap を使うため main.js に追記
import BootstrapVue from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
Vue.use(BootstrapVue);
import Vue from "vue";
import App from "./App.vue";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
Vue.use(BootstrapVue);
Vue.config.productionTip = false;
new Vue({
store,
render: h => h(App)
}).$mount("#app");
store を編集
import Vue from "vue";
import Vuex from "vuex";
import axios from "axios";
Vue.use(Vuex);
const http = axios.create({
baseURL:
process.env.NODE_ENV === "development" ? "http://localhost:3000/" : "/",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest"
},
responseType: "json"
});
export default new Vuex.Store({
state: {
filter: "all",
todos: []
},
getters: {
remaining(state) {
return state.todos.filter(todo => !todo.completed).length;
},
completedAll(state, getters) {
return getters.remaining === 0;
},
todosFiltered(state) {
if (state.filter === "all") {
return state.todos;
} else if (state.filter === "active") {
return state.todos.filter(todo => !todo.completed);
} else if (state.filter === "completed") {
return state.todos.filter(todo => todo.completed);
}
return state.todos;
},
showClearCompletedButton(state) {
return state.todos.filter(todo => todo.completed).length > 0;
}
},
mutations: {
addTodo(state, todo) {
state.todos.push({
id: todo.id,
title: todo.title,
completed: false,
editing: false
});
},
clearCompleted(state) {
state.todos = state.todos.filter(todo => !todo.completed);
},
updateFilter(state, filter) {
state.filter = filter;
},
checkAll(state, checked) {
state.todos.forEach(todo => {
todo.completed = checked;
});
},
deleteTodo(state, id) {
const index = state.todos.findIndex(todo => todo.id === id);
state.todos.splice(index, 1);
},
updateTodo(state, todo) {
const index = state.todos.findIndex(item => item.id === todo.id);
state.todos.splice(index, 1, {
id: todo.id,
title: todo.title,
completed: todo.completed,
editing: todo.editing
});
},
retrieveTodos(state, todos) {
state.todos = todos;
}
},
actions: {
retrieveTodos({ commit }) {
http
.get("/api/v1/todos")
.then(response => {
commit("retrieveTodos", response.data);
})
.catch(error => {
console.log(error);
});
},
addTodo({ commit }, todo) {
http
.post("/api/v1/todos", {
title: todo.title,
completed: false
})
.then(response => {
commit("addTodo", response.data);
})
.catch(error => {
console.log(error);
});
},
clearCompleted({ commit }) {
http
.delete("/api/v1/delete_completed")
.then(response => {
commit("clearCompleted", response.data);
})
.catch(error => {
console.log(error);
});
},
checkAll({ commit }, checked) {
http
.patch("/api/v1/check_all", {
checked
})
.then(() => {
commit("checkAll", checked);
})
.catch(error => {
console.log(error);
});
},
deleteTodo({ commit }, id) {
http
.delete(`/api/v1/todos/${id}`)
.then(response => {
commit("deleteTodo", response.data.id);
})
.catch(error => {
console.log(error);
});
},
updateTodo({ commit }, todo) {
http
.patch(`/api/v1/todos/${todo.id}`, {
title: todo.title,
completed: todo.completed
})
.then(response => {
commit("updateTodo", response.data);
})
.catch(error => {
console.log(error);
});
}
}
});
store は大きく5つのブロックに分かれています
axios のデフォルト通信設定
- baseURL: API 取得のための URL
- header:リクエスト時のヘッダの値
- responseType:レスポンスの形式
import axios from "axios";
Vue.use(Vuex);
const http = axios.create({
baseURL:
process.env.NODE_ENV === "development" ? "http://localhost:3000/" : "/",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest"
},
responseType: "json"
});
state
アプリの状態管理をするための単一オブジェクトです
- todo: タスクの配列
- filter:全て ・ 完了 ・ 未完了 のフィルタ
state: {
filter: "all",
todos: []
},
getters
component でいう computed にあたります
- remaining: タスク完了の件数
- showClearCompletedButton: クリアボタンを 表示 ・ 非表示 の切替用
getters: {
remaining(state) {
return state.todos.filter(todo => !todo.completed).length;
},
/*************** 省略 ***************/
showClearCompletedButton(state) {
return state.todos.filter(todo => todo.completed).length > 0;
}
}
mutaition
state を変更するためのメソッド群です
action を経由して state を更新するために使っています
- addTodo: 新しいタスクを追加しています
- retrieveTodos: ページに最初にアクセスしたとき、タスク一覧を作成しています
mutations: {
addTodo(state, todo) {
state.todos.push({
id: todo.id,
title: todo.title,
completed: false,
editing: false
});
},
/*************** 省略 ***************/
retrieveTodos(state, todos) {
state.todos = todos;
}
},
actions
非同期処理を行うためのメソッド群です
axios を使って Rails API を取得するために使っています
- retrieveTodos: Rails の API からタスク一覧を取得しています
- updateTodo: Rails の API からタスクの更新結果を取得しています
actions: {
retrieveTodos({ commit }) {
http
.get("/api/v1/todos")
.then(response => {
commit("retrieveTodos", response.data);
})
.catch(error => {
console.log(error);
});
},
/*************** 省略 ***************/
updateTodo({ commit }, todo) {
http
.patch(`/api/v1/todos/${todo.id}`, {
title: todo.title,
completed: todo.completed
})
.then(response => {
commit("updateTodo", response.data);
})
.catch(error => {
console.log(error);
});
}
}
App を編集
<template>
<div id="app" class="container">
<img alt="Vue logo" src="./assets/logo.png" class="logo" />
<h1>VueTODO</h1>
<todo-list></todo-list>
</div>
</template>
<script>
import TodoList from "./components/TodoList.vue";
export default {
name: "App",
components: {
TodoList
}
};
</script>
<style lang="scss" scoped>
.logo {
margin: 0 auto;
display: block;
}
</style>
メインとなる TodoList コンポーネントを呼び出しています
import TodoList from "./components/TodoList.vue";
export default {
name: "App",
components: {
TodoList
}
};
TodoList コンポーネントを作成
新しいタスクの追加 と 子コンポーネントを束ねています
<template>
<div>
<b-container class="bv-example-row">
<b-row>
<b-col cols="12">
<b-form @submit.prevent="addTodo">
<b-form-group label="New todo" label-for="new-todo">
<b-form-input
id="new-todo"
v-model="newTodo"
placeholder="What needs to be done?"
></b-form-input>
</b-form-group>
</b-form>
<b-list-group>
<transition-group name="fade">
<TodoItem
v-for="(todo, index) in todosFiltered"
:key="todo.id"
:todo="todo"
:index="index"
class="todo-item"
:check-all="completedAll"
/>
</transition-group>
</b-list-group>
<b-list-group class="mt-4">
<b-list-group-item
class="flex-wrap d-flex justify-content-around align-items-center"
>
<TodoCheckAll />
<TodoItemsRemaining />
</b-list-group-item>
<b-list-group-item
class="flex-wrap d-flex justify-content-around align-items-center"
>
<TodoFiltered />
<TodoClearCompleted />
</b-list-group-item>
</b-list-group>
</b-col>
</b-row>
</b-container>
</div>
</template>
<script>
import TodoItem from "@/components/TodoItem";
import TodoItemsRemaining from "@/components/TodoItemsRemaining";
import TodoCheckAll from "@/components/TodoCheckAll";
import TodoFiltered from "@/components/TodoFiltered";
import TodoClearCompleted from "@/components/TodoClearCompleted";
import { mapGetters } from "vuex";
export default {
name: "TodoList",
components: {
TodoItem,
TodoItemsRemaining,
TodoCheckAll,
TodoFiltered,
TodoClearCompleted
},
data() {
return {
newTodo: ""
};
},
computed: {
...mapGetters(["completedAll", "todosFiltered"])
},
created() {
this.$store.dispatch("retrieveTodos");
},
methods: {
addTodo() {
if (this.newTodo.trim()) {
this.$store.dispatch("addTodo", {
id: this.idForTodo,
title: this.newTodo
});
}
this.newTodo = "";
}
}
};
</script>
<style lang="scss">
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style>
TodoItem コンポーネントを作成
親コンポーネントの TodoList から props を受け取り
個々のタスクの表示させています
<template>
<b-list-group-item
class="flex-wrap d-flex justify-content-around align-items-center todo-item"
>
<b-col cols="2">
<b-form-checkbox v-model="completed" @input="doneEdit"></b-form-checkbox>
</b-col>
<b-col cols="8">
<label
v-if="!editing"
:class="{ completed: completed }"
@dblclick="editing = true"
>{{ title }}</label
>
<b-form-input
v-else
v-model="title"
v-focus
type="text"
@blur="doneEdit"
@keyup.enter="doneEdit"
@keyup.escape="cancelEdit"
/>
</b-col>
<b-col cols="2">
<button
type="button"
class="close"
aria-label="Close"
@click="deleteTodo(todo.id)"
>
<span aria-hidden="true">×</span>
</button>
</b-col>
</b-list-group-item>
</template>
<script>
import { mapActions } from "vuex";
export default {
name: "TodoItem",
directives: {
focus: {
inserted: function(el) {
el.focus();
}
}
},
props: {
todo: {
type: Object,
required: true
},
index: {
type: Number,
required: true
},
checkAll: {
type: Boolean,
required: true
}
},
data() {
return {
id: this.todo.id,
title: this.todo.title,
completed: this.todo.completed,
editing: false
};
},
watch: {
checkAll() {
this.completed = this.checkAll ? true : this.todo.completed;
}
},
methods: {
...mapActions(["deleteTodo", "updateTodo"]),
doneEdit() {
this.editing = false;
this.updateTodo({
id: this.id,
title: this.title,
completed: this.completed,
editing: this.editing
});
},
cancelEdit() {
this.title = this.todo.title;
this.editing = false;
}
}
};
</script>
<style lang="scss" scoped>
.todo-item {
animation-duration: 0.3s;
}
.completed {
text-decoration: line-through;
color: grey;
}
</style>
TodoItemsRemaining コンポーネントを作成
残りのタスク件数を表示させています
<template>
<b-col cols="6">
<span class="text-danger">{{ remaining }}</span>
{{ remaining | pluralize("item") }} left
</b-col>
</template>
<script>
import { mapGetters } from "vuex";
export default {
name: "TodoItemsRemaining",
filters: {
pluralize: (n, w) => (n === 1 ? w : w + "s")
},
computed: {
...mapGetters(["remaining"])
}
};
</script>
TodoFiltered コンポーネントを作成
All(全て) ・ Active(未完了) ・ Completed(完了)
の値によってタスクにフィルタをかけています
<template>
<b-col cols="6">
<b-form-radio-group
v-model="selected"
:options="options"
buttons
button-variant="outline-primary"
name="radio-btn-outline"
@change="updateFilter"
></b-form-radio-group>
</b-col>
</template>
<script>
import { mapState, mapMutations } from "vuex";
export default {
name: "TodoFiltered",
data() {
return {
selected: "all",
options: [
{ text: "All", value: "all" },
{ text: "Active", value: "active" },
{ text: "Completed", value: "completed" }
]
};
},
computed: {
...mapState(["filter"])
},
methods: {
...mapMutations(["updateFilter"])
}
};
</script>
TodoClearCompleted コンポーネントを作成
完了したタスクの一括クリアボタンを表示させています
<template>
<b-col cols="6">
<div>
<b-button
v-if="showClearCompletedButton"
variant="outline-primary"
@click="clearCompleted"
>Clear Completed</b-button
>
</div>
</b-col>
</template>
<script>
import { mapGetters, mapActions } from "vuex";
export default {
name: "TodoClearCompleted",
computed: {
...mapGetters(["showClearCompletedButton"])
},
methods: {
...mapActions(["clearCompleted"])
}
};
</script>
TodoCheckAll コンポーネントを作成
タスクを一括で完了 ・ 未完了に切り替えるための
チェックボックスを表示させています
<template>
<b-col cols="6">
<b-form-checkbox :checked="completedAll" @change="checkAll"
>Check All</b-form-checkbox
>
</b-col>
</template>
<script>
import { mapGetters, mapActions } from "vuex";
export default {
name: "TodoCheckAll",
computed: {
...mapGetters(["completedAll"])
},
methods: {
...mapActions(["checkAll"])
}
};
</script>
ブラウザで確認
Rail のサーバを起動
rails s
Vue のサーバを起動
cd frontend
yarn serve
localhost:8080 にアクセスしてこんな画面が表示されたら成功です
Heroku へデプロイしてみる
事前準備
Heroku のアカウントがない場合はこちらから作成してください
デプロイには heroku toolbelt が必要なのでこちらからインストールしてください
mac の場合は Homebrew でインストール可能です
brew install heroku
プロジェクトを commit
プロジェクト直下へ移動し commit を行なってください
git init
git add .
git commit -m "init"
vue.config.js を作成
frontend ディレクトリの直下に vue.config.js ファイルを作成し
build ファイルの出力先をプロジェクト直下の public ディレクトリへ変更します
module.exports = {
outputDir: "../public"
};
Vue を build
yarn build
プロジェクト直下の public ディレクトリに
build されたファイルが作成されていることを確認してください
public/
├── css
│ ├── app.27d4506b.css
│ └── chunk-vendors.19588e8d.css
├── favicon.ico
├── img
│ └── logo.82b9c7a5.png
├── index.html
└── js
├── app.6635e2d3.js
├── app.6635e2d3.js.map
├── chunk-vendors.4ad97586.js
└── chunk-vendors.4ad97586.js.map
Heroku にログイン
heroku login
上のコマンドを実行するとブラウザに切替わるのでボタンを押してログインしてください
Heroku にアプリを作成
アプリ名を入力すると URL にアプリ名が反映されます
https://アプリ名.herokuapp.com/
省略すると Heroku 側で自動的に割り振られます
heroku create アプリ名
Heroku のリポジトリへ push
git push heroku master
データベースの migration と テストデータを追加
heroku run rails db:migrate
heroku run rails db:seed
ブラウザで確認
heroku open
おわりに
最後まで読んでいただきありがとうございました。
おかしな部分がありましたら、ご指摘お願いします。