[Rails]ブックマーク 13/20
はじめに
投稿にブックマーク機能を追加していきます。
要件:
- ☆ボタンを押すと、投稿をブックマークに登録 / 解除できること
- ブックマークした一覧を表示できること
- 自分が作成した投稿にはブックマークボタンが表示されないこと
- ユーザーが同じ投稿に対するブックマークが1つのみとなること
- ブックマークの追加 / 解除後 は元の画面にリダイレクトされること
- ブックマーク時・・・・・「ブックマークしました」
- ブックマーク解除時・・・「ブックマークを外しました」
- ブックマークした投稿が存在しない場合「ブックマーク中の投稿がありません」と表示されること
環境:
Rails 6.1.7.3
ruby 3.0.0
Bookmarkモデルを作成する
今回のように「どのユーザー」が「どの投稿」をブックマークしたかを記録する為に、データベースにuser_idとpost_idの2つのカラムを持つbookmark`テーブルを作成します。
bin/rails generate model bookmarks user:references article:references
ユーザーが同じ掲示板に対するブックマークが1つしかないとなることはuser_idとpost_id`の組み合わせが一意性を持つことになります。
class CreateBookmarks < ActiveRecord::Migration[6.1]
def change
create_table :bookmarks do |t|
t.references :user, null: false, foreign_key: true
t.references :article, null: false, foreign_key: true
t.timestamps
end
add_index :bookmarks, [:user_id, :article_id], unique: :true
end
end
add_indexメソッド: マイグレーションファイル内で使用されるActive Recordのメソッドです。このメソッドを使用することで、テーブルにインデックスを追加できます。
[:user_id, :board_id]: インデックスのカラム(列)の配列です。この場合、user_idとboard_idの2つのカラムを組み合わせてインデックスを作成します。
bookmarksテーブルに対して、user_idとboard_idの組み合わせが一意であることを保証するためのインデックスを追加します。これにより、特定のユーザーと投稿の組み合わせに対して重複するブックマークの登録を防ぐことができます。
bin/rails db:migrate
Running via Spring preloader in process 7149
== 20230701090942 CreateBookmarks: migrating ==================================
-- create_table(:bookmarks)
-> 0.0060s
-- add_index(:bookmarks, [:user_id, :article_id], {:unique=>:true})
-> 0.0006s
== 20230701090942 CreateBookmarks: migrated (0.0068s) =========================
Bookmarkモデルにバリデーションを追加する
class Bookmark < ApplicationRecord
belongs_to :user
belongs_to :article
validates_uniqueness_of :user_id, scope: :article_id
end
一つの投稿に対して、ブックマークするユーザーに一意性を持たせるためscopeオプションをつけます。
scopeをつけないと、テーブル全体で一意な数字という意味になってしまいます。
ArticleとUserモデルとのアソシエーションを設定する
class User < ApplicationRecord
has_many :bookmarks
has_many :bookmark_articles, through: :bookmarks, source: :article
end
class Article < ApplicationRecord
has_many :bookmarks, dependent: :destroy
end
has_manyを使ってUserモデルが多数の`bookmark_articles を持っていることを設定しています。
:sourceオプションは、has_many :through関連付けにおける「ソースの」関連付け名、つまり関連付け元の名前を指定します。このオプションは、関連付け名から関連付け元の名前が一致しない場合だけ使います。
この関連付けによって、sqlを発行する時にbookmarkテーブルをジョインせずユーザーがブックマークした投稿を取得することができます。
user = User.find(params[:user_id])
user.bookmark_articles
user.bookmarks.map{ |bookmark| bookmark.article }
routes.rbを編集する
ブックマーク一覧へのリンクが必要です。
ブックマーク一覧ではIDを持たないためコレクションを使います。
index/new/createのような、idを持たない場合はコレクションを使い、show/edit/update/destroyのような、idを必要とする場合はメンバを使います。
resources :articles do
resources :comments, only: %i[create destroy]
collection do
get 'bookmarks'
end
end
resources :bookmarks, only: %i[create destroy]
bookmarks_articles GET /articles/bookmarks(.:format) articles#bookmarks
bookmarks POST /bookmarks(.:format) bookmarks#create
bookmark DELETE /bookmarks/:id(.:format) bookmarks#destroy
Userモデルにブックマークのメソッドを追加する
ユーザーのブックマークした投稿のコレクションに投稿を追加する、削除するメソッドとブックマークの判定ロジックを定義します。
class User < ApplicationRecord
...
# ブックマークに追加する
def bookmark(article)
bookmark_articles << article
end
# ブックマークを外す
def unbookmark(article)
bookmark_articles.destroy(article)
end
# ブックマークをしているか判定する
def bookmark?(article)
bookmark_articles.include?(article)
end
end
<<は破壊的な処理で自身に加えたいオブジェクトを指定します。
DBに保存されるときはcreateのSQLが発行されます.
Bookmarksコントローラーを作成する
投稿の星マークがクリックされた場合にbookmarksテーブルにレコードを追加されるためBookmarksControllerに createアクションを追加します。
ブックマーク解除機能にはdestroyアクションを使います。
アクションに対応するビューファイルがないためローカル変数を使います。
class BookmarksController < ApplicationController
before_action :user_login_required
def create
article = Article.find(params[:article_id])
Current.user.bookmark(article)
flash[:success] = t('bookmark.create')
redirect_back fallback_location: articles_path
end
def destroy
article = Current.user.bookmarks.find_by(id: params[:id])&.article
Current.user.unbookmark(article)
flash[:success] = t('bookmark.destroy')
redirect_back fallback_location: articles_path
end
end
find_by(id: params[:id]):bookmarksコレクションから、id: params[:id]に指定された特定のIDを持つブックマークを検索します。params[:id]は通常、URLのパラメーターから取得されます。
.article:ブックマークオブジェクトから関連する記事オブジェクトを取得します。この行は、関連付けを利用してBookmarkモデルからArticleモデルにアクセスしています。
最終的に、変数
articleには現在のユーザーのブックマークから見つかった特定の記事オブジェクトが代入されます。これにより、特定のブックマークIDに関連付けられた記事にアクセスできます。
コンソールから処理の流れを確認します。
Started POST "/bookmarks?article_id=3" for ::1 at 2023-07-02 00:59:01 +0900
Processing by BookmarksController#create as HTML
Parameters: {"authenticity_token"=>"[FILTERED]", "article_id"=>"3"}
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
↳ app/controllers/application_controller.rb:9:in `set_current_user'
Article Load (0.1ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]]
↳ app/controllers/bookmarks_controller.rb:5:in `create'
TRANSACTION (0.0ms) begin transaction
↳ app/models/user.rb:19:in `bookmark'
Bookmark Exists? (0.1ms) SELECT 1 AS one FROM "bookmarks" WHERE "bookmarks"."user_id" = ? AND "bookmarks"."article_id" = ? LIMIT ? [["user_id", 1], ["article_id", 3], ["LIMIT", 1]]
↳ app/models/user.rb:19:in `bookmark'
Bookmark Create (0.3ms) INSERT INTO "bookmarks" ("user_id", "article_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["user_id", 1], ["article_id", 3], ["created_at", "2023-07-01 15:59:01.338414"], ["updated_at", "2023-07-01 15:59:01.338414"]]
↳ app/models/user.rb:19:in `bookmark'
TRANSACTION (0.7ms) commit transaction
↳ app/models/user.rb:19:in `bookmark'
Redirected to http://localhost:3000/
Completed 302 Found in 10ms (ActiveRecord: 1.3ms | Allocations: 5305)
Started DELETE "/bookmarks/6" for ::1 at 2023-07-02 01:05:47 +0900
Processing by BookmarksController#destroy as HTML
Parameters: {"authenticity_token"=>"[FILTERED]", "id"=>"6"}
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
↳ app/controllers/application_controller.rb:9:in `set_current_user'
Bookmark Load (0.1ms) SELECT "bookmarks".* FROM "bookmarks" WHERE "bookmarks"."user_id" = ? AND "bookmarks"."id" = ? LIMIT ? [["user_id", 1], ["id", 6], ["LIMIT", 1]]
↳ app/controllers/bookmarks_controller.rb:12:in `destroy'
Article Load (0.1ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
↳ app/controllers/bookmarks_controller.rb:12:in `destroy'
TRANSACTION (0.0ms) begin transaction
↳ app/models/user.rb:24:in `unbookmark'
Bookmark Load (0.1ms) SELECT "bookmarks".* FROM "bookmarks" WHERE "bookmarks"."user_id" = ? AND "bookmarks"."article_id" = ? [["user_id", 1], ["article_id", 2]]
↳ app/models/user.rb:24:in `unbookmark'
Bookmark Destroy (0.3ms) DELETE FROM "bookmarks" WHERE "bookmarks"."id" = ? [["id", 6]]
↳ app/models/user.rb:24:in `unbookmark'
TRANSACTION (0.8ms) commit transaction
↳ app/models/user.rb:24:in `unbookmark'
Redirected to http://localhost:3000/articles/bookmarks
Completed 302 Found in 11ms (ActiveRecord: 1.6ms | Allocations: 5523)
Articlesコントローラーにbookmarksメソッドを追加する
コレクションルーティングに追加したbookmarksアクションをArticlesコントローラーに追加します。
def bookmarks
@bookmark_articles = Current.user.bookmark_articles.includes(:user).order(created_at: :desc)
end
includes(:user)を使って関連するuserの情報も取得しN+1問題を回避します。
ビューファイルを作成します。
ログインされているユーザーの記事でなれければブックマークボタンを表示します。
ブックマークされてない記事にブックマークボタン(_bookmark.html.erb)を表示し、
されたら外すボタン(_unbookmark.html.erb)を表示します。
ブックマーク記事一覧はbookmarks.html.erbにて作成します。
<% if Current.user&.own?(@article) %>
<%= render 'articles/crud'%>
<% else %>
<% if Current.user&.bookmark?(@article) %>
<%= render 'articles/unbookmark', article: @article %>
<% else %>
<%= render 'articles/bookmark', article: @article %>
<% end %>
<% end %>
ブックマークを追加します。
<%= link_to bookmarks_path(article_id: article.id),
id: "js-bookmark-button-for-article-#{article.id}",
method: :post do %>
<%= icon 'far', 'star' %>
<% end %>
bookmarks_path(article_id: article.id)に投稿のIDを渡す必要があります。
理由としては、Bookmarkコントローラーのcreateアクションでは投稿を取得してからブックマークのコレクションに追加されますが、createアクションのルートにArticlesコントローラの子要素としてネストしていません。
article_idがないとbookmarkテーブルに保存されないので(article_id: article.id)で投稿のIDを渡します。

