Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails5「中級」チュートリアル(3-6)投稿機能: 特定のブランチ(翻訳)

概要

概要

原著者の許諾を得て翻訳・公開いたします。

Rails5中級チュートリアルはセットアップが短めで、RDBMSにはPostgreSQL、テストにはRSpecを用います。
原文が非常に長いので分割します。章ごとのリンクは順次追加します。

注意: Rails中級チュートリアルは、Ruby on Railsチュートリアル(https://railstutorial.jp/)(Railsチュートリアル)とは著者も対象読者も異なります。

目次

Rails5「中級」チュートリアル(3-6)投稿機能: 特定のブランチ(翻訳)

各投稿は、ある特定のブランチに属します。別ブランチのための特定のページを作成しましょう。

新しいブランチに切り替えます。

git checkout -b specific_branches

homeページのサイドメニュー

homeページのサイドメニューの更新に取りかかりましょう。特定のブランチへのリンクを追加します。index.html.erbファイルを開きます。

views/pages/index.html.erb

#side-menu要素の中にリンクをいくつか追加することにします。ファイルの中身をパーシャルに切り出しておきましょう。今やっておかないと、たちまち乱雑になってしまいます。
#side-menu要素と#main-content要素をカットしてそれぞれ別のパーシャルファイルに貼り付けます。pagesディレクトリの下にindexディレクトリを作成し、要素に対応するパーシャルファイルをそこに作成します。作成後のファイルは以下のようになります(Gist)。

<!-- views/pages/index/_side_menu.html.erb -->
<div id="side-menu"  class="col-sm-3">
</div><!-- side-menu -->
<!-- views/pages/index/_main_content.html.erb -->
<div id="main-content" class="col-sm-9">
  <%= render @posts %>
</div><!-- main-content -->

homeページテンプレート内でこれらのパーシャルファイルをレンダリングします。このファイルは以下のようになります(Gist)。

<!-- views/pages/index.html.erb -->
<%= render 'posts/modal' %>

<div class="container">
  <div class="row">
    <%= render 'pages/index/side_menu' %>
    <%= render 'pages/index/main_content' %>
  </div><!-- row -->
</div><!-- container -->

変更をcommitします。

git add -A
git commit -m "Split home page template's content into partials"

_side_menu.html.erbパーシャルにリンクのリストを追加します。追加後は以下のようになります(Gist)。

<!-- views/pages/index/_side_menu.html.erb -->
<div id="side-menu"  class="col-sm-3">
  <ul id="links-list">
    <%= render 'pages/index/side_menu/no_login_required_links' %>
  </ul>
</div><!-- side-menu -->

これで順序なしリストが追加されます。このリストの中で、リンクを持つ別のパーシャルをレンダリングしましょう。このリンクは、サインインしているかどうかにかかわらずすべてのユーザーに表示されます。このパーシャルファイルを作成してリンクを追加します。

indexディレクトリの下にside_menuディレクトリを作成します。

views/pages/index/side_menu

このディレクトリの下に_no_login_required_links.html.erbパーシャルを作成し、以下のコードを追加します(Gist)。

<!-- views/pages/index/side_menu/_no_login_required_links.html.erb -->
<li id="hobby">
  <%= link_to hobby_posts_path do %>
    <i class="fa fa-user-circle-o" aria-hidden="true"></i> Find a hobby buddy
  <% end %>
</li>

<li id="study">
  <%= link_to study_posts_path do %>
    <i class="fa fa-graduation-cap" aria-hidden="true"></i> Find a study buddy
  <% end %>
</li>

<li id="team">
  <%= link_to team_posts_path do %>
    <i class="fa fa-users" aria-hidden="true"></i> Find a team member
  <% end %>
</li>

ここでは、投稿の特定のブランチへのリンクをいくつか足しているだけです。hobby_posts_pathなどのパスをどこから得たらよいかわからない場合は、routes.rbファイルをご覧ください。さっきcollectionのネストをルーティングのresources:posts宣言の中に書いてあります。

ここでi要素の属性を注意深く見てみると、faクラスがあることに気づくでしょう。このクラスがあることでFont Awesomeのアイコンが宣言されます。Font Awesomeライブラリのセットアップはまだですが、幸いセットアップはとても簡単です。メインのapplication.html.erbファイルのhead要素の中に以下を追加します。

<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">

これで以下のようにサイドメニューが表示されるはずです。

変更をcommitします。

git add -A
git commit -m "Add links to the home page's side menu"

小さい画面(幅767px1000px)でのBootstrapコンテナの表示がつぶれすぎていてよろしくないので、この幅の範囲で広げましょう。mobile.scssファイルに以下のコードを追加します(Gist)。

// assets/stylesheets/responsive/mobile.scss
...
@media only screen and (min-width:767px) and (max-width: 1000px) {
  .container {
     width: 100% !important;
  }
}

変更をcommitします。

git add -A
git commit -m "set .container width to 100%
when viewport's width is between 767px and 1000px"

ブランチページ

サイドメニューのリンクのひとつをクリックしてみるとエラーが表示されます。まだPostsControllerにアクションがなく、このコントローラに対応するテンプレートもありません。

PostControllerhobbystudyteamアクションを定義します(Gist)。

# controllers/posts_controller.rb
...
  def hobby
    posts_for_branch(params[:action])
  end

  def study
    posts_for_branch(params[:action])
  end

  def team
    posts_for_branch(params[:action])
  end
...

どのアクションもposts_for_branchを呼び出しています。このメソッドはアクション名に応じて特定ページのデータを返します。このメソッドをprivateスコープで定義しましょう(Gist)。

# contorllers/posts_controller.rb
...
private

def posts_for_branch(branch)
  @categories = Category.where(branch: branch)
  @posts = get_posts.paginate(page: params[:page])
end
...

@categoriesインスタンス変数は、特定のブランチから取り出したすべてのカテゴリです。たとえば、hobbyブランチページを開いたとすると、hobbyブランチに属するすべてのカテゴリが取り出されます。

投稿を取得して@postsインスタンス変数に保存するのにget_postsを使っており、その後ろにpagenateメソッドがチェインされています。paginateメソッドはwill_paginate gemによって提供されます。まずget_postsメソッドを定義しましょう。PostsControllerprivateスコープに以下を追加します(Gist)。

# controllers/posts_controller.rb
...
def get_posts
  Post.limit(30)
end
...

現時点のget_postsメソッドは投稿をきっかり30件取り出しますが、投稿の種類が絞り込まれていません。ここはもう少し改良できそうなので、後でこのメソッドに戻ることにします。

will_pagenate gemを追加してページネーションを利用できるようにします。

gem 'will_paginate', '~> 3.1.0'

以下を実行します。

bundle install

後足りないのはテンプレートだけです。テンプレートはどのブランチでも似たようなものなので、同じコードを何度も書くのではなく、すべてのブランチで共通する一般的な構造を備えたパーシャルを作成しましょう。postsディレクトリの下に_branch.html.erbファイルを作成します(Gist)。

<!-- posts/_branch.html.erb -->
<div id="branch-main-content" class="container">
  <div class="row">
    <h1 class="page-title"><%= page_title %></h1>
    <%= render 'posts/branch/create_new_post', branch: branch %>
  </div><!-- row -->

  <div class="row">
    <%= render 'posts/branch/categories', branch: branch %>
  </div>

  <div class="row">
    <div class="col-sm-12" id="feed">
      <%= render @posts %>
      <%= render no_posts_partial_path %>
    </div>
  </div><!-- row -->

  <div class="infinite-scroll">
    <%= will_paginate @posts %>
  </div>
</div><!-- container -->

ページの冒頭でpage_title変数が出力されていることがわかります。_branch.html.erbパーシャルをレンダリングするときにこの変数を引数として渡します。次に、リンクを表示する_create_new_postが出力されます。ユーザーはこのリンク先で新しい投稿を作成できます。branchディレクトリの下にこのパーシャルファイルを作成しましょう(Gist)。

<!-- posts/branch/_create_new_post.html.erb -->
<div class="col-sm-12">
  <div class="col-sm-8 col-sm-offset-2">
    <%= render create_new_post_partial_path, branch: branch %>
  </div><!-- col-sm-8 -->
</div><!-- col-sm-12 -->

レンダリングするパーシャルファイルの決定にはcreate_new_post_partial_pathヘルパーメソッドを使うことにします。posts_helper.rbファイルに以下のメソッドを実装します(Gist)。

# helpers/posts_helper.rb
...
  def create_new_post_partial_path
    if user_signed_in?
      'posts/branch/create_new_post/signed_in'
    else
      'posts/branch/create_new_post/not_signed_in'
    end
  end
...

create_new_postディレクトリを新しく作り、対応するパーシャルを2つその下に作成します(GistGist)。

<!-- posts/branch/create_new_post/_signed_in.html.erb -->
<div class="new-post-button-parent">
  <span>Cannot find anyone? Try to: </span>
  <%= link_to "Create a new post",
              new_post_path(branch: branch),
              :class => "new-post-button" %>
</div>
<!-- posts/branch/create_new_post/_not_signed_in.html.erb -->
<div class="text-center login-branch">
  To create a new post you have to
  <%= link_to 'Login',
              login_path,
              class: 'login-button login-button-branch' %>
</div>

次に_branch.html.erbファイルでカテゴリのリストを表示します。_categories.html.erbパーシャルファイルを作成します(Gist)。

<!-- posts/branch/_categories.html.erb -->
<% branch_path_name = "#{params[:action]}_posts_path" %>

<div class="col-sm-12">
  <ul class="categories-list">
    <%= render all_categories_button_partial_path,
               branch_path_name: branch_path_name %>
    <% @categories.each do |category| %>
      <li class="category-item">
        <%= link_to category.name,
                    send(branch_path_name, category: category.name),
                    :class => ("selected-item" if params[:category] == category.name) %>
      </li>
    <% end %>
  </ul>
</div><!-- col-sm-12 -->

このファイルでは、レンダリングするファイルの決定にall_categories_button_partial_pathヘルパーメソッドを使っています。このメソッドをposts_helper.rbファイルで定義しましょう(Gist)。

# helpers/posts_helper.rb
 ...
   def all_categories_button_partial_path
    if params[:category].blank?
      'posts/branch/categories/all_selected'
    else
      'posts/branch/categories/all_not_selected'
    end
  end
  ...

デフォルトではすべてのカテゴリが選択されます。params[:category]が空の場合は、ユーザーが選んだカテゴリが何もないことを表し、すなわちデフォルト値のallが選択されます。対応するパーシャルファイルを作成しましょう(GistGist)。

<!-- posts/branch/categories/_all_selected.html.erb -->
<li class="category-item">
  <%= link_to "All",
              send(branch_path_name),
              :class => "selected-item"  %>
</li>
<!-- posts/branch/categories/_all_not_selected.html.erb -->
<li class="category-item">
  <%= link_to "All", send(branch_path_name) %>
</li>

このsendメソッドは、文字列で表されるメソッドを呼び出すのに使われています。この方法によって柔軟性が高まり、メソッド呼び出しが動的になります。ここでは、現在のコントローラアクションに応じて異なるパスを生成しています。

次に、_branch.html.erbの内部で投稿をレンダリングしてno_posts_partial_pathヘルパーメソッドを呼び出します。投稿が見つからない場合はメソッドがメッセージを表示します。

posts_helper.rbに以下のヘルパーメソッドを追加します(Gist)。

# helpers/posts_helper.rb
...
def no_posts_partial_path
  @posts.empty? ? 'posts/branch/no_posts' : 'shared/empty_partial'
end
...

ここでは三項演算子を用いてコードを少しすっきりさせています。私は投稿が何もない場合にはメッセージを表示したくないと考えています。renderメソッドには空文字列を渡せないので、代わりに空のパーシャルへのパスを渡しています。空のパーシャルは何も表示したくないときに使います。

ビューにsharedディレクトリを作成して空のパーシャルを作成します。

views/shared/_empty_partial.html.erb

続いて、branchディレクトリの下にメッセージ表示用の_no_posts.html.erbパーシャルを作成します。(Gist)。

<!-- posts/branch/_no_posts.html.erb -->
<div class="text-center">Currently there are no published posts</div>

最後に、投稿数が多い場合にはgemのwill_paginateメソッドを用いて投稿を複数ページに分割します。

hobby/study/teamアクションに対応するテンプレートをそれぞれを作成します。それらのテンプレートで_branch.html.erbパーシャルファイルをレンダリングして特定のローカル変数を渡します(GistGistGist)。

<!-- posts/hobby.html.erb -->
<%= render 'posts/modal' %>
<%= render partial: 'posts/branch', locals: {
    branch: 'hobby',
    page_title: 'Find a person with the same hobby',
    search_placeholder: 'E.g. guitar playing, programming, cooking'
  } %>
<!-- posts/study.html.erb -->
<%= render 'posts/modal' %>
<%= render partial: 'posts/branch', locals: {
    branch: 'study',
    page_title: 'Find a person who studies the same field as you',
    search_placeholder: 'E.g. nutrition, calculus, astrophysics'
  } %>
<!-- posts/team.html.erb -->
<%= render 'posts/modal' %>
<%= render partial: 'posts/branch', locals: {
    branch: 'team',
    page_title: 'Find a person with similar interests as yours to your team',
    search_placeholder: 'E.g. musician for a band, developer for a project'
  } %>

これでブランチページのいずれかを表示すると、以下のように表示されます。

ページを下までスクロールすると、ページネーションもできるようになっています。

ブランチページの作成作業がだいぶ増えてきたので、ここで変更をcommitしましょう。

git add -A
git commit -m "Create branch pages for specific posts

- Inside the PostsController define hobby, study and team actions.
  Define a posts_for_branch method and call it inside these actions
- Add will_paginate gem
- Create a _branch.html.erb partial file
- Create a _create_new_post.html.erb partial file
- Define a create_new_post_partial_path helper method
- Create a _signed_in.html.erb partial file
- Create a _not_signed_in.html.erb partial file
- Create a _categories.html.erb partial file
- Define a all_categories_button_partial_path helper method
- Create a _all_selected.html.erb partial file
- Create a _all_not_selected.html.erb partial file
- Define a no_posts_partial_path helper method
- Create a _no_posts.html.erb partial file
- Create a hobby.html.erb template file
- Create a study.html.erb template file
- Create a team.html.erb template file"

spec

ヘルパーメソッドをspecでカバーしましょう。posts_helper_spec.rbファイルは次のような感じになります(Gist)。

# spec/helpers/posts_helper_spec.rb
require 'rails_helper'

RSpec.describe PostsHelper, :type => :helper do

  context '#create_new_post_partial_path' do
    it "returns a signed_in partial's path" do
      helper.stub(:user_signed_in?).and_return(true)
      expect(helper.create_new_post_partial_path). to (
        eq 'posts/branch/create_new_post/signed_in'
      )
    end

    it "returns a signed_in partial's path" do
      helper.stub(:user_signed_in?).and_return(false)
      expect(helper.create_new_post_partial_path). to (
        eq 'posts/branch/create_new_post/not_signed_in'
      )
    end
  end

  context '#all_categories_button_partial_path' do
    it "returns an all_selected partial's path" do
      controller.params[:category] = ''
      expect(helper.all_categories_button_partial_path).to (
        eq 'posts/branch/categories/all_selected'
      )
    end

    it "returns an all_not_selected partial's path" do
      controller.params[:category] = 'category'
      expect(helper.all_categories_button_partial_path).to (
        eq 'posts/branch/categories/all_not_selected'
      )
    end
  end

  context '#no_posts_partial_path' do
    it "returns a no_posts partial's path" do
      assign(:posts, [])
      expect(helper.no_posts_partial_path).to (
        eq 'posts/branch/no_posts'
      )
    end

    it "returns an empty partial's path" do
      assign(:posts, [1])
      expect(helper.no_posts_partial_path).to (
        eq 'shared/empty_partial'
      )
    end
  end
end

このspecもかなりシンプルです。ここではstubメソッドを用いてメソッドの戻り値を定義しました。paramsの定義は、controller.params[:param_name]のようにコントローラを選択してシンプルに行っています。最後に、インスタンス変数の代入にはassignメソッドを使っています。

変更をcommitします。

git add -A
git commit -m "Add specs for PostsHelper methods"

画面デザインの変更

ブランチページに表示する投稿のデザインを変えてみたいと思います。homeページではカード形式のデザインを使っています。ブランチページでリスト形式のデザインを作成し、ユーザーが多数の投稿を効率よく閲覧できるようにしてみましょう。

postsディレクトリの下にpostディレクトリを作成し、そこに_home_page.html.erbパーシャルファイルを作成します。

posts/post/_home_page.html.erb

_post.html.erbパーシャルの内容をカットし、この_home_page.html.erbファイルに貼り付けます。_post.html.erbパーシャルファイルには以下のコードを追加します(Gist)。

<!-- posts/_post.html.erb -->
<%= render post_format_partial_path, post: post %>

ここで呼んでいるpost_format_partial_pathヘルパーメソッドは、現在のパスに応じて、投稿をどのデザインでレンダリングするかを選択します。ユーザーがhomeページにいる場合はhomeページ向けのデザインでレンダリングし、ブランチページにいる場合はブランチページ向けのデザインでレンダリングします。_post.html.erbファイルの内容を_home_page.html.erbに移動したのはそのためです。

postディレクトリに_branch_page.html.erbファイルを作成し、ブランチページ向けの画面デザインを定義する以下のコードを貼り付けます(Gist)。

<!-- posts/post/_branch_page.html.erb -->
<div class="single-post-list" id=<%= post_path(post.id) %>>
  <%= truncate(post.title, :length => 60) %>
  <div class="post-content">
    <div class="posted-by">Posted by <%= post.user.name %></div>
    <h3><%= post.title %></h3>
    <p><%= post.content %></p>
    <%= link_to "I'm interested", post_path(post.id), class: 'interested' %>
  </div>
</div>

レンダリングするパーシャルファイルを決定するpost_format_partial_pathヘルパーメソッドをposts_helper.rbで定義します(Gist)。

# helpers/posts_helper.rb
def post_format_partial_path
  current_page?(root_path) ? 'posts/post/home_page' : 'posts/post/branch_page'
end

投稿のレンダリングはhomeページのテンプレート内で行われるため、担当するコントローラが異なります。このため、このままではpost_format_partial_pathヘルパーメソッドはhomeページで呼び出せません。このメソッドをhomeページのテンプレート内で使えるようにするには、ApplicationHelper(helper/application_helper.rb)の内側にPostsHelperをインクルードします。

include PostsHelper

spec

post_format_partial_pathヘルパーメソッドのspecを追加します(Gist)。

# helpers/posts_helper_spec.rb
context '#post_format_partial_path' do
  it "returns a home_page partial's path" do
    helper.stub(:current_page?).and_return(true)
    expect(helper.post_format_partial_path).to (
      eq 'posts/post/home_page'
    )
  end

  it "returns a branch_page partial's path" do
    helper.stub(:current_page?).and_return(false)
    expect(helper.post_format_partial_path).to (
      eq 'posts/post/branch_page'
    )
  end
end

変更をcommitします。

git add -A
git commit -m "Add specs for the post_format_partial_path helper method"

CSS

ブランチページの投稿スタイルをCSSで記述しましょう。CSSのpostsディレクトリの下に以下の内容でbranch_page.scssファイルを作成します(Gist)。

// stylesheets/partials/posts/branch_page.scss
.single-post-list {
  min-height: 45px;
  max-height: 45px;
  padding: 10px 20px 10px 0px;
  margin: 0 10px;
  border-bottom: solid 3px rgba(0, 0 , 0, 0.05);
  border-bottom-right-radius: 10%;
  transition: border-color 0.1s;
  overflow: hidden;
  &:hover {
    cursor: pointer;
  }
}

.page-title {
  margin: 30px 0;
  text-align: center;
  background-color: white !important;
  font-weight: bold;
  a {
    color: black;
  }
  a:hover {
    text-decoration: underline;
  }
}

.categories-list {
  margin: 10px 0;
  padding: 0;
}

.category-item {
  display: inline-block;
  margin: 15px 0;
  a {
    font-size: 16px;
    font-size: 1.6rem;
    color: rgba(0,0,0,0.7);
    border: solid 2px rgba(0,0,0,0.4);
    border-radius: 8%;
    padding: 10px;
  }
  a:hover, .selected-item {
    background: $navbarColor;
    color: white;
    border: solid 2px white;
    border-radius: 0px;
  }
}

.new-post-button-parent {
  text-align: right;
  span {
    font-size: 12px;
    font-size: 1.2rem;
  }
}

.new-post-button {
  display: inline-block;
  background: $navbarColor;
  color: white;
  padding: 8px;
  border-radius: 10px;
  font-weight: bold;
  border: solid 2px $navbarColor;
  margin: 10px 0;
  &:hover, &:active, &:focus {
    background: white;
    color: black;
  }
}

.login-branch {
  margin: 10px 0;
}

.login-button-branch {
  padding: 5px 10px;
  border-radius: 10px;
  &:hover, &:active, &:visited, &:link {
    color: white;
  }
}

#branch-main-content {
  background: white;
  height: calc(100vh - 50px);
}

