はじめに
Qiitaの記事ストック機能のように、ユーザーが投稿したブログ記事をストックしておき、さらにストックした記事を一覧表示させる機能を実装していきたいと思います。
実装したいこと
- ログインしているユーザーは、他のユーザーが投稿した記事をストックできる
- 既にストックしている記事を解除できる
- ユーザーがストックしている記事を一覧で確認できる
- 1人のユーザーは既にストックした記事をさらにストックできない
- ログインしていないユーザーはストックできない
前提
- ユーザー認証機能(Userモデル)
- 記事投稿機能(Postモデル)
以上のモデルが既に実装済みであるという前提で話を進めていきます。
今回ユーザー認証機能に「devise」、ページネーション機能に「kaminari」を使用しています。
ストック機能実装のために必要な手順
- Stockモデル作成
- stocksコントローラー作成
- ルーティングを設定
- ビューを作成(Ajax)
- ストック記事の一覧表示
まずは、誰がどの記事をストックしているのか管理するための stocks テーブルの作成から始めていきます。
Stockモデルを作成
ストック機能を実装するためには「誰がどの記事をストックしているのか」を管理する必要があり、中間テーブル である「stocks テーブル」を作成します。
雑な図ですが、テーブルのイメージはこんな感じです。
users テーブルと posts テーブルが外部キーで紐付いた Stockモデルを作成します。
$ rails g model Stock user:references post:references
作成されたマイグレーションファイルに追記します。
class CreateStocks < ActiveRecord::Migration[5.2]
def change
create_table :stocks do |t|
t.references :user, index: true, foreign_key: true, null: false
t.references :post, index: true, foreign_key: true, null: false
t.timestamps
# 同じ記事をストックできないように一意制約を追加
t.index [:user_id, :post_id], unique: true
end
end
end
user_id と post_id は必須なので、null: false
を指定します。これを指定することでカラムが null のデータを保存させないことができます。
t.index [:user_id, :post_id], unique: true
を指定しておくことで、user_id, post_idの組み合わせを一意に保つことができるので「1人のユーザーが同じ記事を複数ストックする」ことを防ぐことができます。
編集が完了したら
$ rails db:migrate
アソシエーションを設定
作成した stock モデルと外部キーで関連付けたモデルにアソシエーションを設定していきます。
ストックモデル
class Stock < ApplicationRecord
belongs_to :user
belongs_to :post
# バリデーションを設定
validates :user_id, presence: true
validates :post_id, presence: true
# ストック一覧表示用、あとで解説
def self.get_stock_posts(user)
self.where(user_id: user.id).map(&:post)
end
end
ユーザーモデル
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
has_many :posts, dependent: :destroy
# ここを追加:アソシエーションを追加
has_many :stocks, dependent: :destroy
validates :name, presence: true, length: { maximum: 20 }
validates :email, length: { maximum: 255 }
validates :password, length: { minimum: 8 }
end
ポストモデル
class Post < ApplicationRecord
belongs_to :user
# アソシエーションを設定する
has_many :stocks, dependent: :destroy
# 投稿記事が誰にストックされているかを取得できる
has_many :stock_users, through: :stocks, source: :user
validates :title, presence: true, length: { maximum: 100 }
validates :body, presence: true, length: { maximum: 10000 }
# 現在ログインしているユーザーidを受け取り、記事をストックする
def stock(user)
stocks.create(user_id: user.id)
end
# 現在ログインしているユーザーidを受け取り、記事のストックを解除する
def unstock(user)
stocks.find_by(user_id: user.id).destroy
end
# 記事がストック済みであるかを判定
# 取得済みであれば true を返す
def stocked?(user)
stock_users.include?(user)
end
end
それぞれのモデルに、アソシエーションを設定します。
postモデルに記述されているメソッドは、コントローラーやビューで使います。
stocksコントローラー作成
コントローラーを作成します。
$ rails g controller stocks
作成されたコントローラーに次を記述する
class StocksController < ApplicationController
def index
# 一覧は後で作成
end
def create
@post = Post.find(params[:post_id])
# 取得した記事がまだストックされていなければ
unless @post.stocked?(current_user)
# ログインしているユーザーを取得してparamsで渡された記事をストック
@post.stock(current_user)
# ajaxでストックボタンを実装
respond_to do |format|
format.html { redirect_to request.referrer || root_url }
format.js
end
end
end
def destroy
@post = Stock.find(params[:id]).post
# 取得した記事が既にストックされていれば
if @post.stocked?(current_user)
# ログインしているユーザーを取得してparamsで渡された記事のストック解除
@post.unstock(current_user)
# ajaxでストックボタンを実装
respond_to do |format|
format.html { redirect_to request.referrer || root_url }
format.js
end
end
end
end
先程 postモデルに作成したstocked?
メソッドで、既にストックされている記事かを判定して、stock
メソッドで記事をストック、unstock
メソッドで記事のストックを解除します。
ストックボタンを ajax で実装するため、respond_toで形式を指定する
ルーティングを設定
Rails.application.routes.draw do
devise_for :users, controllers: {
registrations: 'users/registrations'
}
resources :users, only: %i(show)
resources :posts
# 次を追加
resources :stocks, only: %i(index create destroy)
end
only:
で index create destroyを指定します。
ビューを作成
記事詳細ページの設置したい箇所にストックボタン / ストック解除ボタンのパーシャルを設置します。
view/posts/show.html.erb
<div id="post-<%= @post.id %>">
<%= render partial: 'stocks/stock', locals: { post: @post } %>
</div>
ストックボタン / ストック解除ボタンのパーシャルを作成
<!-- 自身の投稿以外 かつ ログインしているユーザーであれば -->
<% if current_user != post.user && user_signed_in? %>
<div class="stock">
<!-- 既にストックしている記事であれば「ストック解除ボタン」を表示 -->
<% if post.stocked?(current_user) %>
<!-- stocksコントローラーのdestoryアクションにmethod: :deleteで指定して送信 -->
<%= form_with model: post.stocks.find_by(user_id: current_user.id), method: :delete do |f| %>
<%= button_tag(class: "btn btn-dark") do %>
<i class="fas fa-folder-minus"></i>
<% end %>
<% end %>
<!-- ストックしていない記事であれば「ストックボタン」を表示 -->
<% else %>
<!-- buildでstocksコントローラーのcreateアクションへ -->
<%= form_with model: post.stocks.build do |f| %>
<!-- hiddenでpost_idを送信 -->
<div><%= hidden_field_tag :post_id, post.id %></div>
<%= button_tag(class: "btn btn-outline-dark") do %>
<i class="fas fa-folder-plus"></i>
<% end %>
<% end %>
<% end %>
</div>
<% end %>
js用のerb作成
$("#post-<%= @post.id %> .stock").html("<%= escape_javascript(render partial: 'stocks/stock', locals: { post: @post }) %>");
$("#post-<%= @post.id %> .stock").html("<%= escape_javascript(render partial: 'stocks/stock', locals: { post: @post }) %>");
Ajaxでストックボタンを実装することで、ストックボタン または ストック解除ボタンをクリックしたとき、非同期的にボタンUIが変更されます。
ストック記事一覧ページを作成
投稿記事のストック機能のが完成したら、ストックした記事の一覧を表示させたいと思います。
ストックした記事の一覧のページネーションは「kaminari」実装します。
stocksコントローラーに追記
一覧表示に先程作成したget_stock_posts
メソッドを使っていきます。
class Stock < ApplicationRecord
belongs_to :user
belongs_to :post
.
.
.
# 渡されたユーザーのストック記事一覧を返す
def self.get_stock_posts(user)
self.where(user_id: user.id).map(&:post)
end
end
stocksコントローラーに次を追記します。
class StocksController < ApplicationController
def index
# ログインユーザーがストックした記事一覧を取得
stock_posts = Stock.get_stock_posts(current_user)
# 取得した記事を10件毎にページネーションさせるためにpaginate_arrayメソッドを使う
@stock_posts = Kaminari.paginate_array(stock_posts).page(params[:page]).per(10)
end
# 以下create destoryが続く
end
現在ログインしているユーザーがストックした記事一覧を表示させたいので、先程作成したget_stock_posts
の引数にcurrent_user
を渡して、stocksテーブルからログインユーザーのidと一致するデータを全て取得して.map(&:post)
でpostsテーブルからストックした記事を返します。
Kaminari.paginate_array(stock)
としている理由は、stock_posts = Stock.get_stock_posts(current_user)
で取得したオブジェクトのクラスがArray
クラスなのでpage(params[:page])
メソッドが使えないためです。
page(params[:page])
を使うためにはPaginatableArray
クラスに変換する必要があり、paginate_array(stock)
の引数に取得したオブジェクトを渡すことでPaginatableArray
クラスに変換することができます。
クラス変換の確認
> stock_posts = Stock.get_stock_posts(current_user)
pry> stock_posts.class
#=> Array
> @stock_posts = Kaminari.paginate_array(stock_posts).page(params[:page]).per(10)
pry> @stock_posts.class
#=> Kaminari::PaginatableArray
詳しい内容はkaminariの公式ページを確認してください
一覧のビューを作成
ビューを作成して一覧が表示できているか確認します
<% @stock_posts.each do |post| %>
<p><%= post.title %></p>
<p><%= post.user.name %></p>
<% end %>
<!-- 10件毎にページネーション -->
<div class="paginate">
<%= paginate @stock_posts %>
</div>
localhost:3000/stocks
を入力して一覧が表示されていれば完了です。
さいごに
いろいろ試行錯誤してストック機能を作成してみました。
間違っている点や改善点などありましたら、教えていただけると嬉しいです。