ブックマークボタンのajax化
Ajaxとは
Webブラウザ上で非同期通信を行い、ページ全体の再読み込み無しにページを更新する方法のこと。
クライアントとサーバー間の通信においては、通常、同期通信と呼ばれる方法が用いられ、一瞬画面が白くなった後、画面が切り替わるような通信は、全てこの同期通信です。 この同期通信の間、クライアントは他の作業を行うことができません。 これは、クライアントからサーバーに対してページ全ての情報を返すようリクエストが送られているためであり、リクエストを送ったクライアントは、サーバーかの応答があるまでその結果を待機し、結果を受け取った後に画面全体を切り替える処理を行う。
一方、非同期通信では、クライアントからのリクエスト送信後、サーバーの処理中にも、他の作業を行うことができる。
非同期通信の方法は二種類
・remote:true形式
・ajax関数を使った形式
今回はremote:true形式で実装する。
コントローラーの修正
app/controllers/bookmarks_controller.rb
class BookmarksController < ApplicationController def create @board = Board.find(params[:board_id]) current_user.bookmark(@board) end def destroy @board = current_user.bookmarks.find(params[:id]).board current_user.unbookmark(@board) end end
redirect_toの削除
ブックマークボタンをajax化
link_toにremote: trueを指定して、非同期処理にする。
app/views/boards/_bookmark.html.erb
<%= link_to bookmarks_path(board_id: board.id), id: "js-bookmark-button-for-board-#{board.id}", class:"float-right", method: :post, remote: true do %> <%= icon 'far', 'star' %> <% end %>
app/views/boards/_unbookmark.html.erb
<%= link_to bookmark_path(current_user.bookmarks.find_by(board_id: board.id)), id: "js-bookmark-button-for-board-#{board.id}", class:"float-right", method: :delete, remote: true do %> <%= icon 'fas', 'star' %> <% end %>
ブックマークボタンの切り替え処理を追加
ブックマーク時と解除時に、ブックマークのアイコンを切り替える処理をjavascriptで実装する。
HTML形式のリクエストで送信していた時はcreateアクションやdestroyアクションでビューの作成はしていないのでリダイレクト処理でどのページに遷移するか指定していたが、今はremote: trueでJS形式のリクエストで送信しているのでアクション通過後に部分的に更新させるためのJSファイルを作成する。
app/views/bookmarks/create.js.erb
$("#js-bookmark-button-for-board-<%= @board.id %>").replaceWith("<%= j(render('boards/unbookmark', board: @board)) %>");
app/views/bookmarks/destroy.js.erb
$("#js-bookmark-button-for-board-<%= @board.id %>").replaceWith("<%= j(render('boards/bookmark', board: @board)) %>");
参考文献
ブックマーク機能(お気に入り登録)の追加
モデルの作成
rails g model Bookmark user:references board:references
referencesを使用することで、モデル間の関連付けであるbelongs_toを自動で追加してくれます。今回はUserモデルとBoardモデルにBookmarkモデルがbelongs_toで紐づいているという状況だ。
マイグレーションファイル
class CreateBookmarks < ActiveRecord::Migration[5.2] def change create_table :bookmarks do |t| t.references :user, foreign_key: true t.references :board, foreign_key: true t.timestamps end # bookmarksテーブルに置いてuser_idとboard_idの組み合わせを一意性のあるものしている。 add_index :bookmarks, [:user_id, :board_id], unique: true end end
bookmark.rb
class Bookmark < ApplicationRecord # belongs_toは対象カラムに対するpresence: trueは自動で設定されている。 belongs_to :user # 外部キー user_id belongs_to :board # 外部キー board_id # user_id と board_idの組み合わせを一意性のあるものにしている validates :user_id, uniqueness: { scope: :board_id } end
belongs_to オプションを設定した場合に、対象カラムに対する presence: true は自動で設定されるので不要となる。 アソシエーションもしくはscope/joinを使ってブックマークした掲示板の一覧を取得していること
user.rb
class User < ApplicationRecord # ここからが他のモデルとの関係性 has_many :boards, dependent: :destroy has_many :comments, dependent: :destroy has_many :bookmarks, dependent: :destroy # userのidを入れて、bookmarksメソッドを入れて、それぞれのboardを出す # 下記の記述はuser.bookmarks.map(&:board)これをしているのと一緒 has_many :bookmark_boards, through: :bookmarks, source: :board # 引数に渡されたものが、userのものであるか? def own?(object) id == object.user_id end # 引数に渡されたboardがブックマークされているか? def bookmark?(board) bookmark_boards.include?(board) end # board_idを入れてブックマークしてください def bookmark(board) # current_userがブックマークしているboardの配列にboardを入れる bookmark_boards << board end # 引数のboardのidをもつ、レコードを削除してください def unbookmark(board) bookmark_boards.destroy(board) end end
board.rb
class Board < ApplicationRecord belongs_to :user has_many :bookmarks, dependent: :destroy end
ルーティング設定
resources :boards do resources :comments, only: %i[create] # /boards/bookmarksのURLを作っている。このURLのブックマークの一覧を表示する。 collection do get :bookmarks end end # ブックマークのcreateアクションとdestroyアクション resources :bookmarks, only: %i[create destroy]
コントローラーの作成
class BookmarksController < ApplicationController def create board = Board.find(params[:board_id]) current_user.bookmark(board) redirect_back fallback_location: root_path, success: t('defaults.message.bookmark') end def destroy board = current_user.bookmarks.find(params[:id]).board current_user.unbookmark(board) redirect_back fallback_location: root_path, success: t('defaults.message.unbookmark') end end
def bookmarks @bookmark_boards = current_user.bookmark_boards.includes(:user).order(created_at: :desc) end
無駄にSQLを発行させるN + 1 問題を防ぐために、includes(:user)として、関連するユーザー情報も取得する。
Viewの作成
<% if current_user.bookmark?(board) %> <%# bookmarkしていれば、ブックマークしているボタンのとこへ %> <%= render 'unbookmark', board: board %> <% else %> <%# bookmarkしていなければ、ブックマークしていないボタンのとこへ %> <%= render 'bookmark', board: board %> <% end %>
ブックマークしていない場合
<%= link_to bookmarks_path(board_id: board.id), id: "js-bookmark-button-for-board-#{board.id}", class:"float-right", method: :post do %> <%= icon 'far', 'star' %> <% end %>
ブックマークされていないので、表示は☆ボタンを表示しています。link_toで囲んでbookmarksコントローラーのcreateアクションに遷移するpathが記述されています。つまり、☆ボタンを押すとbookmarksコントローラのcreateアクション動きます。
ブックマークしている場合
<%= link_to bookmark_path(current_user.bookmarks.find_by(board_id: board.id)), id: "js-bookmark-button-for-board-#{board.id}", class:"float-right", method: :delete do %> <%= icon 'fas', 'star' %> <% end %>
ブックマークしていない場合と逆になり、色あり星ボタンを表示し、destroyコントローラが動くpathが記載されている。
<% if current_user.bookmark?(board) %> <%= render 'unbookmark', { board: board } %> <% else %> <%= render 'bookmark', { board: board } %> <% end %>
参考文献
掲示板の編集・削除機能の実装
ルーティングの設定
resources :boards do resources :comments, only: %i[create], shallow: true end
コントローラの設定
app/controllers/boards_controller.rb
def edit; end def update if @board.update(board_params) redirect_to @board, success: t('defaults.message.updated', item: Board.model_name.human) else flash.now['danger'] = t('defaults.message.not_updated', item: Board.model_name.human) render :edit end end def destroy @board.destroy! redirect_to boards_path, success: t('defaults.message.deleted', item: Board.model_name.human) end private def find_board @board = current_user.boards.find(params[:id]) end # このメソッドはset_boardという命名でも良い。
@board.destroy!を使っている理由は、削除処理は「必ず成功するもの」だからだ。 また。save save! は、処理が失敗したときの挙動が違います。 前者はfalse を返し、後者は例外を返します。 例えば、掲示板作成でタイトルを入力漏れし、falseが帰ってきたら、エラー表示箇所を訂正してまた作成を試みます。掲示板作成は「失敗する可能性がある」処理です。 一方、削除の処理は失敗する余地がない処理です。この処理が失敗したときは、意図的に処理を止めてデバッグが必要になります。
ビューを作成
app/views/boards/_board.html.erb
<%= render 'crud_menus', board: board if current_user.own?(board) %>
app/views/boards/_crud_menus.html.erb
<ul class='crud-menu-btn list-inline float-right'> <li class="list-inline-item"> <%= link_to edit_board_path(board), id: "button-edit-#{board.id}" do %> <%= icon 'fa', 'pen' %> <% end %> </li> <li class="list-inline-item"> <%= link_to board_path(board), id: "button-delete-#{board.id}", method: :delete, data: { confirm: t('defaults.message.delete_confirm') } do %> <%= icon 'fas', 'trash' %> <% end %> </li> </ul>
app/views/boards/show.html.erb
<%= render 'crud_menus', board: @board if current_user.own?(@board) %>
英語のように読み解くと、 crud_menusパーシャルを呼び出しパーシャル内のboardに@boardを代入する。 もし、current_userのidが@boardのuser_idと一致していたら
current_userと掲示板作った人が一致しているか確認している。
編集のリンクを編集ページのパスに変更、削除のリンクを削除確認アラートを表示させるように、部分テンプレートを変更する。
掲示板編集のビューを追加する app/views/boards/edit.html.erb
<% content_for(:title, @board.title) %> <div class="container"> <div class="row"> <div class="col-lg-8 offset-lg-2"> <h1><%= t('.title') %></h1> <%= render 'form', { board: @board } %> </div> </div> </div>
参考文献
タイトルを動的に出力
動的タイトル表示とは
ここで言うタイトルとはブラウザのタブの所に表示される文字のこと。
content_forメソッドとは
content_forメソッドは、Railsにデフォルトで用意されているもので、画面毎に異なる内容を呼び出したい場合に使う。
ヘルパーメソッドを追加する
app/helpers/application_helper.rb
module ApplicationHelper def page_title(page_title = '') base_title = 'サイト名' page_title.empty? ? base_title : page_title + ' | ' + base_title end end
application.html.erb
<title><%= page_title(yield :title ) %></title>
デフォルトのタイトルは、サイト名だけを表示するようにする。
各ページのタイトルを設定する
<% content_for(:title, t('.title')) %>
コメント機能
モデルの作成
userとboardに紐ずいたcommentモデルを作成します
bundle exec rails generate model comment body:text user:references board:references
db/migrate/YYYYMMDDhhmmss_create_comments.rb
class CreateComments < ActiveRecord::Migration[5.2] def change create_table :comments do |t| t.text :body, null: false t.references :user, foreign_key: true t.references :board, foreign_key: true t.timestamps end end end
モデルを作成する際にreferencesとすることでindexと外部キー制約(foregin_key: true)が自動で追加される。indexをuser_idとmicropost_idに追加することによってそれぞれに関連したコメントを探す際にデータを高速に調べられるようになり、外部キー制約がつくことによってuser_id(micropost_id)にはUserテーブルに存在するidのみデータベースレベルで保存するようになる。 また、コメントが空だとコメント機能の意味をなさないためnull:falseを追加する。 commentモデルに、bodyのバリデーションを追加
class Comment < ApplicationRecord belongs_to :user belongs_to :board validates :body, presence: true, length: { maximum: 65_535 } end
自動でbelongs_toで一対一の関連付けができている。User,Boardモデルにはそれぞれ手動で追加する必要がある。また、バリテーションも追加する。テーブル作成の時点でcontentにはnull:falseで空で保存させないようにしていますが空文字("")は保存できます。なのでpresence:trueを追加することによって空文字も拒否するようになる。また、バリデーションに基づいたエラーメッセージも保存されます。
Boardモデルにコメントとの関連を追加
掲示板とコメントが1対多の関係であることをモデルに追加する。
class Board < ApplicationRecord mount_uploader :board_image, BoardImageUploader belongs_to :user has_many :comments, dependent: :destroy validates :title, presence: true, length: { maximum: 255 } validates :body, presence: true, length: { maximum: 65_535 } end
ユーザーとコメントが1対多の関連であることをモデルに追加する。
class User < ApplicationRecord authenticates_with_sorcery! has_many :boards, dependent: :destroy has_many :comments, dependent: :destroy
Comenntsコントローラーの作成、ルーディングの設定
rails g controller comments
class CommentsController < ApplicationController def create comment = current_user.comments.build(comment_params) if comment.save redirect_to board_path(comment.board), success: t('defaults.message.created', item: Comment.model_name.human) else redirect_to board_path(comment.board), danger: t('defaults.message.not_created', item: Comment.model_name.human) end end private def comment_params params.require(:comment).permit(:body).merge(board_id: params[:board_id]) end end
ルーティングの設定
resources :boards, only: %i[index new create show] do resources :comments, only: %i[create], shallow: true end end
commetsはboardsとネストして親子の関係を持たせる。こうすることによってコメントを作成する際に関連しているmicropostのidを取得することが容易になる。 shallowオプションとは何か? ルーティングの記述を複雑にせず、かつ深いネストを作らないという絶妙なバランスを保つことのできるオプション
対応するビューの作成
app/controllers/boards_controller.rb
def show @board = Board.find(params[:id]) @comment = Comment.new @comments = @board.comments.includes(:user).order(created_at: :desc) end
コメントのビューを実装する 掲示板の編集と削除のボタンは、掲示板の一覧と詳細ページで同じものを表示するので、部分テンプレートとして作成する。 app/views/boards/_crud_menus.html.erb
<ul class='crud-menu-btn list-inline float-right'> <li class="list-inline-item"> <%= link_to '#', id: "button-edit-#{board.id}" do %> <%= icon 'fa', 'pen' %> <% end %> </li> <li class="list-inline-item"> <%= link_to '#', id: "button-delete-#{board.id}" do %> <%= icon 'fas', 'trash' %> <% end %> </li> </ul>
app/views/boards/_board.html.erb
<%= image_tag board.board_image_url, class: 'card-img-top', size: '300x200' %> <div class="card-body"> <h4 class="card-title"> <%= link_to board.title, board_path(board) %> </h4> <%= render 'crud_menus', board: board %> <ul class="list-inline"> <li class="list-inline-item"> <%= icon 'far', 'user' %>
app/views/boards/show.html.erb
<% content_for(:title, @board.title) %> <div class="container pt-5"> <div class="row mb-3"> <div class="col-lg-8 offset-lg-2"> <h1><%= t('.title') %></h1> <!-- 掲示板内容 --> <article class="card"> <div class="card-body"> <div class='row'> <div class='col-md-3'> <%= image_tag @board.board_image.url, class: 'card-img-top img-fluid', size: '300x200' %> </div> <div class='col-md-9'> <h3 class="d-inline"><%= @board.title %></h3> <%= render 'crud_menus', board: @board %> <ul class="list-inline"> <li class="list-inline-item">by <%= @board.user.decorate.full_name %></li> <li class="list-inline-item"><%= l @board.created_at, format: :long %></li> </ul> </div> </div> <p><%= simple_format(@board.body) %></p> </div> </article> </div> </div> <!-- コメントフォーム --> <%= render 'comments/form', { board: @board, comment: @comment } %> <!-- コメントエリア --> <%= render 'comments/comments', { comments: @comments } %> </div>
app/models/user.rb
def own?(object) id == object.user_id end
ユーザーのコメントであるかを判定するメソッドをuserモデルに追加する。
コメントフォームのテンプレートを作成する。 app/views/comments/_form.html.erb
<div class="row mb-3"> <div class="col-lg-8 offset-lg-2"> <%= form_with model: comment, url: [board, comment], local: true do |f| %> <%= render 'shared/error_messages', object: f.object %> <%= f.label :body %> <%= f.text_area :body, class: 'form-control mb-3', id: 'js-new-comment-body', row: 4, placeholder: Comment.human_attribute_name(:body) %> <%= f.submit t('defaults.post'), class: 'btn btn-primary' %> <% end %> </div> </div>
コメント一覧のテンプレートを作成する。 app/views/comments/_comments.html.erb
<div class="row"> <div class="col-lg-8 offset-lg-2"> <table id="js-table-comment" class="table"> <%= render comments %> </table> </div> </div>
app/views/comments/_comment.html.erb
<tr id="comment-<%= comment.id %>"> <td style="width: 60px"> <%= image_tag 'sample.jpg', class: 'rounded-circle', size: '50x50' %> </td> <td> <h3 class="small"><%= comment.user.decorate.full_name %></h3> <div id="js-comment-<%= comment.id %>"> <%= simple_format(comment.body) %> </div> <div id="js-textarea-comment-box-<%= comment.id %>" style="display: none;"> <textarea id="js-textarea-comment-<%= comment.id %>" class="form-control mb-1"><%= comment.body %></textarea> <button class="btn btn-light js-button-edit-comment-cancel" data-comment-id="<%= comment.id %>">キャンセル</button> <button class="btn btn-success js-button-comment-update" data-comment-id="<%= comment.id %>">更新</button> </div> </td> <% if current_user.own?(comment) %> <td class="action"> <ul class="list-inline justify-content-center" style="float: right;"> <li class="list-inline-item"> <a href="#" class='js-edit-comment-button' data-comment-id="<%= comment.id %>"> <%= icon 'fa', 'pen' %> </a> </li> <li class="list-inline-item"> <a href="#" class='js-delete-comment-button' data-comment-id="<%= comment.id %>"> <%= icon 'fas', 'trash' %> </a> </li> </ul> </td> <% end %> </tr>
参考文献
掲示板の画像アップロード機能
確認ポイント
・gemのインストール
gem 'carrierwave' gem 'mini_magick'
CarrierWaveとは ファイルのアップロード機能を簡単に追加する事が出来るgem
Minimagickとは 画像に対して、画像同士を合成したり、リサイズしたりと編集することができるようになるためのgem
その後
bundle install
・Boardモデルのboardsテーブルにboard_imageカラムを追加する。
$ bundle exec rails g migration AddBoardImageToBoards board_image:string $ bundle exec rails db:migrate
マイグレーションファイル
class AddBoardImageToBoards < ActiveRecord::Migration[5.2] def change add_column :boards, :board_image, :string end end
画像を選択せずに掲示板を作成できるようにしたいので、マイグレーションファイルはそのままでNOTNULL制約はつけない
・画像uploaderの作成 次にcarrierwaveを利用するためのアップローダーを作成する。carrierwaveを導入したことで以下のコマンドが利用できるようになったので、ターミナルで実行する。
$ rails g uploader image ※imageの箇所は任意の名前でOKです。例:pictureなど $ bundle exec rails g uploader BoardImage #今回の場合
生成されたアップローダーに、デフォルトの画像ファイルと、アップロード可能なファイル種別を指定する。
class BoardImageUploader < CarrierWave::Uploader::Base def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end def default_url 'board_placeholder.png' end def extension_whitelist %w(jpg jpeg gif png) end end
アップローダーが作成できたので、画像アップロード機能を実装したいモデルに対して編集を行う。
mount_uploader :image, ImageUploader mount_uploader :board_image, BoardImageUploader #今回の場合
上記を追記することで、アップローダーを任意のモデルに対して マウントすることができました。 ※マウント 取り付ける、搭載するなどの意味
アップロード先のフォルダを、.gitignoreに登録する
/public/uploads
・ControllerとViewに、画像ファイルのフィールドを追加する app/controllers/boards_controller.rb
def board_params params.require(:board).permit(:title, :body, :board_image, :board_image_cache) end
掲示板のフォームに、画像ファイルの入力フィールドを追加 app/views/boards/_form.html.erb
<div class="form-group"> <%= f.label :board_image %> <%= f.file_field :board_image, class: 'form-control mb-3', accept: 'image/*' %> <%= f.hidden_field :board_image_cache %> </div> <div class='mt-3 mb-3'> <%= image_tag board.board_image.url, id: 'preview', size: '300x200' %> </div>
<%= image_tag board.board_image.url, id: 'preview', size: '300x200' %> プレビューを表示している。boardモデルのboard_imageのurlを呼び出し、表示する。
<%= f.hidden_field :board_image_cache %> これは、掲示板作成できなかった場合、アップロードした画像を消えないようにする処理する。
・JSファイルを設定する
javascriptの記載は、application.jsには記載せず、別の専用ファイルを作成する。 ここでは、application.jsは個別のJavaScriptを読み込む専用のファイルのため、preview.jsファイルを作成し記載する。
function previewFileWithId(id) { const target = this.event.target; const file = target.files[0]; const reader = new FileReader(); reader.onloadend = function () { preview.src = reader.result; } if (file) { reader.readAsDataURL(file); } else { preview.src = ''; } }
const target = this.event.target; traget・・・操作を差し込む対象のオブジェクト event.target・・・最初にイベントが起こった要素です。今回だと、ファイルを選択した時のイベントを示す。
const file = target.files[0]; targetには「files」というプロパティが用意されています。 管理されているファイルは、「File」というオブジェクトの形をしています。 このFileオブジェクトには、ファイルに関する各種の情報や、ファイルアクセスのためのメソッドなどがまとめられている。 files[0]で一つ目のFileオブジェクトを取り出している
reader.onloadend = function () { preview.src = reader.result; } onloadend、FileReaderのイベント。データの読み込みが成功か失敗に関わらず終了した時にloadendイベントが発生し、ここに設定したコールバック関数が呼び出される。 そして、取得されたファイルの結果を、previewのsrc属性に指定している。
if (file) { reader.readAsDataURL(file); } else { preview.src = ''; } readAsDataURL()は、FileReaderのメソッドです。ファイルを、Data URIとして読み込むメソッド。例えば画像ファイルをこのメソッドで読み込んで、読み込んだデータをimg要素のsrc属性に指定すればブラウザに表示できる。 前文でscr属性を指定したので、ここで、画像を表示させる。
参考文献
フォーム入力時エラー情報を個別表示
確認ポイント
・エラーメッセージ表示部分をformのテンプレートに記載せず、専用のパーシャルが作られていること。 shared/_error_messages.html.erb
<% if object.errors.any? %> <div class="alert alert-danger"> <ul class="mb-0"> <% object.errors.full_messages.each do |msg| %> <li><%= msg %></li> <% end %> </ul> </div> <% end %>
・次にパーシャル 呼び出し app/views/boards/_form.html.erb
<%= form_with model: board, local: true do |f| %> <%= render 'shared/error_messages', object: f.object %> #重要 <div class="form-group"> <%= f.label :title %>
<%= render 'shared/error_messages', object: f.object %> この部分がエラーメッッセージに該当する部分です。 第二引数のmodel: f.objectはパーシャルに対して変数を渡しています。 modelという変数名で、f.objectという値を渡しています。 こうすることでパーシャルの汎用性が増える。
参考文献