#feed {
  background-color: white;
}

base/default.scssに以下を追加します(Gist)。

// assets/stylesheets/base/default.scss
.login-button, .sign-up-button {
  background-color: $navbarColor;
  color: white !important;
}

小画面デバイスでの表示を修正するため、responsive/mobile.scssに以下を追加します(Gist)。

// assets/stylesheets/responsive/mobile.scss
...
@media screen and (max-width: 550px) {
  .page-title {
      font-size: 20px;
      font-size: 2rem;
  }
  .new-post-button-parent {
    text-align: center;
    span {
      display: none !important;
    }
  }
  .post-button {
    padding: 5px;
  }
  .category-item {
    a {
      padding: 5px;
    }
  }
}

@media screen and (max-width: 767px) {
  .single-post-list {
    min-height: 65px;
    max-height: 65px;
    padding: 10px 0;
  }
}
...

訳注: application.scssに以下を追加する必要もあります。

// assets/stylesheets/application.scss
@import "partials/posts/*";

これでブランチページは次のように表示されるはずです。

変更をcommitします。

git add -A
git commit -m "Describe the posts style in branch pages

- Create a branch_page.scss file and add CSS
- Add CSS to the default.scss file
- Add CSS to the mobile.scss file"

検索バー

投稿リストを閲覧できるだけではなく、特定の投稿を検索できるようにもしたいと思います。_branch.html.erbパーシャルファイルのcategories rowの直前に以下を追加します(Gist)。

