前回の記事の続きになります。
前回の記事はこちら↓
本記事の概要
完成版イメージ
今回実装する機能(CRUD)の完成イメージです。
今回実装する機能について
####【ブックマーク新規投稿】
- モーダルを開いてフォームを表示させます。
- それぞれタイトル、URL、カテゴリーを入力し、「ADD BOOKMARK」ボタンを押すと投稿が完了し、「CANCEL」ボタンを押すとキャンセルされモーダルを閉じます。
- カテゴリーは既存のものを参照して選択できるようにします。
####【ブックマーク編集・更新】
- モーダルを開いてフォームを表示させます。
- フォームには編集するブックマークの内容をあらかじめ表示させます。
- 「UPDATE」ボタンを押すと編集内容の更新が完了します。「CANCEL」を押すと更新せずにモーダルを閉じます。
【ブックマーク削除】
- 削除前に確認するメッセージをモーダルで表示します。
- モーダルの「DELETE」を押すとブックマークが削除されます。削除しない場合はモーダルの外の暗い部分をクリックすることでそのままモーダルを閉じます。
【並び替え機能】
- Vue.Draggable を導入して実装します。
記事構成
本アプリケーションについての記事は大きく4部構成で作成しております。
- 第一章:アプリケーション作成〜ダミーデータ表示
- 第二章:本記事(ブックマークのCRUD実装)
- 第三章:フリーワード検索、カテゴリー別絞り込み機能の実装
- 第四章:ローディング表示、ページネーション、ユーザー管理機能の実装
本記事の参考URL
本記事の作成にあたって以下の記事を参考にさせて頂きました。
実装手順(CRUD)
それでは実装に入ります。
ルーティングの編集
各機能の実装に入る前に、config/routes.rb
ファイルを編集します。
Rails.application.routes.draw do
root to: 'home#index'
namespace :api, format: 'json' do
# 編集前
# resources :bookmarks, only: [:index]
# 編集後
resources :bookmarks, only: [:index, :create, :update, :destroy]
end
end
CSRF対策の無効化
CSRF(Cross-Site Request Forgery)とは、悪意のあるユーザーが不正なスクリプト等を含んだURLを用意し、本物のユーザーを装ってそのURLにアクセスさせる攻撃手法の事です。
Railsでは、erbでPOSTリクエスト等を送信する際にCSRFトークンというものを自動的に発行してCSRFを対策してくれます。
公式:Rails セキュリティガイド -Railsガイド
しかし、axiosを使ってVue.js側からPOSTリクエストを送信するとエラーが発生してしまうため、一度Rails側でこのCSRF対策を無効化し、後に以下の記事を参考にさせて頂きつつフロント側でCSRF対策処理を施します。
それではbookmarks_controller
でCSRF対策の無効化処理を記述します。
class Api::BookmarksController < ApplicationController
protect_from_forgery :except => [:create, :update, :destroy] # <- この部分を追加
# protect_from_forgery with: :exception <-アクションを指定しない場合
end
ブックマークの新規投稿機能
まずサーバーサイドの実装をします。
bookmarks_controllerの編集
app/controllers/api/bookmarks_controller.rb
に create アクションを追加します。
class Api::BookmarksController < ApplicationController
protect_from_forgery :except => [:create, :update, :destroy]
def create
@bookmark = Bookmark.new(bookmark_params)
if @bookmark.save
head :no_content
else
render json: @bookmark.errors, status: :unprocessable_entity
end
end
private
def bookmark_params
params.require(:bookmark).permit(:title, :url, :category)
end
end
Rails側の実装が終わったら、次はフロントを実装していきます。
app.vueの編集
app/javascript/app.vue
でモーダルやフォームの部分のテンプレートと入力したデータをサーバーサイドに送信する処理を記述していきます。
全てのコードを書くと長くなってしまうので、テンプレートの主要な部分と script 部分を分けて載せます。
【テンプレート部分】
<template>
<v-app id="app">
<!------- 中略 -------->
<!-- モーダルを開くためのボタン部分 -->
<v-btn @click="togglePostModal()" style="margin: 20px 0 40px 0;">
Bookmarkを追加する
</v-btn>
<!------- 中略 -------->
<!-- 新規投稿用モーダルウィンドウ -->
<v-dialog v-model="dialogPostFlag" width="500px" persistent>
<v-card>
<v-card-title class="headline blue-grey darken-3 white--text" primary-title>
ブックマーク新規投稿
</v-card-title>
<!-- タイトルの入力フォーム -->
<v-text-field v-model="postTitle" :counter="50" label="Title" required style='margin:20px;'></v-text-field>
<!-- URLの入力フォーム -->
<v-text-field v-model="postUrl" label="URL" required style='margin:20px;'></v-text-field>
<!-- カテゴリーの入力フォーム -->
<v-text-field v-model="postCategory" :counter="50" label="Category" required style='margin:20px;'></v-text-field>
<!-- カテゴリーを選択できるプルダウン (categoriesForEditの配列の中身を表示) -->
<v-select v-model='postCategory' :items="categoriesForEdit" label="Category [select]" style='margin:20px;'></v-select>
<v-divider></v-divider>
<v-card-actions>
<!-- モーダルを閉じる キャンセルボタン -->
<v-btn dark @click="togglePostModal">
Cancel
</v-btn>
<v-spacer></v-spacer>
<!-- postBookmark を呼んで値を送信 (submitボタン) -->
<v-btn @click="postBookmark">
Add Bookmark
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-app>
</template>
【Vue.js部分】
<script>
import axios from 'axios';
export default {
data: function () {
return {
bookmarkList: ['',''],
// 以下を追加
allData: ['',''], // bookmarkの値を全て代入する空配列をもう一つ用意
categories: ['All'], // カテゴリーを代入する配列 (検索機能で使用)
categoriesForEdit: [], // カテゴリーを代入する配列 (新規投稿や編集フォームで使用)
category: 'ALL', // bookmarkのカテゴリー 初期値は'ALL'
dialogPostFlag: false, // モーダル表示:初期値をfalseに設定
postTitle: "", // フォームの中身の初期値は空
postUrl: "", // フォームの中身の初期値は空
postCategory: "", // フォームの中身の初期値は空
}
},
mounted () {
this.setBookmark();
},
methods: {
setBookmark: function () {
axios.get('/api/bookmarks')
.then(response => {
// allData の中にもデータを全て代入する
this.allData = response.data
this.bookmarkList = this.allData
}
);
this.listCategories();
},
// bookmarkのカテゴリーをまとめて管理するメソッド
listCategories: function() {
this.categories = [] // 検索で表示するカテゴリーの選択メニュー
this.categoriesForEdit = [] // 新規投稿や編集のフォームに表示する選択メニュー
this.categories.push('ALL') // `ALL`を配列に追加してメニューの一番上に表示させる
// 配列の要素の数だけ反復処理
for (i=0; i<this.allData.length; i++) {
// 保存されたカテゴリーの文字列が、i 番目の allData のカテゴリーの何文字目で一致するかを返し、
// 一致しない場合は -1 を返す
// そして結果が -1 で true である場合に処理を行う
if (this.categories.indexOf(this.allData[i].category) == -1) {
// つまり新しいカテゴリーが保存された時それぞれの配列の中に値を push する
this.categories.push(this.allData[i].category)
this.categoriesForEdit.push(this.allData[i].category)
}
}
},
// モーダルを開閉するメソッド
togglePostModal: function() {
// モーダルの開閉状態を操作する。(ONとOFFを切り替える)
this.dialogPostFlag = !this.dialogPostFlag
},
// 投稿内容を送信するメソッド
postBookmark: function() {
// axiosを使って /api/bookmarks コントローラーの create アクションにデータを送信
// 第2引数でカラムにそれぞれフォームで受け取ったデータを渡す
axios.post('/api/bookmarks', {title:this.postTitle,url:this.postUrl,category:this.postCategory})
.then(response => {
this.setBookmark(); // setBookmark()を呼び出す
this.postTitle = '' // postTitleの中身を空の状態に戻す
this.postUrl = '' // postUrlの中身を空の状態に戻す
this.postCategory = '' // postCategoryの中身を空の状態に戻す
}
);
this.dialogPostFlag = !this.dialogPostFlag // モーダルを閉じる
},
}
}
</script>
以上が新規投稿機能の実装部分になります。
app.vue
でフォームのデータを送る処理は postBookmark の部分になります。ここでフォームに入力されたデータをaxiosを使って/api/bookmarks_controller.rb
にデータを送信しています。
ブックマーク編集・更新機能
bookmarks_controllerの編集
class Api::BookmarksController < ApplicationController
protect_from_forgery :except => [:create, :update, :destroy]
# updateアクションを追加
def update
@bookmark = Bookmark.find(params[:id])
if @bookmark.update_attributes(bookmark_params)
render "index", formats: :json, handlers: "jbuilder"
else
render json: @bookmark.errors, status: :unprocessable_entity
end
end
private
def bookmark_params
params.require(:bookmark).permit(:title, :url, :category)
end
end
app.vueの編集
【テンプレート部分】
<template>
<v-app id="app">
<!------- 中略 -------->
<v-flex xs8>
<div style="width: 100%; margin: 5px 0 20px 0; display: flex; justify-content: center;">
<h1>Bookmark 一覧</h1>
</div>
<v-layout>
<v-flex row wrap style="justify-content: center;">
<!-- bookmark の表示部分 -->
<v-card v-for="bookmark in bookmarkList" :key="bookmark.id" style="width: 100%">
<v-card-title primary-title style="margin-bottom: 15px; width: 100%; padding-bottom: 10px;">
<div style="width: 100%;">
<div class="headline mb-0" style="display: flex; justify-content: space-between; width: 100%">
<p style="font-size: 18px;">
{{ bookmark.title }} <!-- タイトルを表示 -->
</p>
<!-- 編集用フォームのモーダルを開くボタン -->
<v-tooltip right>
<template v-slot:activator="{ on }">
<!-- togglePutModalに編集するbookmarkのIDを引数で渡す -->
<v-btn light v-on="on" @click="togglePutModal(bookmark.id)" style="margin-bottom: 8px">
<span class="material-icons" style="margin-right: 4px;">create</span>
</v-btn>
</template>
<span>編集する</span>
</v-tooltip>
</div>
<v-divider></v-divider>
<div style="font-size: 16px; display: flex; justify-content: space-between; width: 100%">
<div>
#{{ bookmark.category }} <!-- カテゴリーを表示 -->
</div>
</div>
</div>
</v-card-title>
</v-card>
</v-flex>
</v-layout>
</v-flex>
<!------- 中略 -------->
<!-- 更新用モーダルウィンドウ -->
<v-dialog v-model="dialogPutFlag" width="500px" persistent>
<v-card>
<v-card-title class="headline orange darken-4 white--text" primary-title>
ブックマーク編集
</v-card-title>
<v-text-field v-model="putTitle" :counter="50" label="Title" required style='margin:20px;'></v-text-field>
<v-text-field v-model="putUrl" label="URL" required style='margin:20px;'></v-text-field>
<v-text-field v-model="putCategory" :counter="50" label="Category" required style='margin:20px;'></v-text-field>
<v-select v-model='putCategory' :items="categoriesForEdit" label="Category [select]" style='margin:20px;'></v-select>
<v-divider></v-divider>
<v-card-actions>
<!-- キャンセルボタン cancelメソッドを呼び出す -->
<v-btn dark @click="cancel">
Cancel
</v-btn>
<v-spacer></v-spacer>
<!-- 更新ボタン putBookmarkメソッドを呼び出し submit -->
<v-btn @click="putBookmark">
Update
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!------- 中略 -------->
</v-app>
</template>
【Vue.js部分】
<script>
import axios from 'axios';
export default {
data: function () {
return {
bookmarkList: ['',''],
allData: ['',''],
categories: ['All'],
categoriesForEdit: [],
category: 'ALL',
dialogPostFlag: false, // 新規投稿用モーダルウィンドウ
postTitle: "",
postUrl: "",
postCategory: "",
dialogPutFlag: false, //編集用モーダルウィンドウ
putTitle: '',
putUrl: '',
putCategory: '',
}
},
mounted () {
this.setBookmark();
},
methods: {
setBookmark: function () {
axios.get('/api/bookmarks')
.then(response => {
this.allData = response.data
this.bookmarkList = this.allData
}
);
this.listCategories();
},
listCategories: function() {
this.categories = []
this.categoriesForEdit = []
this.categories.push('ALL')
var i = 0;
for (i=0; i<this.allData.length; i++) {
if (this.categories.indexOf(this.allData[i].category) == -1) {
this.categories.push(this.allData[i].category)
this.categoriesForEdit.push(this.allData[i].category)
}
}
},
togglePostModal: function() {
this.dialogPostFlag = !this.dialogPostFlag
},
postBookmark: function() {
axios.post('/api/bookmarks', {title:this.postTitle,url:this.postUrl,category:this.postCategory})
.then(response => {
this.setBookmark();
this.postTitle = ''
this.postUrl = ''
this.postCategory = ''
}
);
this.dialogPostFlag = !this.dialogPostFlag
},
// 編集用モーダルウィンドウの開閉を操作するメソッド
togglePutModal: function(id) { // 編集するbookmarkのIDを受け取る
this.id = id
// axiosを使って編集対象となるbookmarkの内容を取得し、フォームに表示させる
axios.get(`/api/bookmarks/${this.id}.json`)
.then(response => {
this.putTitle = response.data.title
this.putUrl = response.data.url
this.putCategory = response.data.category
}
);
// モーダルの開閉状態を操作
this.dialogPutFlag = !this.dialogPutFlag
},
// 編集したブックマークの内容を更新するメソッド
putBookmark: function() {
axios.put(`/api/bookmarks/${this.id}`, {title:this.putTitle, url:this.putUrl, category:this.putCategory})
.then(response => {
this.setBookmark();
}
);
this.dialogPutFlag = !this.dialogPutFlag
},
// 更新せずにそのままモーダルを閉じるメソッド
cancel: function() {
this.dialogPutFlag = !this.dialogPutFlag
},
}
}
</script>
以上がブックマークの編集、更新機能の実装部分になります。
axiosを使って更新する際は、putを使い、/api/bookmarks/${this.id}
にそれぞれのカラムに更新する内容を代入して送信します。
/api/bookmarks/${this.id}
の部分は、ターミナルでrails routes
コマンドを実行して確認できます。
% rails routes
.
.
.
api_bookmark GET /api/bookmarks/:id(.:format) api/bookmarks#show {:format=>/json/}
PATCH /api/bookmarks/:id(.:format) api/bookmarks#update {:format=>/json/}
PUT /api/bookmarks/:id(.:format) api/bookmarks#update {:format=>/json/}
DELETE /api/bookmarks/:id(.:format) api/bookmarks#destroy {:format=>/json/}
ブックマーク削除機能
bookmarks_controllerの編集
class Api::BookmarksController < ApplicationController
protect_from_forgery :except => [:create, :update, :destroy]
# destroyアクションを追加
def destroy
@bookmark = Bookmark.find(params[:id])
if @bookmark.destroy
render json: { json: 'Bookmark was successfully deleted.'}
else
render json: @bookmark.errors, status: :unprocessable_entity
end
end
end
app.vueの編集
【テンプレート部分】
<template>
<v-app id="app">
<!------- 中略 -------->
<v-flex xs8>
<div style="width: 100%; margin: 5px 0 20px 0; display: flex; justify-content: center;">
<h1>Bookmark 一覧</h1>
</div>
<v-layout>
<v-flex row wrap style="justify-content: center;">
<!-- bookmark の表示部分 -->
<v-card v-for="bookmark in bookmarkList" :key="bookmark.id" style="width: 100%">
<v-card-title primary-title style="margin-bottom: 15px; width: 100%; padding-bottom: 10px;">
<div style="width: 100%;">
<div class="headline mb-0" style="display: flex; justify-content: space-between; width: 100%">
<p style="font-size: 18px;">
{{ bookmark.title }} <!-- タイトルを表示 -->
</p>
<!-- 編集用フォームのモーダルを開くボタン -->
<v-tooltip right>
<template v-slot:activator="{ on }">
<!-- togglePutModalに編集するbookmarkのIDを引数で渡す -->
<v-btn light v-on="on" @click="togglePutModal(bookmark.id)" style="margin-bottom: 8px">
<span class="material-icons" style="margin-right: 4px;">create</span>
</v-btn>
</template>
<span>編集する</span>
</v-tooltip>
</div>
<v-divider></v-divider>
<div style="font-size: 16px; display: flex; justify-content: space-between; width: 100%">
<div>
#{{ bookmark.category }} <!-- カテゴリーを表示 -->
</div>
<!-- 削除確認のモーダルを開くボタン -->
<v-tooltip right>
<template v-slot:activator="{ on }">
<!-- toggleDeleteModalに削除するbookmarkのIDを引数で渡す -->
<v-btn dark v-on="on" @click="toggleDeleteModal(bookmark.id)" style="margin-top: 8px">
<span class="material-icons" style="margin-right: 4px;">delete</span>
</v-btn>
</template>
<span>削除する</span>
</v-tooltip>
</div>
</div>
</v-card-title>
</v-card>
</v-flex>
</v-layout>
</v-flex>
<!------- 中略 -------->
<!-- 削除確認用モーダル -->
<v-dialog v-model="dialogDeleteFlag" width="400">
<v-card>
<v-card-title class="headline blue-grey darken-3 white--text" primary-title>
確認
</v-card-title>
<br>
<br>
<v-card-text>
<p>本当に削除してもよろしいですか?</p>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn dark class="yellow--text" @click="deleteBookmark()">
Delete
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-app>
</template>
【Vue.js部分】
<script>
import axios from 'axios';
export default {
data: function () {
return {
bookmarkList: ['',''],
allData: ['',''],
categories: ['All'],
categoriesForEdit: [],
category: 'ALL',
dialogPostFlag: false, // 新規投稿用モーダルウィンドウ
postTitle: "",
postUrl: "",
postCategory: "",
dialogPutFlag: false, //編集用モーダルウィンドウ
putTitle: '',
putUrl: '',
putCategory: '',
dialogDeleteFlag: false, // 削除していいかどうかを確認するモーダルウィンドウ
}
},
mounted () {
this.setBookmark();
},
methods: {
setBookmark: function () {
axios.get('/api/bookmarks')
.then(response => {
this.allData = response.data
this.bookmarkList = this.allData
}
);
this.listCategories();
},
listCategories: function() {
this.categories = []
this.categoriesForEdit = []
this.categories.push('ALL')
var i = 0;
for (i=0; i<this.allData.length; i++) {
if (this.categories.indexOf(this.allData[i].category) == -1) {
this.categories.push(this.allData[i].category)
this.categoriesForEdit.push(this.allData[i].category)
}
}
},
togglePostModal: function() {
this.dialogPostFlag = !this.dialogPostFlag
},
postBookmark: function() {
axios.post('/api/bookmarks', {title:this.postTitle,url:this.postUrl,category:this.postCategory})
.then(response => {
this.setBookmark();
this.postTitle = ''
this.postUrl = ''
this.postCategory = ''
}
);
this.dialogPostFlag = !this.dialogPostFlag
},
togglePutModal: function(id) {
this.id = id
axios.get(`/api/bookmarks/${this.id}.json`)
.then(response => {
this.putTitle = response.data.title
this.putUrl = response.data.url
this.putCategory = response.data.category
}
);
this.dialogPutFlag = !this.dialogPutFlag
},
putBookmark: function() {
axios.put(`/api/bookmarks/${this.id}`, {title:this.putTitle, url:this.putUrl, category:this.putCategory})
.then(response => {
this.setBookmark();
}
);
this.dialogPutFlag = !this.dialogPutFlag
},
cancel: function() {
this.dialogPutFlag = !this.dialogPutFlag
},
// 削除の確認をするモーダルの開閉状態を管理するメソッド
toggleDeleteModal: function(id) {
this.id = id
this.dialogDeleteFlag = !this.dialogDeleteFlag
},
// bookmarkを削除するメソッド
deleteBookmark: function() {
axios.delete(`/api/bookmarks/${this.id}`)
.then(response => {
this.setBookmark();
}
);
this.dialogDeleteFlag = !this.dialogDeleteFlag
},
}
}
</script>
以上がブックマークの削除機能の実装部分になります。
これで一通りのブックマークの関するCRUDを実装することができました。
bookmarks_controllerを整える
最後に、bookmarks_controllerで共通している処理をまとめます。
class Api::BookmarksController < ApplicationController
before_action :set_bookmark, only: [:update, :destroy]
protect_from_forgery :except => [:create, :update, :destroy]
def index
@bookmarks = Bookmark.order('created_at DESC')
render "index", formats: :json, handlers: "jbuilder"
end
def create
@bookmark = Bookmark.new(bookmark_params)
if @bookmark.save
render :show, status: :created
else
render json: @bookmark.errors, status: :unprocessable_entity
end
end
def update
if @bookmark.update_attributes(bookmark_params)
render "index", formats: :json, handlers: "jbuilder"
else
render json: @bookmark.errors, status: :unprocessable_entity
end
end
def destroy
if @bookmark.destroy
render json: { json: 'Bookmark was successfully deleted.'}
else
render json: @bookmark.errors, status: :unprocessable_entity
end
end
private
def bookmark_params
params.require(:bookmark).permit(:title, :url, :category).merge(user_id: current_user.id)
end
def set_bookmark
@bookmark = Bookmark.find(params[:id])
end
end
CSRF対策
最初にRails側でCSRF対策を無効化しましたが、改めてフロント側で対策していきます。
具体的には、csrfトークンをヘッダーに含める形で実装します。
<script>
import axios from 'axios';
// ヘッダーにcsrfトークンを含ませる
axios.defaults.headers.common = {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN' : document.querySelector('meta[name="csrf-token"]').getAttribute('content')
};
export default {
data: function () {
return {
...
参考にさせていただいた記事:
ドラッグ&ドロップでの並び替え機能を実装する
Vueには、ドラッグ&ドロップの実装に便利な Vue.Draggable というライブラリがあります。
今回はこの Vue.Draggable を公式Githubの Readme を参考に導入し実装します。
% yarn add vuedraggable
【テンプレート部分を編集】
ドラッグ&ドロップさせたい要素は基本的に<draggable>
の直下に配置ないとドラッグ&ドロップできないため、<draggable></draggable>
で挟み込むように記述します。
<template>
<v-app id="app">
<v-container style="height: 1000px; max-width: 2400px; padding: 0 20px;">
<v-layout>
<!------- 中略 -------->
<v-flex xs8>
<div style="width: 100%; margin: 5px 0 20px 0; display: flex; justify-content: center;">
<h1>Bookmark 一覧</h1>
</div>
<v-layout>
<v-flex row wrap style="justify-content: center;">
<!-- bookmarkList の配列の中身を入れ替えるように v-model で指定 -->
<draggable v-model="bookmarkList" style="margin: 0 25px; width: 80%; cursor: pointer;">
<v-card v-for="bookmark in bookmarkList" :key="bookmark.id" :items-per-page="itemsPerPage" style="width: 100%">
<!------- 中略 -------->
</v-card>
</draggable>
</v-container>
</v-app>
</template>
【Vue.js側を編集】
Vue.Draggable をインポートし、コンポーネントとして扱います。
<script>
import draggable from 'vuedraggable' // Vue.Draggable をインポート
import axios from 'axios';
export default {
...
components: {
draggable, // draggableをコンポーネントとして使う
},
methods: {
...
以上で本記事【ブックマークのCRUD + ドラッグ&ドロップ機能の実装】は終了です。
次章、フリーワード検索、カテゴリー別絞り込み機能の実装はこちら↓