Rails Tutorialの知識から【ポートフォリオ】を作って勉強する話 #14 ユーザ投稿表示, ページネーション編

# こんな人におすすめ
* プログラミング初心者でポートフォリオの作り方が分からない
* Rails Tutorialをやってみたが理解することが難しい

前回：[#13 パスワード再設定編](https://qiita.com/aokyo17/items/28e12c9ad40cac9e528b)
次回：[#15 投稿機能, Active Storage編](https://qiita.com/aokyo17/items/8769ecff3b7c08dca70b)

# 今回の流れ
1. 投稿用のモデルをつくる
* サンプル投稿を表示する
* テストをつくる

この記事は、**動画を観た時間を記録するアプリのポートフォリオ**です。
今回は**ユーザ投稿の表示とページネーション**を実装します。
（投稿機能は[#15](https://qiita.com/aokyo17/items/8769ecff3b7c08dca70b)で紹介します。）

# 投稿用のモデルをつくる
ここでの手順は以下の通りです。

* Micropostモデルをつくる
* Userモデルを編集する
* バリデーションを追加する
* Micropostモデルを改良する
* エラー時の日本語化を行う

## Micropostモデルをつくる
まずは投稿のためのMicropostモデルをつくりましょう。
ユーザが投稿できる項目は以下の通りです。

* 動画視聴時間
* メモ
* 画像

加えて投稿はユーザがいて初めて成立するので、Userモデルに所属させます。
そのためには生成時にuser:referencesを加えます。
以上を踏まえてMicropostモデルを生成しましょう。

```bash:bash
$ rails g model Micropost time:integer memo:text picture:string user:references
```

投稿を表示する際、twitterのように最新のものが上部に来てほしいものです。
その準備として、マイグレーションにインデックスを加えます。

```rb:db/migrate/[timestamp]_create_microposts.rb
class CreateMicroposts < ActiveRecord::Migration[5.2]
  def change
    create_table :microposts do |t|
      t.integer :time
      t.text :memo
      t.string :picture
      t.references :user, foreign_key: true

      t.timestamps
    end
    add_index :microposts, [:user_id, :created_at]
  end
end
```

user_idは投稿したユーザ、create_atは投稿時間を管理しています。
これらを複合キーとすることで、望み通り取り出すことが可能です。

## Userモデルを編集する
MicropostモデルはUserモデルに所属しました。
この実装はbelongs_to :userにより、user_idとして形になりました。
今度はUserモデルにMicropostモデルを所有してもらいましょう。

```rb:app/models/user.rb
class User < ApplicationRecord
  has_many :microposts
  # 中略
end
```

has_manyによりUserはMicropostと1対多の関係になりました。
こうすることでmicropostsを指定する時、こんな書き方が可能です。

```rb:
user = User.new
user.microposts
```

慣習的にも正しいので、以上の作業は忘れずに行いましょう。

## バリデーションを追加する
ここでは以下のバリデーションを追加します。

* user_idが空の場合受けつけない
* いずれの値も空の場合（user_idを除く）受けつけない
* pictureは適切なファイルのみ受けつける
* pictureは5MBまでを受けつける

```rb:app/models/micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user
  has_one_attached :picture
  default_scope { order(created_at: :desc) }
  validates :user_id, presence: true
  validates :memo, length: { maximum: 255 }
  validates :only_user_id, presence: true
  validate :validate_picture
  
  def resize_picture
    return self.picture.variant(resize: '200x200').processed
  end
  
  private
    def only_user_id
      time.presence or memo.presence or picture.attached?
    end
    
    def validate_picture
      if picture.attached?
        if !picture.content_type.in?(%('image/jpeg image/jpg image/png image/gif'))
          errors.add(:picture, 'はjpeg, jpg, png, gif以外の投稿ができません')
        elsif picture.blob.byte_size > 5.megabytes
          errors.add(:picture, "のサイズが5MBを超えています")
        end
      end
    end
end
```

いずれかという条件の実装にはonly_user_idメソッドを定義しました。
画像の条件にはvalidate_pictureメソッドを定義しました。

参考にさせていただきました↓
[「いずれかのカラムが空でなければ良い」というバリデーション](https://qiita.com/sato_ryu/items/24889b3686c5132e3134)
[Active Storage移行記：バリデーション編](https://tech-blog.sitateru.com/2018/11/active-storage.html)

（これらのバリデーションはエラーになる恐れがあります。）
（その場合、[#15Active Storage導入](https://qiita.com/aokyo17/items/8769ecff3b7c08dca70b)をご覧ください。）

## Micropostモデルを改良する
ここでは以下の機能を追加します。

* 新しい投稿を先にソートする
* ユーザが削除されたら投稿も削除する

```rb:app/models/micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user
  default_scope { order(created_at: :desc) }
  # 中略
end
```

```rb:app/models/user.rb
class User < ApplicationRecord
  has_many :microposts, dependent: :destroy
  # 中略
end
```

## エラー時の日本語化を行う
あとはエラー時の言語を日本語にしましょう。
日本語化にはgemと設定が必要です。[#6](https://qiita.com/aokyo17/items/6ee827c240907514681b)や[Railsのバリデーションエラーのメッセージの日本語化](https://qiita.com/Ushinji/items/242bfba84df7a5a67d5b)を参考に設定を行なってください。

お済みの方は以下のようにファイルを編集してください。

```yml:config/locales/models/ja.yml
ja:
  activerecord:
    models:
      user: ユーザ
      micropost: 投稿
    attributes:
      user:
        name: 名前
        email: メールアドレス
        password: パスワード
        password_confirmation: パスワード（再入力）
      micropost:
        time: 記録時間
        memo: メモ
        picture: 画像
        user_id: ユーザID
        only_user_id: 入力欄
```

後ほどビューをつくります。
その際エラー表示を確認するので、ひとまずここで終了です。
最後にデータベースを更新します。

```bash:bash
$ rails db:migrate
```

# サンプル投稿を表示する
Micropostモデルは整いました。次は投稿を表示しましょう。
ここでの手順は以下の通りです。

* サンプル投稿を生成する
* サンプル投稿を表示する
* ページネーションを実装する

## サンプル投稿を生成する
サンプル投稿を生成するにはfakerを使用します。

```diff:gemfile
+ gem 'faker'
```

```bash:bash
$ bundle install
```

それではサンプル投稿を生成します。
ついでにサンプルユーザも生成しておきます。

```rb:db/seeds.rb
User.create!(
  name: "Example User",
  email: "example@railstutorial.org",
  password: "foobar",
  password_confirmation: "foobar",
  activated: true
)

5.times do |n|
  name = Faker::Name.name
  email = "example_#{n+1}@railstutorial.org"
  password = "password"
  User.create!(
    name: name,
    email: email,
    password: password,
    password_confirmation: password,
    activated: true
  )
end

users = User.order(:created_at).take(3)
50.times do
  memo = Faker::Lorem.sentence(6)
  users.each { |user| user.microposts.create!(memo: memo) }
end
```

最後にデータベースを再構築しましょう。

```bash:bash
$ rails db:migrate:reset
$ rails db:seed
```

これでサンプルユーザとサンプル投稿が生成されました。

## サンプル投稿を表示する
生成を終えたら投稿を表示しましょう。
ここでの手順は以下の通りです。

* usersコントローラを編集する
* ビューを編集する
* ページネーションを追加する

### usersコントローラを編集する
投稿を表示させるビューはshow.html.erbです。
つまりこれに対応するコントローラにMicropostを渡す必要があります。
対応するのはusersコントローラです。編集しましょう。

```app/controllers/users_controller.rb
class UsersController < ApplicationController
  # 中略
  def show
    @user = User.find(params[:id])
    @microposts = @user.microposts.page(params[:page]).per(10)
  end
```

この1文はページネーションを実装する記述です。

```rb:
@microposts = @user.microposts.page(params[:page]).per(10)
```

ページネーションしたい対象に.page〜以下を加えることで動作します。
per(10)は1ページの表示数を表します。今回は10にしました。

### ビューを編集する
続いてはビューです。
パーシャルを使用しつつ実装しましょう。

```erb:app/views/users/show.html.erb
<% provide(:title, @user.name) %>
<div class="container show-container">
  <div class="row">
    <div class="col bg-primary">
      form
    </div>
    <div class="col bg-secondary">
      figure
    </div>
  </div>
  <div class="row">
    <div class="col">
      <%= render 'layouts/log' %>
    </div>
  </div>
</div>
```

```erb:app/views/layouts/_log.html.erb
<h1 class="log-title col-2">Logs</h1>
<% if @user.microposts.any? %>
  <div class="container">
    <ol class="microposts">
    <% @microposts.each do |micropost| %>
        <li id ="micropost-<%= micropost.id %>">
          <span class="row log-list">
            <span class="col-2 log-timestamp d-none d-md-inline-block log-timestamp-block">
              <span class="log-timestamp"><%= time_ago_in_words(micropost.created_at) %>前</span>
            </span>
            <span class="col-md-10 col-log-memos">
              <div class="log-time-and-edit">
                <div class="row">
                  <span class="log-time col-3"><%= micropost.time %>分</span>
                  <span class="col-7 log-timestamp log-timestamp-inline"><%= time_ago_in_words(micropost.created_at) %>前</span>
                  <span class="log-edit col-2"><%= link_to image_tag('edit.png', class: "log-edit-image"), '#' %></span>
                </div>
              </div>
              <span class="log-memo"><%= micropost.memo %></span>
              <span class="log-picture"><%= micropost.picture %></span>
            </span>
          </span>
        </li>
    <% end %>
    </ol>
  </div>
  <%= paginate @microposts %>
<% else %>
  <span>まだ投稿がありません</span>
<% end %>
```

```scss:app/assets/stylesheets/application.scss.erb
// max-width = 767px
@media (max-width: 767px) {
  .nav-item-extend {
    margin-top: 0.6rem;
  }
  
  .log-title {
    padding: 0;
  }
  
  .log-list {
    border-left: 1px solid $lantern-dark-white;
  }
}

@media (min-width: 768px) {
  .log-timestamp-inline {
    visibility: hidden;
  }
}

// 中略

// logs
ol {
  padding: 0;
  list-style: none;
}

.log-title {
  margin: 1.5rem 0;
  text-align: center;
}

.log-list {
  margin-bottom: 1rem;
  
  .log-timestamp {
    color: gray;
  }
  
  .log-timestamp-block {
    text-align: center;
    padding-bottom: 2rem;
    margin-top: 0.1rem;
    border-right: 1px solid $lantern-dark-white;
  }
  
  .col-log-memos {
    padding: 0 1rem 0 1rem;
    
    .log-time-and-edit {
      .log-time {
        font-size: 1.2rem;
        color: $lantern-yellow;
      }
      
      .log-timestamp-inline {
        text-align: right;    
        margin-top: 0.1rem;
        padding: 0;
      }
      
      .log-edit {
        text-align: right;
        margin-top: 0.1rem;

        .log-edit-image {
          width: 1rem;
        }
      }
    }
    
    .log-memo {
    }
    
    .log-picture {
    }
  }
}

// paginate
.pagination {
  margin-top: 1.6rem;
  
  .page-item {
    .page-link {
      border: 1px solid $lantern-light-white;
      background-color: $lantern-light-white;
      color: $lantern-blue;
    }
  }
  
  .page-item.active .page-link {
    background-color: $lantern-blue;
    border-color: $lantern-blue;
    color: $lantern-light-white;
  }
}
// 中略
```

_log.html.erbのこの部分もページネーションを実装するための記述です。

```erb:
<%= paginate @microposts %>
```

コントローラに引き続き、2つの記述が見受けられました。
これらはkaminariというgemにより動作します。
準備は以上です。後はgemを挿れるだけです。

### ページネーションを追加する
それではkaminariを追加し、ページネーションを動作させましょう。
Bootstrap4と日本語化の適用も行います。

```diff:gemfile
+ gem 'kaminari'
+ gem 'kaminari-bootstrap'
```

```bash:bash
$ bundle install
$ rails g kaminari:views bootstrap4
```

```yml:config/locales/models/ja.yml
ja:
  # 中略
  views:
    pagination:
      first: "&laquo; 最初"
      last: "最後 &raquo;"
      previous: "&lsaquo; 前"
      next: "次 &rsaquo;"
      truncate: "..."
```

これらを終えるとビューはこんな感じになります。
PC版↓
![lantern_logs_pc.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/454653/133ced1e-daee-e98b-9dd4-574ba2e4983c.png)
スマホ版↓
![lantern_logs_iphoneX.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/454653/e29c0e48-95b6-4fa3-39e0-e80ad43ceca1.png)


参考になります↓
[【Ruby on Rails】gem(Kaminari)でページネーション機能を追加してBootstrapを適用する。](https://qiita.com/residenti/items/1ae1e5ceb59c0729c0b9)
[【Rails初心者】ページネーションを実装して自分好みにデザインを変える](https://qiita.com/threehouse/items/313824b90a31268b0074)

# テストをつくる
最後にテストを完成させます。
ここでの手順は以下の通りです。

* FactoryBotを整える
* テストを書く

## FactoryBotを整える
まずはテストを行う前の準備をしましょう。
今回新たにMicropostモデルが生成されました。
それに伴うテストを行いたいので、FactoryBotで導入しましょう。

ここでの手順は以下の通りです。

* テスト用のMicropostモデルを生成する
* テスト用のUserモデルを編集する


```rb:spec/factories/microposts.rb
FactoryBot.define do
  factory :memos, class: Micropost do
    trait :memo_1 do
      time { 240 }
      memo { "I just ate an orange!" }
      picture { nil }
      user_id { 1 }
      created_at { 10.minutes.ago }
    end

    trait :memo_2 do
      time { 180 }
      memo { "Check out the @tauday site by @mhartl: http://tauday.com" }
      picture { nil }
      user_id { 1 }
      created_at { 3.years.ago }
    end

    trait :memo_3 do
      time { 59 }
      memo { "Sad cats are sad: http://youtu.be/PKffm2uI4dk" }
      picture { nil }
      user_id { 1 }
      created_at { 2.hours.ago }
    end

    trait :memo_4 do
      time { 207 }
      memo { "Writing a short test" }
      picture { nil }
      user_id { 1 }
      created_at { Time.zone.now }
    end
    association :user, factory: :user
  end
end
```

```rb:spec/factories/users.rb
FactoryBot.define do
  factory :user do
    name { "Michael Example" }
    sequence(:email) { |n| "michael_#{n}@example.com" }
    password { "password" }
    password_confirmation { "password" }
    activated { true }
  end
  
  factory :other_user, class: User do
    name { "Sterling Archer" }
    sequence(:email) { |n| "duchess_#{n}@example.gov" }
    password { "foobar" }
    password_confirmation { "foobar" }
    activated { true }
  end
  
  factory :no_activation_user, class: User do
    name { "No Activation" }
    sequence(:email) { |n| "no_#{n}@activation.co.jp" }
    password { "foobar" }
    password_confirmation { "foobar" }
    activated { false }
  end
end
```

microposts.rbではtraitを使って、Micropostモデル内を区切りました。
users.rbではsequenceを使って、メールアドレスの一意性を保つよう番号をつけました。
以上で準備は完了です。

※ 変更により他のテストが失敗する可能性があるので、適宜変更を加えてください。

## Micropostモデルのテスト
それではいよいよテストに入りましょう。
このテストでは以下を確認します。

* モデルが正しく生成されているか
* いずれの値も空の場合（user_idを除く）、Micropostは存在しないか
* カラムが最新のものから順に並んでいるか
* user_idが存在しないMicropostは存在しないか
* memoが255文字を超えないか

```spec/models/micropost_spec.rb
require 'rails_helper'

RSpec.describe Micropost, type: :model do
  
  let(:user) { create(:user) }
  let(:micropost) { user.microposts.build(time: 240, memo: "Lorem ipsum", user_id: user.id) }

  describe "Micropost" do
    it "should be valid" do
      expect(micropost).to be_valid
    end
    
    it "should not be valid" do
      micropost.update_attributes(time: 1, memo: "  ", picture: nil, user_id: user.id)
      expect(micropost).to be_valid
      micropost.update_attributes(time: nil, memo: "  ", picture: nil, user_id: user.id)
      expect(micropost).to be_invalid
    end
    
    it "should be most recent first" do
      create(:memos, :memo_1, created_at: 10.minutes.ago)
      create(:memos, :memo_2, created_at: 3.years.ago)
      create(:memos, :memo_3, created_at: 2.hours.ago)
      memo_4 = create(:memos, :memo_4, created_at: Time.zone.now)
      expect(Micropost.first).to eq memo_4
    end
  end
    
  describe "user_id" do
    it "should not be present" do
      micropost.user_id = nil
      expect(micropost).to be_invalid
    end
  end
  
  describe "memo" do
    it "should not be at most 255 characters" do
      micropost.memo = "a" * 255
      expect(micropost).to be_valid
      micropost.memo = "a" * 256
      expect(micropost).to be_invalid
    end
  end
end
```

## Userモデルのテスト
このテストでは以下を確認します。

* ユーザが削除されたら投稿も削除されるか

```rb:spec/models/user_spec.rb
require 'rails_helper'

RSpec.describe User, type: :model do
  # 中略
  it "destroys assosiated microposts" do
    user.microposts.create!(memo: "Lorem Ipsum")
    expect{ user.destroy }.to change{ Micropost.count }.by(-1)
  end
```

以上でテストは終了です。
次回は投稿機能を実装します。

前回：[#13 パスワード再設定編](https://qiita.com/aokyo17/items/28e12c9ad40cac9e528b)
次回：[#15 投稿機能, Active Storage編](https://qiita.com/aokyo17/items/8769ecff3b7c08dca70b)