<!-- posts/_branch.html.erb -->
...
<div class="row">
  <%= render  'posts/branch/search_form',
              branch: branch,
              search_placeholder: search_placeholder %>
</div><!-- row -->
...

branchディレクトリの下に_search_form.html.erbパーシャルファイルを作成し、以下のコードを追加します(Gist)。

<!-- posts/branch/_search_form.html.erb -->
<div class="col-sm-12">
  <%= form_tag(send("#{branch}_posts_path"),
               :method => "get",
               id: "search-form") do %>
    <i class="fa fa-search" aria-hidden="true"></i>
    <%= text_field_tag  :search,
                        params[:search],
                        placeholder: search_placeholder,
                        class: "form-control" %>
    <%= render category_field_partial_path %>
  <% end %>
</div><!-- col-sm-12 -->

上のコードでは、sendメソッドを使ってPostsControllerの特定のアクションへのパスを現在のブランチに応じて動的に生成しています。また、特定のカテゴリが選択されている場合にはカテゴリ用のデータフィールドも送信します。ユーザーがカテゴリのひとつを選択すると、そのカテゴリに該当する結果だけを返します。

posts_helper.rbファイルにcategory_field_partial_pathヘルパーメソッドを定義します(Gist)。

# helpers/posts_helper.rb
...
  def category_field_partial_path
    if params[:category].present?
      'posts/branch/search_form/category_field'
    else
      'shared/empty_partial'
    end
  end