article = Article.find(params[:article_id])
bookmarks POST /bookmarks(.:format) bookmarks#create
ブックマークを外します。
外す時はDBからすでに保存されているレコードから投稿のIDを取得するためURLのパラメーターが違いますね。
こちらではBookmarkテーブルからブックマークされている投稿IDを取得します。
<%= link_to bookmark_path(Current.user.bookmarks.find_by(article_id: article.id)),
id: "js-bookmark-button-for-article-#{article.id}",
method: :delete do %>
<%= icon 'fas', 'star' %>
<% end %>
Bookmarkテーブルの投稿ID(article_id)からArticlesテーブルに対応した投稿ID(article.id)を取得します。
article = Current.user.bookmarks.find_by(id: params[:id])&.article
ブックマーク一覧
<div class="row">
<div class="col-12">
<div class="row">
<% if @bookmark_boards.present? %>
<%= render @bookmark_boards %>
<% else %>
<p><%= t('.no_result') %></p>
<% end %>
</div>
</div>
</div>
</div>
@bookmark_articlesはArticleのインスタンスであるためArticleにアクセスができます。
class ArticlesController < ApplicationController
def bookmarks
@bookmark_articles = Current.user.bookmark_articles.includes(:user).order(created_at: :desc)
end
end
ユーザーがbookmark_articlesコレクションを通してソースであるArticleモデルからコレクションの記事を取得することができます。
class User < ApplicationRecord
has_many :bookmark_articles, through: :bookmarks, source: :article
end
<div class="col">
<div class="card h-100">
<%= image_tag article.image.thumb.url, class:'card-img-top' %>
<div class="card-body">
<small class="muted-text"><%= l(article.created_at, format: :long) %></small>
<h5 class="card-title mt-3"><%= link_to article.title, article %></h5>
<p class="card-text"><%= simple_format(article.body) %></p>
<p class="text-end"><small><%= article.user.user_name %></small></p>
<div class="card-footer text-end">
<ul class='crud-menu-btn list-inline float-right'>
<li class="list-inline-item">
<%= link_to article_path(article) do %>
<%= icon 'fa', 'eye' %>
<% end %>
</li>
<% if Current.user&.own?(article) %>
<%= render 'articles/crud', article: article %>
<% else %>
<% if Current.user&.bookmark?(article) %>
<%= render 'articles/unbookmark', article: article %>
<% else %>
<%= render 'articles/bookmark', article: article %>
<% end %>
</ul>
<% end %>
</div>
</div>
</div>
</div>
↳ app/models/user.rb:29:in `bookmark?'
Rendered articles/_bookmark.html.erb (Duration: 0.2ms | Allocations: 151)
Rendered articles/_article.html.erb (Duration: 2.5ms | Allocations: 1685)
Article Exists? (0.1ms) SELECT 1 AS one FROM "articles" INNER JOIN "bookmarks" ON "articles"."id" = "bookmarks"."article_id" WHERE "bookmarks"."user_id" = ? AND "articles"."id" = ? LIMIT ? [["user_id", 1], ["id", 19], ["LIMIT", 1]]
翻訳を追加する
ja:
...
articles:
bookmarks:
title: 'ブックマーク一覧'
no_result: 'ブックマーク中の投稿がありません'
bookmark:
create: 'ブックマークしました'
destroy: 'ブックマーク外しました'
ja:
activerecord:
models:
bookmark: 'ブックマーク'
ブックマークボタンのAjax化
ブックマークの処理を実行するたびにページがリロードされるためBookmarksコントローラーのcreateアクションとdestoryアクションをそれぞれajax化していきます。
app/views/bookmarksの中に、create.js.erbとdestroy.js.erbを作成し、ブックマークボタンの部分テンプレートの再描画を行うように実装していきます。
Bookmarksコントローラーを編集する
ビューに変数を渡す必要がありますのでarticleを@articleに変えます。
class BookmarksController < ApplicationController
before_action :user_login_required
def create
@article = Article.find(params[:article_id])
Current.user.bookmark(@article)
respond_to do |format|
format.js
format.html { redirect_back fallback_location: articles_path, flash: { success: t('bookmark.create') } }
end
end
def destroy
@article = Current.user.bookmarks.find_by(id: params[:id])&.article
Current.user.unbookmark(@article)
respond_to do |format|
format.js
format.html { redirect_back fallback_location: articles_path, flash: { success: t('bookmark.destroy') } }
end
end
end
リンクにremote: trueをつける
ブックマークボタンにれremote: trueをつけます。
<%= link_to
bookmark_path(Current.user.bookmarks.find_by(article_id: article.id)),
id: "js-bookmark-button-for-article-#{article.id}",
method: :delete,
+ remote: true do %>
<%= icon 'fas', 'star' %>
<% end %>
<%= link_to bookmarks_path(article_id: article.id),
id: "js-bookmark-button-for-article-#{article.id}",
method: :post,
+ remote: true do %>
<%= icon 'far', 'star' %>
<% end %>
jsにてボタンテンプレートの再描画を行う
(function(){
const bookmarkBtn = document.querySelector("#js-bookmark-button-for-article-<%= @article.id %>");
const createBookmark = '<%= j(render("articles/unbookmark", article: @article )) %>';
bookmarkBtn.outerHTML = createBookmark;
})();
(function(){
const bookmarkBtn = document.querySelector('#js-bookmark-button-for-article-<%= @article.id %>');
const deleteBookmark = '<%= j(render('articles/bookmark', article: @article )) %>';
bookmarkBtn.outerHTML = deleteBookmark;
})();
終わりに
has_many :throughが難しかったです。
復習して理解できるようになりましょう。
Discussion