🦓

[Rails]ブックマーク 13/20

2023/07/02に公開

はじめに

投稿にブックマーク機能を追加していきます。

要件:
- ☆ボタンを押すと、投稿をブックマークに登録 / 解除できること
- ブックマークした一覧を表示できること
- 自分が作成した投稿にはブックマークボタンが表示されないこと
- ユーザーが同じ投稿に対するブックマークが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`の組み合わせが一意性を持つことになります。

db/migrate/xxx_create_bookmarks.rb
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_idboard_idの2つのカラムを組み合わせてインデックスを作成します。

bookmarksテーブルに対して、user_idboard_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モデルにバリデーションを追加する

app/models/bookmark.rb
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 }

https://railsguides.jp/association_basics.html#has-many-through関連付け

routes.rbを編集する

ブックマーク一覧へのリンクが必要です。
ブックマーク一覧ではIDを持たないためコレクションを使います。
index/new/createのような、idを持たない場合はコレクションを使い、show/edit/update/destroyのような、idを必要とする場合はメンバを使います。

config/routes.rb
  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モデルにブックマークのメソッドを追加する

ユーザーのブックマークした投稿のコレクションに投稿を追加する、削除するメソッドとブックマークの判定ロジックを定義します。

app/models/user.rh
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

https://railsguides.jp/association_basics.html#has-manyで追加されるメソッド-collection
<<は破壊的な処理で自身に加えたいオブジェクトを指定します。
DBに保存されるときはcreateのSQLが発行されます.

https://docs.ruby-lang.org/ja/latest/method/Array/i/=3c=3c.html

Bookmarksコントローラーを作成する

投稿の星マークがクリックされた場合にbookmarksテーブルにレコードを追加されるためBookmarksControllercreateアクションを追加します。
ブックマーク解除機能にはdestroyアクションを使います。
アクションに対応するビューファイルがないためローカル変数を使います。

app/controllers/bookmarks_controller.rb
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コントローラーに追加します。

app/controllers/articles_controller.rb
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にて作成します。

app/views/articles/index.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 %>

ブックマークを追加します。

app/views/articles/_bookmark.html.erb
<%= 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を渡します。

app/controllers/bookmarks_controller.rb
article = Article.find(params[:article_id])
bookmarks POST   /bookmarks(.:format)           bookmarks#create

ブックマークを外します。
外す時はDBからすでに保存されているレコードから投稿のIDを取得するためURLのパラメーターが違いますね。
こちらではBookmarkテーブルからブックマークされている投稿IDを取得します。

app/views/articles/_unbookmark.html.erb
<%= 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)を取得します。

app/controllers/bookmarks_controller.rb
article = Current.user.bookmarks.find_by(id: params[:id])&.article

ブックマーク一覧

app/views/articles/bookmarks.html.erb
  <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_articlesArticleのインスタンスであるためArticleにアクセスができます。

app/controllers/articles_controller.erb
class ArticlesController < ApplicationController
  def bookmarks
    @bookmark_articles = Current.user.bookmark_articles.includes(:user).order(created_at: :desc)
  end
end

ユーザーがbookmark_articlesコレクションを通してソースであるArticleモデルからコレクションの記事を取得することができます。

app/models/user.rb
class User < ApplicationRecord
    has_many :bookmark_articles, through: :bookmarks, source: :article
end
app/views/articles/_article.html.erb
<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]]

翻訳を追加する

config/locales/activerecord/ja.yml
ja:
...
  articles:
    bookmarks:
      title: 'ブックマーク一覧'
      no_result: 'ブックマーク中の投稿がありません'
  bookmark:
      create: 'ブックマークしました'
      destroy: 'ブックマーク外しました'
config/locales/views/ja.yml
ja:
  activerecord:
    models:
      bookmark: 'ブックマーク'

ブックマークボタンのAjax化

ブックマークの処理を実行するたびにページがリロードされるためBookmarksコントローラーのcreateアクションとdestoryアクションをそれぞれajax化していきます。
app/views/bookmarksの中に、create.js.erbdestroy.js.erbを作成し、ブックマークボタンの部分テンプレートの再描画を行うように実装していきます。

Bookmarksコントローラーを編集する

ビューに変数を渡す必要がありますのでarticle@articleに変えます。

app/controllers/bookmarks_controller.rb
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をつけます。

app/views/articles/_unbookmark.html.erb
<%= 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 %>
app/views/articles/unbookmark.html.erb
<%= 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にてボタンテンプレートの再描画を行う

app/views/bookmarks/create.js.erb
(function(){
const bookmarkBtn = document.querySelector("#js-bookmark-button-for-article-<%= @article.id %>");
const createBookmark = '<%= j(render("articles/unbookmark",  article: @article )) %>';
bookmarkBtn.outerHTML = createBookmark;
})();
app/views/bookmarks/destroy.js.erb
(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