...

search_formディレクトリを作成し、その下に_category_field.html.erbパーシャルファイルを作成して以下のコードを追加します(Gist)。

<!-- posts/branch/search_form/_category_field.html.erb -->
<%= hidden_field_tag :category, params[:category] %>

検索フォームのスタイルを整えるため、branch_page.scssファイルに以下を追加します(Gist)。

// assets/stylesheets/partials/posts/branch_page.scss
.fa-search {
  position:absolute;
  bottom:14px;
  left:10px;
  width:20px;
  height:10px;
}

#search-form {
  position:relative;
  input {
    border: solid 2px rgba(0,0,0,0.2);
    border-radius: 10px;
    box-shadow: none;
    outline: 0;
  }
  input:focus {
    border: solid 2px rgba(0,0,0,0.35);
  }
  input#search {
    padding: 15px;
    width: 100%;
    height:20px;
    margin: 10px 0;
    padding-left: 30px;
  }
}

これで、ブランチページの検索フォームが以下のように表示されるはずです。

変更をcommitします。

git add -A
git commit -m "Add a search form in branch pages

- Render a search form inside the _branch.html.erb
- Create a _search_form.html.erb partial file
- Define a category_field_partial_path helper method in PostsHelper
- Create a `_category_field.html.erb` partial file
- Add CSS for the the search form in branch_page.scss"

