Ryota400’s blog

エンジニアを目指して書いてます。

ブックマークボタンの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)) %>");

参考文献

qiita.com

qiita.com

ブックマーク機能(お気に入り登録)の追加

モデルの作成

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 %>

参考文献

qiita.com

qiita.com

qiita.com

study-diary.hatenadiary.jp

掲示板の編集・削除機能の実装

ルーティングの設定

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>

参考文献

qiita.com

タイトルを動的に出力

動的タイトル表示とは

ここで言うタイトルとはブラウザのタブの所に表示される文字のこと。 f:id:Ryota400:20210911111913p:plain

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>

参考文献

qiita.com

qiita.com

掲示板の画像アップロード機能

確認ポイント

・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属性を指定したので、ここで、画像を表示させる。

参考文献

qiita.com

qiita.com

フォーム入力時エラー情報を個別表示

確認ポイント

・エラーメッセージ表示部分を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という値を渡しています。 こうすることでパーシャルの汎用性が増える。

参考文献

qiita.com

qiita.com

qiita.com