動的タイトル表示の実装
ヘルパーを使ってタイトルを動的に表示させる。
ここでいうタイトルとはブラウザのタブに表示される文字のこと↓
実装内容
今回はヘルパーを使い、以下の各ページごとに動的にタイトルを表示させる。
content_forを使い、「ページごとのタイトル名 | タイトル名」のように表示させる。
トップページは「タイトル名」のみ表示。
実装手順
app/helpers/application_helper.rb
にメソッドを作成する。- 各ページのviewファイルへ設定を読み込ませる。
ヘルパーの作成。
empty?
メソッドを使い、真偽判定をする。
変数の値が""(文字列の場合)や値が空白の場合、真となる。
条件演算子(?:)
条件式 ? 真の時の値 : 偽の時の値
で記述。
page_titleの引数に''
を入れて例外が発生するのを防ぐ。
module ApplicationHelper def page_title(page_title = '') base_title = 'SAMPLE APP' page_title.empty? ? base_title : page_title + ' | ' + base_title end end
webサイト全体のviewへ適応する。
i18n翻訳ファイルの編集。
ja: defaults: login: 'ログイン' register: '登録' title: 'ユーザー登録' to_login_page: 'ログインページへ' user_sessions: new: Back: '戻る' title: 'ログイン' email: 'メールアドレス' password: 'パスワード' to_register_page: '登録ページへ' users: new: title: 'ユーザー登録' boards: title: '掲示板作成' index: '掲示板一覧'
ヘルパーで作成したメソッドを読み込む。
yield
メソッド
ビューを挿入すべき場所を指定。
〜 <title><%= page_title(yield(:title)) %></title> 〜
これでトップページにbase_titleが適応される。
各viewファイルに読み込ませていく
content_for
メソッド
コンテンツを名前付きのyieldとしてレンダリング
<%= content_for(:title, t('.title')) %> <%= content_for(:title, t('boards.title')) %> <%= content_for(:title, t('boards.index')) %>
掲示板詳細画面は閲覧中の掲示板名を表示させる。
<% content_for(:title, @board.title) %>
と記述。
Rails 掲示板にコメント機能を追加 part2
part1からの続き。
掲示板の詳細画面を実装したので、そこにコメント機能を実装していく。
投稿詳細画面へコメントの入力フォーム、エリアを追加する。
# Controllerへ追記。
boards_controllerにインスタンス変数@commentと@commentsを定義し、view側で呼び出す。
class BoardsController < ApplicationController skip_before_action :require_login, only: %i[index create show] def show if logged_in? @board = Board.find(params[:id]) @comment = Comment.new @comments = @board.comments.includes(:user).order(created_at: :desc) else redirect_back_or_to login_path, danger: 'ログインしてください' end end
logged_in?
メソッドを使い、ログインしていない場合 ログインページにリダイレクトされるようにした。
ここは単純にskip_before_action :require_login, only: %i[index create]
で良かった。
コメントを習得する際はN+1問題に注意し、includes
メソッドを使用しユーザーを呼び出す。
created_at: :desc
で作成された順に降順で表示。
コメントフォームのview
パーシャルで作成する。
ルーティングをネスト(board/board_id/comment)している場合はurlを2つ指定する。
<!-- コメントフォーム --> <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', rows: 4, placeholder: 'コメント' %> <%= f.submit class: 'btn btn-primary', value: '投稿' %> <% end %> </div> </div>
コメントエリアのview
こちらもパーシャルで作成。
コメントを作成したユーザーにだけアイコンを表示。
<!-- コメントエリア --> <%= comment.user.decorate.full_name %> <%= simple_format(comment.body) %> #入力値の改行に対応する 〜 <% 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 %> 〜
- コメントエリア
_comment.html.erb
にbootstrapを適応。
render comments
でよしなに_comment.html.erb
を呼び出している。
render @commnets
でも同様。
<div class="row"> <div class="col-lg-8 offset-lg-2"> <table id="js-table-comment" class="table"> <%= render comments %> </table> </div> </div>
掲示板一覧画面と掲示板詳細画面において、コメントを作成したユーザーにのみ編集、削除ボタンを表示させる。
- モデル側にユーザーのコメントであるか判定するメソッドを定義。
def own?(object) id == object.user_id end
<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>
掲示板詳細画面show.html.erb
で
- 編集削除ボタン
- コメントフォーム
- コメントエリア
を読み込む。
フォームは、comments/_form.html.erb
をレンダーし、@boardと@commentを引数として渡している。
エリアは、comments /_comments.html.erb
をレンダーし、@commentsをcommentsという変数で使えるようにしている。
〜 <!-- 編集、削除ボタン--> <%= render 'crud_menus', board: @board %> <!-- コメントフォーム --> <%= render 'comments/form',{board: @board, comment: @comment} %> <!-- コメントエリア --> <%= render "comments/comments", {comments: @comments} %> 〜
- 掲示板一覧画面に編集、削除ボタンを読み込む。
<%= render 'crud_menus', board: board %>
comments_controllerの作成。
$ rails g controller comments
userとcommentでアソシエーションが定義されてるのでその点に注意する。
class CommentsController < ApplicationController def create comment = current_user.comments.build(comment_params) if comment.save redirect_to board_path(comment.board), success: 'コメントを作成しました' else redirect_to board_path(comment.board), danger: 'コメントを作成できませんでした' end end end
ログインユーザーに関連したコメントを表示させる。
current_user
メソッドでログイン中のユーザーを返す。
アソシエーションで定義されたオブジェクトを初期化する際はnewではなくbuildを使う。
comment = current_user.comments.build(comment_params)
ストロングパラメータの追加。
コメントをデータベースへ保存するにはuser_idとboard_idが必要でcommentの値(body)とboard_idが保存される。
(board_id: params[:board_id])
はURLであるboards/:board_id/comments
からboard_idをとってきている。
merge
はハッシュを合体させるメソッド。
private def comment_params params.require(:comment).permit(:body).merge(board_id: params[:board_id]) end
Rails 掲示板にコメント機能を追加 part1
概要
- 掲示板詳細画面を追加し、その中でコメントをできるようにする。
- 掲示板一覧画面から掲示板のタイトル名を押すと掲示板詳細画面へ行けるようにする。
- 書き込んだコメントが一番上に表示されるようにコメントの並び順を指定
- コメントした本人のみに削除・編集ボタンを表示させる
- コメントを投稿したユーザーが削除されると、ユーザーが投稿したコメントも削除される
- コメントが投稿された掲示板が削除されると、掲示板に投稿されたコメントも削除される
モデルの作成
Commentモデルを作成し、User、Boardモデルとアソシエーションを定義する。
Commentモデルに外部キー(user_id, board_id)を設定。
Commentモデルにバリデーションを設定。
今回はreferences
でモデルと同時に作成したのでCommentモデル側のアソシエーションは自動で定義される。
(CommentをUserとBoardに関連付けたいので、User、Boardにhas_many、Commentにbelong_toを定義)
$ rails g model Comment body:text user:references board:references invoke active_record create db/migrate/20210522131533_create_comments.rb create app/models/comment.rb
空のデータは登録できないようにNOTNULL制約を追加。
外部キーが記述されていることを確認する。
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
ここまで確認できたらrails db:migrate
を実行。
== 20210522131533 CreateComments: migrating =================================== -- create_table(:comments) -> 0.0036s == 20210522131533 CreateComments: migrated (0.0040s) ==========================
Commnetモデルにバリデーションの追加。
空の投稿を防ぐ為、presence: true
をつける。
class Comment < ApplicationRecord belongs_to :user belongs_to :board validates :body, presence: true, length: { maximum: 10_000 } end
User、Boardモデル側に以下のアソシエーションを定義
has_many :comments, dependent: :destroy
dependent: :destroyオプションを定義しないと、親モデル(user board)を削除した時に、その親モデルに紐づく子モデル(comment)の内容が残ってしまうので注意する。
掲示板詳細画面の作成
ルーティングの作成
今回は、BoardモデルとCommentモデルが紐付いてるので、ルーティングをネスト(入れ子)する必要がある。
ネストする場合はshallow
オプションを使用する。
shallow
オプションを使用するとルーティングが簡潔になる。
resources :boards, only: %i[index new create show] do resources :comments, only: %i[create destroy], shallow: true end
使用前
comments#createはcommentのidではなくboard_idを指定する。
Prefix Verb URI Pattern Controller#Action board_comments POST /boards/:board_id/comments(.:format) comments#create board_comment DELETE /boards/:board_id/comments/:id(.:format) comments#destroy
使用後
comments#destroyはcommentのidを指定するのでboard_idは指定しない。
Prefix Verb URI Pattern Controller#Action board_comments POST /boards/:board_id/comments(.:format) comments#create comment DELETE /comments/:id(.:format) comments#destroy
controllerの作成
@board = Board.find(params[:id])
でリクエストパラメータ(params)経由でidを受け取りBoardオブジェクトをデータベースから取得。
findはidによってそのモデルに対応するレコードをデータベースから検索する。
params[:id]にはURL “localhost:3000/boards/[id]”の[id]の部分が格納されてる。
class BoardsController < ApplicationController skip_before_action :require_login, only: %i[index create show] #... def show @board = Board.find(params[:id]) #...
viewの作成
boards/show.html.erb
を作成。
#... <%= image_tag @board.board_image.url, class: 'card-img-top img-fluid', size: '300x200' %> <%= @board.title %> #掲示板名の表示 <%= @board.user.decorate.full_name %> #user_decorator.rbのfull_nameメソッドを呼び出し、ユーザー名を表示する。 <%= l @board.created_at, format: :long %> #...
l
メソッドについて
l
メソッドはi18nにおいて日付や時刻を表す際に使用する。
config/locales/ja.ymlに書かれた内容を読み込む。
format
オプションを使うことで複数の書式を使い分けることができる。
ja: time: formats: default: "%Y年%m月%d日(%a) %H時%M分%S秒 %z" # 2021年05月23日(日) 02時10分39秒 +0900 long: "%Y/%m/%d %H:%M" # 2021/05/23 02:10 short: "%m/%d %H:%M" # 05/23 02:10
掲示板一覧画面から掲示板のタイトル名を押すと掲示板詳細画面へ行けるようにする。
掲示板一覧画面に詳細画面へのリンクを追加。
今回のboard_pathはidが指定されているのでlink_to prefix_path(渡すid)
と記述。
<%= link_to board.title, board_path(board.id) %>
part2へ続く。
掲示板に画像アップロード機能を実装
実装内容
- 掲示板作成画面から画像を投稿する。
- gem CarrierWaveとMiniMagickを利用。
- サムネイルというラベルを追加し、画像の選択をする。
- 画像を選択せずに投稿した場合、デフォルトの画像を表示。
- アップロードできるファイルはjpg, jpeg, png, gifのみに制限。
- 画像投稿時にプレビューとして表示。
導入
CarrierWaveはRailsアプリケーションに画像アップロード機能をつけることができる。
MiniMagickはアップロードされた画像のリサイズなどができる。
MiniMagickを使用するには、ImageMagickが必要なので事前にインストールする。
$ brew install imagemagick
gem 'carrierwave' gem 'mini_magick'
Boardモデルのboardsテーブルにboard_imageカラムを追加する。
board_imageカラムには画像情報(ファイルそのものではない)が格納される。
$ rails g migration AddBoardImageToBoards board_image:string Running via Spring preloader in process 10390 invoke active_record create db/migrate/20210520113137_add_board_image_to_boards.rb
画像を選択せずに掲示板を作成できるようにしたいので、マイグレーションファイルはそのままでNOTNULL制約はつけない
class AddBoardImageToBoards < ActiveRecord::Migration[5.2] def change add_column :boards, :board_image, :string end end
$ rails db:migrate == 20210520113137 AddBoardImageToBoards: migrating ============================ -- add_column(:boards, :board_image, :string) -> 0.0013s == 20210520113137 AddBoardImageToBoards: migrated (0.0016s) ===================
画像uploaderの作成
ジェネレータでuploaderを生成することから始める。
$ rails g uploader image(アップローダー名)
これにより次のファイルが生成される。
ここには画像のリサイズなど、設定を記述する。
create app/uploaders/image_uploader.rb
uploaderとモデルを紐付ける。
今回はBoardモデルと紐付ける。
class Board < ApplicationRecord mount_uploader :board_image, ImageUploader
image_uploader.rbの編集
このファイルはデフォルトでコードが記述されており、コメントアウトを解除して使用する。 MiniMagickでリサイズするので記述。
class ImageUploader < CarrierWave::Uploader::Base include CarrierWave::MiniMagick
デフォルトの設定
storage :file
public/uploads/
に画像が保存される。def store_dir
画像が保存される先のpathを指定。
storage :file def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end
追加設定
def default_url 'board_placeholder.png' # app/assets/images/board_placeholder.png end def extension_allowlist %w(jpg jpeg gif png) end
viewの作成
掲示板作成フォームを編集する。
画像投稿時にプレビューとして表示するには、画像はまだデータベースへ登録されていないので、選択した画像はjavascriptを使って表示させる必要がある。
#... <div class="form-group"> <%= f.label :board_image %> <%= f.file_field :board_image, onchange: 'previewFile(preview)', 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> #...
onchange
イベントハンドラでjavascriptを指定フォームの入力値、選択が変更されたときに処理を行う。
accept
でファイルを指定する。image/は画像ファイル、video/*は動画ファイル。
<%= f.hidden_field :board_image_cache %>
これは、掲示板作成できなかった場合、アップロードした画像を消えないようにする処理。
javascriptでプレビューを表示させる。
jsファイルの作成。
application.jsは個別のjavascriptを設定するファイルなので、ここには記述してはいけない。
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 = ''; } }
掲示板一覧画面へアップロードした画像を表示させる。
board.rb
のboard_imageからurlをとってくる。
<%= image_tag board.board_image.url, class: 'card-img-top', size: '300x200' %>
ストロングパラメータに画像のフィールドを追加。
privatedef board_params params.require(:board).permit(:title, :body, :board_image, :board_image_cache) end end
ローカルでアップロードした画像をリモートへアップロードしないように設定。
.gitignoreに画像のアップロード先を追加する。
# Ignore vendor /public/uploads
rmコマンドでリモートリポジトリから削除する。
$ git rm -r --cached public/uploads
# public/uploadsディレクトリ下のファイルをすべて削除
ログイン中のユーザーで掲示板の新規作成をする
UserモデルとBoardモデルにアソシエーションを設定
Userと関連付いたboardを作成するので、has_manyをUserモデルに定義する。
class User < ApplicationRecord authenticates_with_sorcery! validates :password, length: { minimum: 3 }, if: -> { new_record? || changes[:crypted_password] } validates :password, confirmation: true, if: -> { new_record? || changes[:crypted_password] } #... has_many :boards, dependent: :destroy end
controller
UserモデルとBoardモデルにアソシエーションが定義されている場合の記述。
以下のように記述した場合、登録に成功しない。
class BoardsController < ApplicationController skip_before_action :require_login, only: %i[index create] def create @board = Board.new(board_params) ←ここ if @board.save redirect_back_or_to boards_path, info: '掲示板を作成しました' else flash.now[:danger] = '掲示板を作成できませんでした' render 'new' end end private def board_params params.require(:board).permit(:title, :body) end end
原因
user_idカラム作成時にNOT NULL制約をつけているので、上記コードだとuser_idがnilになってしまう。
ログインユーザーに関連した掲示板のオブジェクトを作成するには
@board = current_user.boards.build(board_params)
current_user.boards.newとすることで、user_idを登録したboardオブジェクトを初期化できる。引数としてパラメータ(board_params)を渡すことでフォームに入力されたオブジェクトを作成することができる。アソシエーションで定義されたオブジェクトを初期化する際はnewではなくbuildを使う。(どちらを記述しても挙動は変わらない)
また、以下のようにmergeを使ってuser_idを用意することもできるが、
Board.new(board_params.merge(user_id: current_user.id))
アソシエーション関連であるオブジェクトは強調したほうが良いので、モデルのアソシエーションに関連性のない情報を登録する時にmergeは使うと良い。
view
ビューはパーシャルを使い表示させる。
<%= form_with model:board, local: true do |f| %> <%= render 'shared/error_messages', object: f.object %> <div class="form-group"> <%= f.label :title %> <%= f.text_field :title, class: "form-control" %> </div> <div class="form-group"> <%=f.label :body %> <%=f.text_area :body, class: "form-control", low: "10", style: "height: 200px" %> </div> <%= f.submit "登録する", class: "btn btn-primary" %> <% end %> </div> </div> </div>
パーシャルをnew.htmlで読み込む。
<div class="container"> <div class="row"> <div class="col-lg-8 offset-lg-2"> <h1>掲示板作成</h1> <%= render 'form', { board: @board } %> </div> </div> </div>
Rails 掲示板一覧の作成
モデルの作成
- Boardモデルの作成しtitle,bodyカラムを追加する。
- Boardモデルにuser_idを外部キーとして設定する。
紐づくモデル名+「_id」で外部キーと呼ぶ。
reference型を使いモデルと同時に作成。
$ rails g model board title:string body:text user:references invoke active_record create db/migrate/20210506055856_create_boards.rb create app/models/board.rb
マイグレーションファイルとモデルファイルができる。
UserモデルとBoardモデルにアソシエーションを設定し、バリデーションを追加。
class Board < ApplicationRecord validates :title, presence: true, length: { maximum: 200 } validates :body, presence: true, length: { maximum: 60_000 } belongs_to :user end
dependent: :destroyオプション
has_manyにdependent: :destroyを追加すると、親モデル(user)を削除した時に、その親モデルに紐づく子モデル(board)も同時に削除されるようになる。これを定義しないとUserを削除したときに、boardに投稿した内容が残ってしまうので必須である。
class User < ApplicationRecord authenticates_with_sorcery! validates :password, length: { minimum: 3 }, if: -> #... has_many :boards, dependent: :destroy end
データベースに未入力データの登録を排除するための NOT NULL 制約を追加。
class CreateBoards < ActiveRecord::Migration[5.2] def change create_table :boards do |t| t.string :title, null: false t.text :body, null: false t.references :user, foreign_key: true t.timestamps end end end
マイグレーションを実行。
$ rails db:migrate
gem Fakerで作成したダミーデータをデータベースへ投入する。
$ rails db:seed
掲示板一覧画面の作成
ルーティングを定義
Rails.application.routes.draw do root to: 'users#index' get '/login', to: 'user_sessions#new' post '/login', to: 'user_sessions#create' post '/logout', to: 'user_sessions#destroy' resources :users resources :boards end
コントローラを作成しindexアクションを定義
$ rails g controller boards create app/controllers/boards_controller.rb invoke erb create app/views/boards invoke decorator create app/decorators/board_decorator.rb
includesメソッドとN+1問題
N+1問題とは、データベースからデータを取得する際、必要以上にSQLが発行されてしまいパフォーマンスが低下してしまう問題である。アソシエーションが定義されている場合に発生する。
@boards = Board.allと定義した場合、掲示板一覧ページ遷移時(1回)にユーザーとその掲示板の数(N回)だけSQLが発行されてしまう。
以下のようになる
対策
includesメソッドで関連するテーブルをまとめて取得。
orderメソッドは昇順 ASC、降順 DESCの並び替えができる。
created_atは、作成された日時なので 降順で新しい投稿が上にくるようにできる。
descはdescendingの略で降順という意味。
class BoardsController < ApplicationController skip_before_action :require_login, only: %i[index create] def index if logged_in? @boards = Board.all.includes(:user).order(created_at: :desc) else redirect_to login_path, danger: 'ログインしてください' end end
viewの作成。
パーシャルを使用し掲示板一覧画面を表示させる。
1.eachを使う場合
<%= @boards .each do |board| %> <div class="col-sm-12 col-lg-4 mb-3"> <div id="board-id-<%= board.id %>"> <div class="card"> <%= image_tag 'board_placeholder.png', class: 'card-img-top', size: '300x200' %> #... <% end %>
パーシャルをindex.htmlで読み込む。
部分テンプレート内のboardという変数に@boardが代入される。localsオプションが省略されてる。
<%= render partial: 'board', locals: {board: @board} %>
*localsオプションを使用した場合、partialは省略できない。
#... <% if @boards.present? %> <%= render 'board', {board: @board} %> <%else%> <p><%= '掲示板がありません' %></p> <%end%>
2.eachを使わない場合
<div class="col-sm-12 col-lg-4 mb-3"> <div id="board-id-<%= board.id %>"> <div class="card"> <%= image_tag 'board_placeholder.png', class: 'card-img-top', size: '300x200' %> #...Railsでは、@boardsという変数からよしなに「_board.html.erb」を探してくれる。
@boardsにrenderメソッドを使うことで「_board.html.erb」をboardの数だけ繰り返し表示することができる。<%= render @boards %>は、collectionオプションが省略されている。
<%= render partial: 'board', collection: @boards %>
省略できる条件
- 呼び出す部分テンプレートがviewフォルダ内のboardsフォルダに存在する
- 部分テンプレート名が_board.html.erbである
- 部分テンプレート内で使う変数がboardである
#... <% if @boards.present? %> <%= render @boards %> <%else%> <p><%= '掲示板がありません' %></p> <%end%><%= render @boards %>
このコードは以下の処理とまったく同じである。
<%= render partial: 'board', collection: @boards %> <!--下のコードと全く同じ --> <% @boards.each do |board| %> <%= render partial: 'board', board: board %> <% end %>
タイムゾーンを日本時間にする。
config.time_zone = 'Tokyo' config.active_record.default_timezone = :local
Fakerを利用しダミーデータを作成
掲示板の一覧にダミーデータを投入する。
gem Fakerのインストール。
Fakerはdevelopmentとtest環境にインストールする。
group :development, :test do #... gem 'faker' #... end
bundle installで完了。
データベース上にダミーデータを生成
今回はBoardモデルのダミーデータを20個作る。 データは公式のREADMEからとってくる。
20.times do title = Faker::Games::Pokemon.name body = Faker::Games::Pokemon.move user = User.offset(rand(User.count)).first Board.create!( title: title, body: body, user: user ) end
seedに書いた内容をデータベースへ反映。
$ rails db:seed
データが作成されたか確認。
なぜcreate!を使うのか。
createとcreate!の違い
createの場合はユーザーが無効なときfalseを返す。一方、create!は例外を発生させる(例外が発生した時点で処理が止まる)。よって、デバッグが安易になるのでcreate!を使用したほうがいい。
user = User.offset(rand(User.count)).first
について
- モデル.offset(取得開始位置) 指定位置からレコードを取得。
- rand 引数に整数を渡したら0以上指定した整数未満の整数を返す。
- first 最初のレコードを取得。
20個のダミーデータから0〜19個間でランダムに数字を生成して、最初のレコードを取得している。 User.offset(18).firstなら18以降から最初のUserを取得する。