このフォームはまだ機能していません。検索機能を使える何らかのgemを追加してもよいのですが、まだデータは複雑ではないので、簡単な検索エンジンを独自に作成することもできます。ここではPostモデルでスコープを用いてクエリをチェインできるようにし、コントローラに条件ロジックを追加します(次のセクションではこのコードをService Objectに切り出してコードをすっきりさせる予定です)。

まずはPostモデルでスコープを定義しましょう。手始めに、post.rbファイルでdefault_scopeを定義します。このスコープでは投稿を作成日で降順ソートし、最新の投稿がトップに来るようにします(Gist)。

# models/post.rb
...
default_scope -> { includes(:user).order(created_at: :desc) }
...

訳注: default_scopeについては次の記事もどうぞ。

Railsのdefault_scopeは使うな、絶対(翻訳)

変更をcommitします。

git add -A
git commit -m "Define a default_scope for posts"

default_scopeが正常に機能していることを確認するため、specに含めましょう。post_spec.rbファイルに以下を追加します(Gist)。

# spec/models/post_spec.rb
context 'Scopes' do
  it 'default_scope orders by descending created_at' do
    first_post = create(:post)
    second_post = create(:post)
    expect(Post.all).to eq [second_post, first_post]
  end
end

変更をcommitします。

git add -A
git commit -m "Add a spec for the Post model's default_scope"

