概要
- 前回: Rails5「中級」チュートリアル(3-5)投稿機能: 単一の投稿(翻訳)
- 次回: Rails5「中級」チュートリアル(3-7)投稿機能: Service Object(翻訳)
概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: The Ultimate Intermediate Ruby on Rails Tutorial: Let’s Create an Entire App!
- 原文公開日: 2017/12/17
- 著者: Domantas G
Rails5中級チュートリアルはセットアップが短めで、RDBMSにはPostgreSQL、テストにはRSpecを用います。
原文が非常に長いので分割します。章ごとのリンクは順次追加します。
注意: Rails中級チュートリアルは、Ruby on Railsチュートリアル(https://railstutorial.jp/)(Railsチュートリアル)とは著者も対象読者も異なります。
目次
- 1. 序章とセットアップ
- 2. レイアウト
- 3. 投稿
- 3-1 認証
- 3-2 ヘルパー
- 3-3 テスト
- 3-4 メインフィード
- 3-5 単一の投稿
- 3-6 特定のブランチ(本セクション)
- 3-7 Service Object
- 3-8 新しい投稿を作成する
- 4. インスタントメッセージ
- 4-1 非公開チャット
- 4-2 連絡先
- 4-3 グループチャット
- 4-4 メッセンジャー
- 5. 通知
- 5-1 つながりリクエスト
- 5-2 チャット
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"
小さい画面(幅767px
〜1000px
)での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
にアクションがなく、このコントローラに対応するテンプレートもありません。
PostController
にhobby
、study
、team
アクションを定義します(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
メソッドを定義しましょう。PostsController
のprivate
スコープに以下を追加します(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つその下に作成します(Gist、Gist)。
<!-- 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
が選択されます。対応するパーシャルファイルを作成しましょう(Gist、Gist)。
<!-- 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
パーシャルファイルをレンダリングして特定のローカル変数を渡します(Gist、Gist、Gist)。
<!-- 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
については次の記事もどうぞ。
変更を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.rb
のget_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
メソッドを用いて、この後読み込める投稿がまだあるかどうかを決定しています。
対応するパーシャルファイルをそれぞれ作成します(Gist、Gist)。
<!-- 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_page
はwill_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 -->
ブランチごとに投稿を区切るセクションを作成しました。
PagesController
のindex
アクションにインスタンス変数をいくつか定義しましょう。定義後のアクションは次のようになります(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"
- 前回: Rails5「中級」チュートリアル(3-5)投稿機能: 単一の投稿(翻訳)
- 次回: Rails5「中級」チュートリアル(3-7)投稿機能: Service Object(翻訳)