LoginSignup
5
14

More than 3 years have passed since last update.

【Rails5】Qiitaのような記事ストック機能とストックした記事を一覧表示させる

Posted at

はじめに

Qiitaの記事ストック機能のように、ユーザーが投稿したブログ記事をストックしておき、さらにストックした記事を一覧表示させる機能を実装していきたいと思います。

実装したいこと

  • ログインしているユーザーは、他のユーザーが投稿した記事をストックできる
  • 既にストックしている記事を解除できる
  • ユーザーがストックしている記事を一覧で確認できる
  • 1人のユーザーは既にストックした記事をさらにストックできない
  • ログインしていないユーザーはストックできない

前提

  • ユーザー認証機能(Userモデル)
  • 記事投稿機能(Postモデル)

以上のモデルが既に実装済みであるという前提で話を進めていきます。

今回ユーザー認証機能に「devise」、ページネーション機能に「kaminari」を使用しています。

ストック機能実装のために必要な手順

  1. Stockモデル作成
  2. stocksコントローラー作成
  3. ルーティングを設定
  4. ビューを作成(Ajax)
  5. ストック記事の一覧表示

まずは、誰がどの記事をストックしているのか管理するための stocks テーブルの作成から始めていきます。

Stockモデルを作成

ストック機能を実装するためには「誰がどの記事をストックしているのか」を管理する必要があり、中間テーブル である「stocks テーブル」を作成します。

stocks_table.jpg

雑な図ですが、テーブルのイメージはこんな感じです。

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 モデルと外部キーで関連付けたモデルにアソシエーションを設定していきます。

ストックモデル

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



ユーザーモデル

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

ポストモデル

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

作成されたコントローラーに次を記述する

app/controllers/stocks_controller.rb
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で形式を指定する

ルーティングを設定

routes.rb
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>

ストックボタン / ストック解除ボタンのパーシャルを作成

app/view/stocks/_stock.html.erb
<!-- 自身の投稿以外 かつ ログインしているユーザーであれば -->
<% 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作成

view/stocks/create.js.erb
$("#post-<%= @post.id %> .stock").html("<%= escape_javascript(render partial: 'stocks/stock', locals: { post: @post }) %>");


view/stockks/destroy.js.erb
$("#post-<%= @post.id %> .stock").html("<%= escape_javascript(render partial: 'stocks/stock', locals: { post: @post }) %>");

Ajaxでストックボタンを実装することで、ストックボタン または ストック解除ボタンをクリックしたとき、非同期的にボタンUIが変更されます。

ストック記事一覧ページを作成

投稿記事のストック機能のが完成したら、ストックした記事の一覧を表示させたいと思います。
ストックした記事の一覧のページネーションは「kaminari」実装します。

stocksコントローラーに追記

一覧表示に先程作成したget_stock_postsメソッドを使っていきます。

app/models.stock.rb
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コントローラーに次を追記します。

app/controllers/stocks_controller.rb
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の公式ページを確認してください

kaminari | Github

一覧のビューを作成

ビューを作成して一覧が表示できているか確認します

app/views/posts/index.html.erb
<% @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を入力して一覧が表示されていれば完了です。

さいごに

いろいろ試行錯誤してストック機能を作成してみました。
間違っている点や改善点などありましたら、教えていただけると嬉しいです。

5
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
14