それでは検索バーが機能するようにしてみましょう。posts_controller.rbget_postsメソッドの内容を以下で置き換えます(Gist)。

# controllers/posts_controller.rb
def get_posts
  branch = params[:action]
  search = params[:search]
  category = params[:category]

  if category.blank? && search.blank?
    posts = Post.by_branch(branch).all
  elsif category.blank? && search.present?
    posts = Post.by_branch(branch).search(search)
  elsif category.present? && search.blank?
    posts = Post.by_category(branch, category)
  elsif category.present? && search.present?
    posts = Post.by_category(branch, category).search(search)
  else
  end
end

ビューのときと同様、コントローラにこういうロジックを置くのはあまりよくありません。ここをもっとスッキリさせたいので、この後のセクションでこのメソッドのロジックを切り出す予定です。

このコードでは条件ロジックがいくつか連続しています。ユーザーからのリクエストに応じて、データをクエリするときのスコープを切り替えています。

Postモデルに以下のスコープを定義します(Gist)。

# models/post.rb
...
  scope :by_category, -> (branch, category_name) do
    joins(:category).where(categories: {name: category_name, branch: branch})
  end

  scope :by_branch, -> (branch) do
    joins(:category).where(categories: {branch: branch})
  end

  scope :search, -> (search) do
    where("title ILIKE lower(?) OR content ILIKE lower(?)", "%#{search}%", "%#{search}%")
  end
...

関連付けられたテーブルのレコードへのクエリには[joins]https://railsguides.jp/active_record_querying.html#joins)メソッドを使います。また、与えられた文字列を元に基本的なSQL文法を用いてレコードを検索しています。

これでサーバーを再起動していずれかのブランチページを表示すれば、検索バーが使えるようになっているはずです。また、カテゴリボタンをクリックしてカテゴリでフィルタすることも、特定のカテゴリを選択している状態で検索することでそのカテゴリに属する投稿だけを検索することもできます。

変更をcommitします。

git add -A
git commit -m "Make search bar and category filters
in branch pages functional

- Add by_category, by_branch and search scopes in the Post model
- Modify the get_posts method in PostsController"

これらのスコープをspecでカバーしましょう。post_spec.rbファイルの Scopes contextに以下を追加します(Gist)。

# spec/models/post_spec.rb
  it 'by_category scope gets posts by particular category' do
    category = create(:category)
    create(:post, category_id: category.id)
    create_list(:post, 10)
    posts = Post.by_category(category.branch, category.name)
    expect(posts.count).to eq 1
    expect(posts[0].category.name).to eq category.name
  end

  it 'by_branch scope gets posts by particular branch' do
    category = create(:category)
    create(:post, category_id: category.id)
    create_list(:post, 10)
    posts = Post.by_branch(category.branch)
    expect(posts.count).to eq 1
    expect(posts[0].category.branch).to eq category.branch
  end

  it 'search finds a matching post' do
    post = create(:post, title: 'awesome title', content: 'great content ' * 5)
    create_list(:post, 10, title: ('a'..'c' * 2).to_a.shuffle.join)
    expect(Post.search('awesome').count).to eq 1
    expect(Post.search('awesome')[0].id).to eq post.id
    expect(Post.search('great').count).to eq 1
    expect(Post.search('great')[0].id).to eq post.id
  end

変更をcommitします。

git add -A
git commit -m "Add specs for Post model's
by_branch, by_category and search scopes"

無限スクロール機能

ブランチページのいずれかを表示すると、最下部に以下のページネーションが表示されています。

[Next]のリンクをクリックすると、現在より古い記事のページにリダイレクトされます。こうする代わりに、FacebookやTwitterのフィードのように無限スクロールさせることもできます。この場合下にスクロールするだけで、ページの再読み込みやリダイレクトを行わなくても以前の投稿がリストの下に追加されます。驚くことに、この機能はJavaScriptを少し書くだけでとても簡単に実現できるのです。ユーザーがページ最下部までスクロールすると、「次のページ」からデータを取得するAJAX リクエストが常に送信され、リストの最下部に追加されます。

まずはAJAXリクエストとその条件を設定するところから始めましょう。ユーザーがある閾値まで下スクロールすると、AJAXリクエストが発火するようにします。javascripts/postsディレクトリの下にinfinite_scroll.jsファイルを作成し、以下のコードを追加します(Gist)。

