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


## はじめに

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



### 実装したいこと

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





### 前提

- ユーザー認証機能（Userモデル）
- 記事投稿機能（Postモデル）

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



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





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

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



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







## Stockモデルを作成

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




![stocks_table.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/285374/cbee5750-7874-69a5-9a54-f5f2ca520f58.jpeg)


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





users テーブルと posts テーブルが外部キーで紐付いた Stockモデルを作成します。

```bash
$ rails g model Stock user:references post:references
```



作成されたマイグレーションファイルに追記します。

```ruby
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の組み合わせを一意に保つことができるので「**１人のユーザーが同じ記事を複数ストックする**」ことを防ぐことができます。



編集が完了したら

```bash
$ 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

```


<br>
ユーザーモデル

```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コントローラー作成

コントローラーを作成します。

```bash
$ 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

```erb
<div id="post-<%= @post.id %>">
    <%= render partial: 'stocks/stock', locals: { post: @post } %>
</div>
```





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


```erb: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作成





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


<br>

```js: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テーブルからストックした記事を返します。

<br>

`Kaminari.paginate_array(stock)`としている理由は、`stock_posts = Stock.get_stock_posts(current_user)`で取得したオブジェクトのクラスが`Array`クラスなので`page(params[:page])`メソッドが使えないためです。

`page(params[:page])`を使うためには`PaginatableArray`クラスに変換する必要があり、`paginate_array(stock)`の引数に取得したオブジェクトを渡すことで`PaginatableArray`クラスに変換することができます。



クラス変換の確認

```bash
> 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](https://github.com/kaminari/kaminari#paginating-a-generic-array-object)



### 一覧のビューを作成



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


```erb: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`を入力して一覧が表示されていれば完了です。



## さいごに

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