// assets/javascripts/posts/infinite_scroll.js
$(document).on('turbolinks:load', function() {
  var isLoading = false;
  if ($('.infinite-scroll', this).length > 0) {
    $(window).on('scroll', function() {
      var more_posts_url = $('.pagination a.next_page').attr('href');
      var threshold_passed = $(window).scrollTop() > $(document).height() - $(window).height() - 60;
      if (!isLoading && more_posts_url && threshold_passed) {
        isLoading = true;
        $.getScript(more_posts_url).done(function (data,textStatus,jqxhr) {
          isLoading = false;
        }).fail(function() {
          isLoading = false;
        });
      }
    });
  }
});

訳注: 原文コードままだと動かなかったため、上のJavaScriptコードは修正してあります(参考)。

isLoadingは、一度に1件のリクエストだけが送信されるようにするための変数です。リクエストが進行中の場合、他のリクエストは開始されません。

最初にページネーション機能の有無と、表示する投稿が他にもあるかどうかをチェックします。次に、次ページへのリンク(ここからデータを取り出します)を取得します。続いてAJAXリクエストを呼び出すときの閾値(threshold)を設定します。ここではウィンドウ最下部から60pxまでを閾値に設定しています。すべての条件がパスしたら、getScript()関数で次のページからデータを読み込みます。

getScript()関数はJavaScriptファイルを読み込むので、どのファイルでレンダリングするかをPostsControllerで指定しなければなりません。レンダリングするファイルは、posts_for_branchメソッドの中でrespond_toの形で指定します(Gist)。

# controllers/posts_controller.rb
respond_to do |format|
  format.html
  format.js { render partial: 'posts/posts_pagination_page' }
end

このコントローラが.jsファイルを用いて応答しようとすると、posts_pagination_pageテンプレートがレンダリングされます。このパーシャルファイルは、新たに取り出した投稿をリストの末尾に追加します。投稿をappendしてページネーション要素を更新するパーシャルファイルを作成しましょう(Gist)。

<!-- posts/_posts_pagination_page.js.erb -->
$('#feed').append('<%= j render @posts %>');
<%= render update_pagination_partial_path %>

posts_helper.rbファイルにupdate_pagination_partial_pathヘルパーメソッドを追加します(Gist)。

# helpers/posts_helper.rb
def update_pagination_partial_path
  if @posts.next_page
    'posts/posts_pagination_page/update_pagination'
  else
    'posts/posts_pagination_page/remove_pagination'
  end
end

ここではwill_paginate gemのnext_pageメソッドを用いて、この後読み込める投稿がまだあるかどうかを決定しています。

対応するパーシャルファイルをそれぞれ作成します(GistGist)。

<!-- posts/posts_pagination_page/_update_pagination.js.erb -->
$('.pagination').replaceWith('<%= j will_paginate @posts %>');
<!-- posts/posts_pagination_page/_remove_pagination.js.erb -->
$(window).off('scroll');
$('.pagination').remove();

これで、いずれかのブランチページで下にスクロールすれば過去の投稿が自動的にリストの下に追加されるはずです。

ページネーションのメニューを表示する必要もなくなったので、CSSで隠しましょう。branch_page.scssファイルに以下を追加します。

# stylesheets/partials/posts/branch_page.scss
...
.infinite-scroll {
  display: none;
}
...

変更をcommitします。

git add -A
git commit -m "Transform posts pagination into infinite scroll

- Create an infinite_scroll.js file
- Inside PostController's posts_for_branch method add respond_to format
- Define an update_pagination_partial_path
- Create _update_pagination.js.erb and _remove_pagination.js.erb partials
- hide the .infinite-scroll element with CSS"

spec

update_pagination_partial_pathヘルパーメソッドをspecでカバーしましょう(Gist)。

# spec/helpers/post_helper_spec.rb
context '#update_pagination_partial_path' do
  it "returns an update_pagination partial's path" do
    posts = double('posts', :next_page => 2)
    assign(:posts, posts)
    expect(helper.update_pagination_partial_path).to(
      eq 'posts/posts_pagination_page/update_pagination'
    )
  end

  it "returns a remove_pagination partial's path" do
    posts = double('posts', :next_page => nil)
    assign(:posts, posts)
    expect(helper.update_pagination_partial_path).to(
      eq 'posts/posts_pagination_page/remove_pagination'
    )
  end
end

ここでは、postsインスタンス変数とそこにチェインされるnext_pageメソッドをdoubleを用いてシミュレートしています。RSpecのモックについて詳しくはこちらをご覧ください。

変更をcommitします。

git add -A
git commit -m "Add specs for the update_pagination_partial_path
helper method"

この時点で、下スクロールすると投稿が下に追加されることを確認できるfeature specを書くこともできます。infinite_scroll_spec.rbファイルを作成します(Gist)。

# spec/features/posts/infinite_scroll_spec.rb
require "rails_helper"

RSpec.feature "Infinite scroll", :type => :feature do
  Post.per_page = 15

  let(:check_posts_count) do
    expect(page).to have_selector('.single-post-list', count: 15)
    page.execute_script("$(window).scrollTop($(document).height())")
    expect(page).to have_selector('.single-post-list', count: 30)
  end

  scenario "User scrolls down the hobby page
            and posts list will be appended with older posts", js: true do
    create_list(:post, 30, category: create(:category, branch: 'hobby'))
    visit hobby_posts_path
    check_posts_count
  end

  scenario "User scrolls down the study page
            and posts list will be appended with older posts", js: true do
    create_list(:post, 30, category: create(:category, branch: 'study'))
    visit study_posts_path
    check_posts_count
  end

  scenario "User scrolls down the team page
            and posts list will be appended with older posts", js: true do
    create_list(:post, 30, category: create(:category, branch: 'team'))
    visit team_posts_path
    check_posts_count
  end

end

上のspecファイルではブランチページをすべてカバーしており、3つのページでこの機能が正常に動作することを確認しています。per_pagewill_paginate gemのメソッドです。ここではPostモデルを選択してページのデフォルト投稿数を設定するのに使っています。

このファイルのコード量を減らすためにcheck_posts_countメソッドを定義しています。同じコードを異なるspecで繰り返すのではなく、単一のメソッドに切り出しています。ページを開いたときに投稿が15件表示されることが期待されています。続いてexecute_scriptメソッドを用いてJavaScriptを実行し、ブラウザのスクロールバーを最下部までスクロールしています。スクロールが終わったら、最後に投稿が15件追加されることが期待されています。これで、ページには投稿が30件表示されます。

変更をcommitします。

git add -A
git commit -m "Add feature specs for posts' infinite scroll functionality"

homeページの更新

現在のhomeページには投稿が数件ランダムに表示されているだけです。これを改修して、すべてのブランチから投稿を数件表示できるようにしましょう。

_main_content.html.erbファイルの内容を以下で置き換えます(Gist)。

<!-- pages/index/_main_content.html.erb -->
<div id="main-content" class="col-sm-9">
  <h3 class="page-name"><%= link_to 'Hobby', hobby_posts_path %></h3>
  <div class="row">
    <%= render @hobby_posts %>
    <%= render no_posts_partial_path(@hobby_posts) %>
  </div><!-- row -->

  <h3 class="page-name"><%= link_to 'Study', study_posts_path %></h3>
  <div class="row">
    <%= render @study_posts %>
    <%= render no_posts_partial_path(@study_posts) %>
  </div><!-- row -->

  <h3 class="page-name"><%= link_to 'Team member', team_posts_path %></h3>
  <div class="row">
    <%= render @team_posts %>
    <%= render no_posts_partial_path(@team_posts) %>
  </div><!-- row -->
</div><!-- main_content -->

ブランチごとに投稿を区切るセクションを作成しました。

PagesControllerindexアクションにインスタンス変数をいくつか定義しましょう。定義後のアクションは次のようになります(Gist)。

# controllers/pages_controller.rb
  def index
    @hobby_posts = Post.by_branch('hobby').limit(8)
    @study_posts = Post.by_branch('study').limit(8)
    @team_posts = Post.by_branch('team').limit(8)
  end

先ほどno_posts_partial_pathヘルパーメソッドを作成しましたが、再利用しやすいように少々変更する必要があります(現在はブランチページでしか使えません)。このメソッドにpostsパラメータを追加すると次のようになります(Gist)。

# helpers/posts_helper.rb
def no_posts_partial_path(posts)
  posts.empty? ? 'posts/shared/no_posts' : 'shared/empty_partial'
end

postsパラメータを追加したことで、インスタンス変数は単純な変数に置き換えられ、パーシャルのパスも変わりました。そこで_no_posts.html.erbパーシャルファイルのパスも以下のように変更します。

posts/branch/_no_posts.html.erb

上のパスを以下に変更します。

posts/shared/_no_posts.html.erb

また、posts/_branch.html.erbファイルのno_posts_partial_pathメソッドを、@postsインスタンス変数を引数として渡すように変更します。

スタイルも少し追加しましょう。default.scssファイルに以下を追加します(Gist)。

// assets/stylesheets/base/default.scss
...
.container {
  padding: 0;
}

.row {
  margin: 0;
}

home_page.scssに以下を追加します(Gist)。

// assets/stylesheets/partials/home_page.scss
.page-name {
  margin: 15px 0px 15px 0px;
  text-align: center;
  background-color: white !important;
  font-weight: bold;
  a {
    color: black;
  }
  a:hover {
    text-decoration: underline;
  }
}
...

これでhomeページが以下のように表示されるはずです。

訳注: specの更新が原文で漏れていたので、以下に補います。

# /spec/helpers/posts_helper_spec.rb
  context '#no_posts_partial_path' do
    it "returns a no_posts partial's path" do
      expect(helper.no_posts_partial_path([])).to (
        eq 'posts/shared/no_posts'
      )
    end

    it "returns an empty partial's path" do
      expect(helper.no_posts_partial_path([1])).to (
        eq 'shared/empty_partial'
      )
    end
  end

変更をcommitします。

git add -A
git commit -m "Add posts from all branches in the home page

- Modify the `_main_content.html.erb file
- Define instance variables inside the PagesControllers index action
- Modify the `no_posts_partial_path helper method to be more reusable
- Add CSS to style the home page"

関連記事

新しいRailsフロントエンド開発(1)Asset PipelineからWebpackへ(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。