01_書籍管理アプリ

書籍の情報を登録、編集、削除できるMVCアプリを作成します。

アプリ作成前の準備

アプリごとにgemのバージョンが異なる場合が多いので、システム共通ディレクトリではなく、アプリごとのディレクトリにgemをインストールする手順で学習していきます。

# ホームディレクリ直下のruby-campディレクトリに移動します。
$ cd ~/ruby-camp
# アプリ用のディレクトリ(今回はbook_shelf)を作成し、作成したディレクトリに移動する。
$ mkdir book_shelf
$ cd book_shelf

gemを管理するGemfileの作成するために、bundle initコマンドを実行します。

$ bundle init

Gemfileを以下のように修正します。 「# gem "rails"」の#を削除します。 現状だとコメントアウトされているため。 「gem "rails"」の後ろに「, "5.2.1"」を追加します。(今回はバージョンを指定) 追加後は「gem "rails", "5.2.1"」となっていればOKです!

# Gemfile
# frozen_string_literal: true
source "https://rubygems.org"
git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
- # gem "rails"
+ gem "rails", "5.2.1"

--path vendor/bundleを指定し、railsアプリケーション用のディレクトリ配下のvendor/bundleディレクトリにgemをインストールします。 --jobs=4オプションは、gemを並列処理でインストールするというオプジョンになります。(インストールが早く終わるようです) 毎回bundle install --path vendor/bundle --jobs=4を実行するのは長いので、biとコマンドを入力したら、bundle install --path vendor/bundle --jobs=4を実行するようにエイリアスを設定します。

$ vi ~/.bashrc

ファイルを開いたら以下の内容を記述します。

alias bi='bundle install --path vendor/bundle --jobs=4'

.bashrcを読み込んでいる.bash_profileを再度読み込みます。

$ source ~/.bash_profile

biコマンドを実行します。

$ bi

Railsアプリの作成します。 今回はカレントディレクトリ(.)に、5.2.1のRailsでアプリを作成します。

$ bundle exec rails _5.2.1_ new .
# Gemfileがすでに存在するため、以下のメッセージが表示されるので、「y」を押した後にenterを押します。
exist
create README.md
create Rakefile
create .ruby-version
create config.ru
create .gitignore
conflict Gemfile
Overwrite /Users/kazuma/ruby-camp/book_shelf/Gemfile? (enter "h" for help) [Ynaqdh]

Gemfileが上書きされるので、再度バージョン部分を以下のように修正します。 sqlite3の1.4系がリリースされたが、Rails側が対応していないので、, '~> 1.3.6'の指定をします。

# Gemfile
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '2.5.3'
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
- gem 'rails', '~> 5.2.1'
+ gem 'rails', '5.2.1'
# Use sqlite3 as the database for Active Record
- gem 'sqlite3'
+ gem 'sqlite3', '~> 1.3.6'
# Use Puma as the app server
gem 'puma', '~> 3.11'
# 省略

Gemfileを修正したのでもう一度bundle installを実行。 オプション指定は2回目以降は不要。

$ bundle install

.gitignore

Gitで管理する場合は、.gitignoreファイルに以下の行を追記(末尾に1行追記)します。 /vendor/bundle内にGemファイルがインストールされているため、Gitの管理対象外とします。 bundle installを実行することで、Gemをインストールできるので、管理する必要はありません。

/vendor/bundle

vi(vim)で編集してもいいですが、ターミナルで以下のコマンドを実行してすることで、.gitignoreファイルの末尾に追記することが可能です。

$ echo "/vendor/bundle" >> .gitignore

覚える事ができない場合はエイリアスを設定します。

$ vi ~/.bashrc

ファイルを開いたら以下の内容を記述します。

alias vbgi='echo "/vendor/bundle" >> .gitignore'

以下のコマンドを実行することで、.gitignoreに/vendor/bundleを追記することが可能です。

$ vbgi

Bookモデル作成

モデル名:Book

以下の内容でBookモデルを作成します。

カラム名

カラム名(日本語)

データ型

name

書籍名

string

price

価格

integer

publish_date

発売日

date

description

説明

text

モデルを作成する場合は、bin/rails g model [モデル名] [カラム名]:[データ型]で作成しますが、今回はbin/rails g resourceコマンドを使用します。 resourceコマンドを実行することで、関連するコントローラファイルと、Viewディレクトリ(app/views/XXXX)を作成してくれます。

$ bin/rails g resource book title:string price:integer publish_date:date description:text

マイスレーションファイルの修正

データベースに反映する前に、マイグレーションスクリプトを修正します。 全てのカラムを必須入力とするため、null: falseとします。 また、titleとdescriptionは文字数を制限するため、lengthオプションを指定します。

  • title: null: false, length: 50

  • price: null: false

  • publish_date: null: false

  • description: null: false, length: 1000

# db/migrate/yyyyMMddHHmmss_create_books.rb
class CreateBooks < ActiveRecord::Migration[5.2]
def change
create_table :books do |t|
- t.string :title
- t.integer :price
- t.date :publish_date
- t.text :description
+ t.string :title, null: false, length: 50
+ t.integer :price, null: false
+ t.date :publish_date, null: false
+ t.text :description, null: false, length: 1000
t.timestamps
end
end
end

マイグレーションスクリプトに追記したらデータベースに反映します。

$ bin/rails db:migrate

データベースに反映されたことを確認するために、bin/rails db:migrate:statusコマンドを実行します。 Statusが「up」になっていればOKです。 「down」の場合は、修正したマイグレーションスクリプトに問題がある可能性があるので確認してください。

$ bin/rails db:migrate:status
database: /Users/kazuma/ruby-camp/book_shelf/db/development.sqlite3
Status Migration ID Migration Name
--------------------------------------------------
up 20181214071252 Create books

Statusがupの状態でマイグレショーンスクリプトを修正し、bin/rails db:migrateを実行してもデータベースに反映することができません。 修正する場合は一度「bin/rails db:rollback」で直前のマイグレーションを「down」にしてから修正します。

Bootstrapの導入

RailsアプリにBootstrapを導入します。

Gemfileの修正

Bootstrapを使用するために、Gemを追加します。 BootstrapはjQueryに依存するため、(デフォルトでjQueryがインストールされない)Rails5.1以上ではjquery-railsもGemfileに追記します。

# Gemfile
# 省略
gem 'bootsnap', '>= 1.1.0', require: false
+ gem 'bootstrap', '~> 4.1.3'
+ gem 'jquery-rails'
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
end
group :development do
# Access an interactive console on exception pages or by calling 'console' anywhere in the code.
gem 'web-console', '>= 3.3.0'
gem 'listen', '>= 3.0.5', '< 3.2'
# Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
gem 'spring'
gem 'spring-watcher-listen', '~> 2.0.0'
end
# 省略

Gemfileを修正したらbundle installを行います。

$ bi

application.jsの修正

application.jsに、jquery3、popper、bootstrap-sprocketsを記述します。

// app/assets/javascripts/application.js
// 省略
//
//= require rails-ujs
//= require activestorage
//= require turbolinks
+ //= require jquery3
+ //= require popper
+ //= require bootstrap-sprockets
//= require_tree .

application.cssの修正

app/assets/stylesheets/application.cssファイルのファイル名を、 mvコマンドを使用して、application.cssからapplication.scssに変更します。 ファイル名変更後、bootstrapをインポートします。

$ mv app/assets/stylesheets/application.css app/assets/stylesheets/application.scss

app/assets/stylesheets/application.scssでbootstrapをimportします。

注意点

  • Sassファイルでは*= require*= require_treeを削除する

  • Sassファイルではインポートに@importを利用する

  • Sassファイルで*= requireを利用すると他のスタイルシートではBootstrapのmixinや変数を利用できなくなる

/* app/assets/stylesheets/application.scss */
/*
* This is a manifest file that'll be compiled into application.css, which will include all the files
* listed below.
*
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
* vendor/assets/stylesheets directory can be referenced here using a relative path.
*
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
* files in this directory. Styles in this file should be added after the last require_* statement.
* It is generally better to create a new file per style scope.
*
-*= require_tree .
-*= require_self
*/
+@import "bootstrap";

パーシャルフォーム(部分テンプレート)

全てのページにヘッダーを表示するため、コードを書いていきます。複数のページに共通の内容を表示する場合は、特定のファイルに切り出し、そのファイルを読み込むようにします。 切り出したファイルの事を パーシャルフォーム(部分テンプレート) といいます。

ヘッダーのイメージ

パーシャルフォームを利用することで、個々のビューで同じコードを記述する必要がなくなります。 部分テンプレートのファイル名の先頭には 「_」 を付けることによりファイル名からパーシャルであることを明示します。

共通のパーシャルフォームはapp/views/の中にsharedディレクトリを作成し、その中にファイルを作ります。 今回はファイル名を「_header.html.erb」とします。

$ mkdir app/views/shared
$ touch app/views/shared/_header.html.erb

ルートパスの設定

bin/rails g resource bookを実行したので、config/routes.rbにはresources :booksが追記されています。 http://localhost:3000でアクセスした時にindexページが表示されるように追記します。

# config/routes.rb
Rails.application.routes.draw do
resources :books
+ root "books#index"
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end

以下のリンクを参考にBootstrapを使用してヘッダーを作成します。 https://getbootstrap.com/docs/4.1/components/navbar/#toggler

<!-- app/views/shared/_header.html.erb -->
+ <nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-3">
+ <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarToggler" aria-controls="navbarTogglerDemo03" aria-expanded="false" aria-label="Toggle navigation">
+ <span class="navbar-toggler-icon"></span>
+ </button>
+ <%= link_to "BookShelf", root_path, class: "navbar-brand" %>
+ <div class="collapse navbar-collapse" id="navbarToggler">
+ <ul class="navbar-nav mr-auto mt-2 mt-lg-0">
+ <li class="nav-item">
+ <%= link_to "Home", root_path, class: "nav-link" %>
+ </li>
+ <li class="nav-item">
+ <%= link_to "New Book", new_book_path, class: "nav-link" %>
+ </li>
+ </ul>
+ <form class="form-inline my-2 my-lg-0">
+ <input class="form-control mr-sm-2" type="search" placeholder="Search" aria-label="Search">
+ <button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
+ </form>
+ </div>
+ </nav>

通常使用するindex.htmlやshow.htmlにはheadタグやdobyタグが存在しません。app/views/layouts/application.htmlのyieldの部分に、index.html.erbやshow.html.erbが呼び出されるイメージです。 ヘッダーは全てのビューに表示したいので、application.html.erbから呼び出します。

パーシャルフォームの呼び出し

render [ファイル名]で呼び出す事が可能です。今回は別のディレクトリにheader.html.erbが存在するため、render "shared/header"で呼び出すことが可能です。 呼び出す場合は、ファイル名の「」を除いた名前で呼び出します。 Bootstrapのグリッドシステムを使用するため、yeildをcontainerとrowのdivタグで囲います。

<!-- application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>BookShelf</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>
<body>
+ <%= render "shared/header" %>
+ <div class="container">
+ <div class="row">
<%= yield %>
+ </div>
+ </div>
</body>
</html>

indexアクション

ヘッダーの表示確認をするため、indexアクションとindex.html.erbを作成します。

# app/controllers/books_controller.rb
class BooksController < ApplicationController
+ def index
+ @books = Book.all
+ end
end

index.html.erb

ファイルは作成しますが、中身は何も記述しません。

$ touch app/views/books/index.html.erb

確認

サーバを起動し、http://localhost:3000にアクセスして、ヘッダーが表示されることを確認します。

$ bin/rails s

Bookの登録機能

Bookの登録機能を作成します。

newアクションとcreateアクション

データを登録する場合は、newとcreateアクションが必要になります。 ストロングパラメータと一緒に作成します。

flash

Railsには簡易的なメッセージを表示する、flashという機能があります。 今回は登録後にメッセージを表示するようにします。 redirect_toのnoticeオプションを指定します。 notice: [メッセージ]で表示することが可能です。 「書籍を登録しました。」のメッセージを表示する部分は、照会機能を作成する際に追加します。

# app/controllers/books_controller.rb
class BooksController < ApplicationController
def index
@books = Book.all
end
+ def new
+ @book = Book.new
+ end
+ def create
+ @book = Book.new(book_params)
+ @book.save
+ redirect_to @book, notice: "書籍を登録しました。"
+ end
+ private
+ def book_params
+ params.require(:book).permit(:title, :price, :publish_date, :description)
+ end
end

new.html.erbと_form.html.erb

登録用フォームのnew.html.erbを作成します。登録と更新フォームは内容が一緒になるので、_form.html.erbを作成し、new.html.erbから呼び出します。

# new.html.erbの作成
$ touch app/views/books/new.html.erb
# _form.html.erbの作成
$ touch app/views/books/_form.html.erb

今回は@bookを_form.html.erbで使用するので、パーシャルフォームに変数を渡す必要があります。 render partial: [ファイル名], locals: { [受け取る変数名]: [渡す変数名] }の内容でパーシャルフォームに変数を渡す事が可能ですが、オプションがlocalsの時だけは、以下のように省略して記述することが可能です。 render [ファイル名], [受け取る変数名]: [渡す変数名]

<!-- app/views/books/new.html.erb -->
+ <div class="col-md-10 offset-md-1 mt-3">
+ <h2 class="text-center">書籍登録</h2>
+ <%= render 'form', book: @book %>
+ </div>
<!-- app/views/books/_form.html.erb -->
+ <%= form_with model: book, local: true do |form| %>
+ <div class="form-group">
+ <%= form.label :title %>
+ <%= form.text_field :title, class: "form-control" %>
+ </div>
+ <div class="form-group">
+ <%= form.label :price %>
+ <%= form.number_field :price, class: "form-control" %>
+ </div>
+ <div class="form-group">
+ <%= form.label :publish_date %>
+ <%= form.date_field :publish_date, class: "form-control" %>
+ </div>
+ <div class="form-group">
+ <%= form.label :description %>
+ <%= form.text_area :description, rows: 10, class: "form-control" %>
+ </div>
+ <%= form.submit class: "btn btn-primary" %>
+ <% end %>

Bookの照会機能

Bookの照会機能を作成します。

showアクション

# app/controllers/books_controller.rb
class BooksController < ApplicationController
def index
@books = Book.all
end
def new
@book = Book.new
end
def create
@book = Book.new(book_params)
@book.save
redirect_to @book, notice: "書籍を登録しました。"
end
+ def show
+ @book = Book.find(params[:id])
+ end
private
def book_params
params.require(:book).permit(:title, :price, :publish_date, :description)
end
end

show.htmlと_flash.html.erb

照会用画面のshow.html.erbと、登録後のお知らせメッセージを表示する_flash.html.erbを作成します。 お知らせメッセージはパーシャルフォームとしてsharedディレクトリに作成します。

# show.html.erbの作成
$ touch app/views/books/show.html.erb
# _flash.html.erbの作成
$ touch app/views/shared/_flash.html.erb

number_with_delimiterは、引数として渡された値をカンマ区切りにしてくれるメソッドです。 simple_formatは、引数として渡された値をpタグで囲って出力します。また改行をbrタグに変換します。

<!-- app/views/books/show.html.erb -->
+ <div class="col-md-10 offset-md-1 mt-5">
+ <h2><%= @book.title %></h2>
+ <ul>
+ <li><b>価格: </b><%= number_with_delimiter @book.price %>円</li>
+ <li><b>発売日: </b><%= @book.publish_date %></li>
+ </ul>
+ <hr>
+ <h4>商品の説明</h4>
+ <%= simple_format @book.description %>
+ </div>

お知らせ用パーシャルフォームを作成します。 flash.noticeで設定されている内容を表示することが可能です。

<!-- app/views/shared/_flash.html.erb -->
+ <% if flash.notice %>
+ <div class="alert alert-success" role="alert">
+ <%= flash.notice %>
+ <button type="button" class="close" data-dismiss="alert" aria-label="Close">
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </div>
+ <% end %>
+ <% if flash.alert %>
+ <div class="alert alert-danger" role="alert">
+ <%= flash.alert %>
+ <button type="button" class="close" data-dismiss="alert" aria-label="Close">
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </div>
+ <% end %>

flash用のパーシャルフォームは、色々な画面で使用する可能性があるので、application.html.erbから呼び出すようにします。

<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<title>BookShelf</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>
<body>
<%= render "shared/header" %>
<div class="container">
+ <div class="row">
+ <div class="col-md-12">
+ <%= render "shared/flash" %>
+ </div>
+ </div>
<div class="row">
<%= yield %>
</div>
</div>
</body>
</html>

バリデーション

  • データの検証、つまり正しいデータだけをデータベースに保存するための仕組みのことを言います。

  • クライアント側のバリデーションと、サーバー側のバリデーションがあります。

  • 今回はサーバー側のバリデーションを実装します。

モデルにバリデーションを設定

app/models/book.rbvalidatesを追加し、それぞれの項目を設定します。

  • presenceで必須入力のチェックを行います。

  • lengthで文字数のチェックを行います。

  • numericalityで数値チェックを行います。

バリデーションについては、こちらが参考になります。

# app/models/book.rb
class Book < ApplicationRecord
+ validates :title, presence: true, length: { maximum: 50 }
+ validates :price, presence: true,
+ numericality: {
+ only_integer: true,
+ greater_than: 1
+ }
+ validates :publish_date, presence: true
+ validates :description, presence: true, length: { maximum: 1000 }
end

バリデーションのチェックとエラーメッセージの表示

books_controller.rbのcreateアクションを、バリデーションでOK(true)の場合とNG(false)の時で処理を分岐します。 NGだった場合、入力内容を保持したままViewを表示したいので、render [:アクション名]で画面を呼びます。 createアクションを下記のように変更して下さい。 ※renderの詳しい使い方は以下を

# app/controllers/books_controller.rb
class BooksController < ApplicationController
def index
@books = Book.all
end
def new
@book = Book.new
end
def create
@book = Book.new(book_params)
- @book.save
- redirect_to @book, notice: "書籍を登録しました。"
+ if @book.save
+ redirect_to @book, notice: "書籍を登録しました。"
+ else
+ render :new
+ end
end
def show
@book = Book.find(params[:id])
end
private
def book_params
params.require(:book).permit(:title, :price, :publish_date, :description)
end
end

エラー表示用のパーシャルフォームをsharedディレクトリに作成します。

$ touch app/views/shared/_errors.html.erb

_form.html.erbから_errors.html.erbを呼び出します。 変数をbookをobjという名前で渡します。他のモデルのエラー内容を表示する可能性があるので、汎用性のあるobjという名前にしています。

<!-- app/views/books/_form.html.erb -->
<%= form_with model: book, local: true do |form| %>
+ <%= render "shared/errors", obj: book %>
<div class="form-group">
<%= form.label :title %>
<%= form.text_field :title, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :new_image %>
<%= form.file_field :new_image, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :price %>
<%= form.number_field :price, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :publish_date %>
<%= form.date_field :publish_date, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :description %>
<%= form.text_area :description, rows: 10, class: "form-control" %>
</div>
<%= form.submit class: "btn btn-primary" %>
<%= link_to '戻る', books_path, class: "btn btn-secondary" %>
<% end %>

obj.errorsの中に、エラーメッセージが配列として格納されています。 any?メソッドで、配列の中に有効な値が存在するか確認します。 有効な値が存在した場合、エラーメッセージを表示します。 any?についてはこちら

<!-- app/views/shared/_errors.html.erb -->
+ <% if obj.errors.any? %>
+ <div class="alert alert-danger">
+ <p><b><%= obj.errors.count %>件のエラーがあります</b></p>
+ <ul>
+ <% obj.errors.full_messages.each do |message| %>
+ <li><%= message %></li>
+ <% end %>
+ </ul>
+ </div>
+ <% end %>

エラーメッセージの確認

何も入力せずに登録ボタンを押します。

国際化対応

Railsの国際化機能を使うと、日本語や英語などさまざまな言語のテキストをブラウザー上に表示することが可能です。

日本語のロケールファイル(辞書ファイル)は、config/locales/ja.ymlに配置します。 YAML (YAML Ain’t Markup Language) とは、構造化されたデータを表現するためのフォーマットです。 YAMLはインデントで構造を表現します。インデントがずれると、正しい値を取得できません。 curlコマンドを使用して、ファイルを取得します。

curl [取得したいファイルのURL] -o [保存先のパス] -oオプション:ファイルに出力する場合に指定します。

$ curl https://raw.githubusercontent.com/svenfuchs/rails-i18n/master/rails/locale/ja.yml -o config/locales/ja.yml

プロジェクトの規模が大きくなるとロケールファイルを1つのYAMLで管理するのが困難になります。 今回は辞書ファイルをview単位で分割してみます。

locales
│── en.yml
│── ja.yml
└── views
└── books
├── en.yml
└── ja.yml

日本語のサイトにする場合は、config.i18n.default_locale = :jaを指定します。 タイムゾーンも東京に変更します。(デフォルトはUTCなので東京との時差9時間) 複数のロケールファイルを読み込めるようにload_pathを追加します。

# config/application.rb
require_relative 'boot'
require 'rails/all'
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module BookShelf
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 5.2
+ config.time_zone = 'Tokyo' # タイムゾーンもついでに変更
+ config.i18n.default_locale = :ja
+ config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{rb,yml}').to_s]
config.action_view.field_error_proc = Proc.new do |html_tag, instance|
%Q(#{html_tag}).html_safe
end
# Settings in config/environments/* take precedence over those specified here.
# Application configuration can go into files in config/initializers
# -- all .rb files in that directory are automatically loaded after loading
# the framework and any gems in your application.
end
end

curlコマンドで取得したconfig/locales/ja.ymlに、以下の内容を追記します。

# config/locales/ja.yml
ja:
activerecord:
+ models:
+ book: 書籍情報
+ attributes:
+ book:
+ title: タイトル
+ price: 価格
+ publish_date: 発売日
+ description: 詳細
errors:
messages:

ビュー用のロケールファイル保存用ディレクトリを作成します。 作成したディレクトリの中に、ja.ymlファイルを作成します。

$ mkdir -p config/locales/views/books/
$ touch config/locales/views/books/ja.yml

作成したja.ymlファイルに以下の内容を記述します。

# config/locales/views/books/ja.yml
+ ja:
+ books:
+ new:
+ title: 書籍登録
+ show:
+ price: 価格
+ price_yen: "¥%{price}"
+ publish_date: 発売日
+ description: 商品の説明

ビューファイルでロケールファイルの内容を表示する場合は、tメソッドを使用します。 本来であれば、t 'books.new.title'で「書籍登録」を表示します。今回はディレクトリ構造と、YAMLファイルの構造が一致しているので、t '.title'だけで「書籍登録」を表示することが可能です。 コントローラ等でロケールファイルの内容を表示する場合は、I18n.tを使用します。 以下のように修正します。

<!-- app/views/books/new.html.erb -->
<div class="col-md-10 offset-md-1 mt-3">
- <h2 class="text-center">書籍登録</h2>
+ <h2 class="text-center"><%= t '.title' %></h2>
<%= render 'form', book: @book %>
</div>

同じように、書籍照会画面も修正します。

<!-- app/views/books/show.html.erb -->
<div class="col-md-10 offset-md-1 mt-5">
<h2><%= @book.title %></h2>
<ul>
- <li><b>価格:</b><%= number_with_delimiter @book.price %>円</li>
- <li><b>発売日:</b><%= @book.publish_date %></li>
+ <li><b><%= t ".price" %>: </b><%= t(".price_yen", price: (number_with_delimiter @book.price)) %></li>
+ <li><b><%= t ".publish_date" %>: </b><%= @book.publish_date %></li>
</ul>
<hr>
- <h4>商品の説明</h4>
+ <h4><%= t ".description" %></h4>
<%= simple_format @book.description %>
</div>

画像の登録機能

書籍の画像も登録できるようにします。 Railsの5.2の新機能のActice Storageを使用します。 Active Storageが実装される前は、CarrierWaveというGemを使用していました。(現在でもCarrierWaveは使われています。)

Active Storegeを使用するにま、ImageMagickをインストールする必要があります。ImageMagickは画像を操作したり表示したりするためのソフトウェアです。 Homebrewを使用してインストールします。

$ brew install imagemagick

RailsでImageMagickを利用するために、Gemfileにmini_magickを追加します。

# Gemfile
# -------- 省略 ----------
gem 'bootsnap', '>= 1.1.0', require: false
gem 'bootstrap', '~> 4.1.3'
gem 'jquery-rails'
+ gem 'mini_magick'
# -------- 省略 ----------

Gemをインストールします。

$ bi

bin/rails active_storage:installを実行して、Active Storageを使用できるようにします。 bin/rails db:migrateを実行して、データベースに反映します。

$ bin/rails active_storage:install
$ bin/rails db:migrate

Bookモデルにhas_one_attached :imageを定義します。has_one_attachedはレコードと画像が1対1の関係性を表します。画像が複数ある場合はhas_many_attachedを指定します。 has_one_attached [:プロパティ名] ※プロパティ名はimageやavatar等の名前にします。

Active Storageの使い方

# app/models/book.rb
class Book < ApplicationRecord
+ has_one_attached :image
validates :title, presence: true, length: { maximum: 50 }
validates :price, presence: true,
numericality: {
only_integer: true,
greater_than: 1
}
validates :publish_date, presence: true
validates :description, presence: true, length: { maximum: 1000 }
end

コールバック

Railsアプリケーションを普通に操作すると、その内部でオブジェクトが作成されたり、更新されたりdestroyされたりします。Active Recordはこのオブジェクトライフサイクルへのフックを提供しており、これを使用してアプリケーションやデータを制御できます。

利用可能なコールバック

Active Recordで利用可能なコールバックの一覧を以下に示します。これらのコールバックは、実際の操作中に呼び出される順序に並んでいます。

  • オブジェクトの作成

    • before_validation

    • after_validation

    • before_save

    • around_save

    • before_create

    • around_create

    • after_create

    • after_save

  • オブジェクトの更新

    • before_validation

    • after_validation

    • before_save

    • around_save

    • before_update

    • around_update

    • after_update

    • after_save

  • オブジェクトのdestroy

    • before_destroy

    • around_destroy

    • after_destroy

今の状態で書籍登録をすると、バリデーションでエラーとなった場合でも、画像が登録されてしまいます。

attributeメソッドを使用して、レコードに存在しないカラムを定義することが可能です。

  • attribute [:カラム名]

今回はattribute :new_imageとし、登録前の画像を一時的に保持する領域を確保します。 バリデーション通過後に実行されるbefore_saveを使用して、 new_imageが設定されている場合は、self.imagenew_imageの値を設定します。

# app/controllers/books_controller.rb
class Book < ApplicationRecord
has_one_attached :image
+ attribute :new_image
validates :title, presence: true, length: { maximum: 50 }
validates :price, presence: true,
numericality: {
only_integer: true,
greater_than: 1
}
validates :publish_date, presence: true
validates :description, presence: true, length: { maximum: 1000 }
+ before_save do
+ self.image = new_image if new_image
+ end
end

画像登録フィールドの追加

書籍登録画面に、画像登録用のフィールドを追加します。

完成イメージ

file_fieldを使用します。file_fieldに紐付けるプロパティはnew_imageとなります。

<!-- app/views/books/_form.html.erb -->
<%= form_with model: book, local: true do |form| %>
<%= render "shared/errors", obj: book %>
<div class="form-group">
<%= form.label :title %>
<%= form.text_field :title, class: "form-control" %>
</div>
+ <div class="form-group">
+ <%= form.label :new_image %>
+ <%= form.file_field :new_image, class: "form-control" %>
+ </div>
<div class="form-group">
<%= form.label :price %>
<%= form.number_field :price, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :publish_date %>
<%= form.date_field :publish_date, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :description %>
<%= form.text_area :description, rows: 10, class: "form-control" %>
</div>
<%= form.submit class: "btn btn-primary" %>
<% end %>

ストロングパラメータにnew_imageを追加します。

# app/controllers/books_controller.rb
# 省略
private
def book_params
- params.require(:book).permit(:title, :price, :publish_date, :description)
+ params.require(:book).permit(:title, :price, :publish_date,
+ :description, :new_image)
end

config/locales/ja.ymlに以下の内容を追記します。

# config/locales/ja.yml
ja:
activerecord:
models:
book: 書籍情報
attributes:
book:
title: タイトル
price: 価格
publish_date: 発売日
description: 詳細
+ new_image: 画像
+ image: 画像

config/locales/views/books/ja.ymlに以下の内容を追記します。

# config/locales/views/books/ja.yml
ja:
books:
new:
title: 書籍登録
edit:
title: 書籍更新
show:
price: 価格
price_yen: "¥%{price}"
publish_date: 発売日
description: 商品の説明
+ edit: 編集
+ return: 戻る

画像を登録しなかった場合に以下の画像を表示するため、curlコマンドで画像を取得し、app/assets/imagesに保存します。

$ curl http://spartacamp.jp/img/no_image.png -o ./app/assets/images/no_image.png

照会画面に画像を表示

完成イメージ

attached?メソッドを使用して、画像が登録されているかチェックをします。 画像が登録されている場合はvariantメソッド(ImageMagicの機能)を使用して、サイズを変更します。画像が登録されていない場合は、no_image.pngを表示します。 後ほど作成する編集画面へ遷移するリンクと、一覧画面へ戻るリンクも追加します。

<!-- app/views/books/show.html.erb -->
<div class="col-md-10 offset-md-1 mt-5">
- <h2><%= @book.title %></h2>
- <ul>
- <li><b><%= t ".price" %>: </b><%= t(".price_yen", price: (number_with_delimiter @book.price)) %></li>
- <li><b><%= t ".publish_date" %>: </b><%= @book.publish_date %></li>
- </ul>
+ <div class="row">
+ <div class="col-md-4 mb-4">
+ <% if @book.image.attached? %>
+ <%= image_tag @book.image.variant(resize: "300x300"), class: "img-thumbnail" %>
+ <% else %>
+ <%= image_tag "no_image.png", class: "img-thumbnail" %>
+ <% end %>
+ </div>
+ <div class="col-md-8">
+ <h2><%= @book.title %></h2>
+ <ul>
+ <li><b><%= t ".price" %>: </b><%= t(".price_yen", price: (number_with_delimiter @book.price)) %></li>
+ <li><b><%= t ".publish_date" %>: </b><%= @book.publish_date %></li>
+ </ul>
+ </div>
+ </div>
<hr>
<h4><%= t ".description" %></h4>
<%= simple_format @book.description %>
+ <%= link_to t(".edit"), edit_book_path(@book), class: "btn btn-primary" %>
+ <%= link_to t(".return"), books_path, class: "btn btn-secondary" %>
</div>

ビューヘルパー

if @book.image.attached?endの部分が増えたことで、Viewが見辛くなってきました。DRYなViewにするために、ヘルパーモジュールに自作のヘルパーメソッドを作成します。 今回はshow_book_imageというメソッドを作成します。Bookクラスのインスタンスを受け取り、画像が登録されている場合と、登録されていない場合の処理を記述します。 ※変数は@bookからbookに変わっているので注意して下さい。

# app/helpers/books_helper.rb
module BooksHelper
+ def show_book_image(book)
+ if book.image.attached?
+ image_tag book.image.variant(resize: "300x300"), class: "img-thumbnail"
+ else
+ image_tag "no_image.png", class: "img-thumbnail"
+ end
+ end
end

照会画面の処理を以下のように変更します。(show_book_imageを呼ぶ)

<!-- app/views/books/show.html.erb -->
<div class="col-md-10 offset-md-1 mt-5">
<div class="row">
<div class="col-md-4 mb-4">
- <% if @book.image.attached? %>
- <%= image_tag @book.image.variant(resize: "300x300"), class: "img-thumbnail" %>
- <% else %>
- <%= image_tag "no_image.png", class: "img-thumbnail" %>
- <% end %>
+ <%= show_book_image @book %>
</div>
<div class="col-md-8">
<h2><%= @book.title %></h2>
<ul>
<li><b><%= t ".price" %>: </b><%= t(".price_yen", price: (number_with_delimiter @book.price)) %></li>
<li><b><%= t ".publish_date" %>: </b><%= @book.publish_date %></li>
</ul>
</div>
</div>
<hr>
<h4><%= t ".description" %></h4>
<%= simple_format @book.description %>
<%= link_to t(".edit"), edit_book_path(@book), class: "btn btn-primary" %>
<%= link_to t(".return"), books_path, class: "btn btn-secondary" %>
</div>

編集機能の作成

登録機能と照会機能が出来たので、今度は編集機能を作成します。

editアクションとupdateアクション

データを更新する場合は、editとupdateアクションが必要になります。

# app/controllers/books_controller.rb
# 省略
def show
@book = Book.find(params[:id])
end
+ def edit
+ @book = Book.find(params[:id])
+ end
+ def update
+ @book = Book.find(params[:id])
+ if @book.update(book_params)
+ redirect_to @book, notice: "書籍を更新しました。"
+ else
+ render :edit
+ end
+ end
private
def book_params
params.require(:book).permit(:title, :price, :publish_date,
:description, :new_image)
end
end

edit.html.erb

更新用フォームのedit.html.erbを作成します。

# edit.html.erbの作成
$ touch app/views/books/edit.html.erb

ファイルの中身はnew.html.erbと同じになります。画面のタイトルはYAMLファイルで制御します。

+ <div class="col-md-10 offset-md-1 mt-3">
+ <h2 class="text-center"><%= t '.title' %></h2>
+ <%= render 'form', book: @book %>
+ </div>

config/locales/views/books/ja.ymlファイルに以下の内容を追記します。

# config/locales/views/books/ja.yml
ja:
books:
new:
title: 書籍登録
+ edit:
+ title: 書籍更新
show:
price: 価格
price_yen: "¥%{price}"
publish_date: 発売日
description: 商品の説明
edit: 編集
return: 戻る

削除機能の作成

照会画面に削除ボタンを追加し、ボタンを押下することで初期を削除する機能を作成します。

完成イメージ

destroyアクション

データを削除する場合は、destroyアクションが必要になります。 削除後は書籍一覧ページに遷移するようにします。

# app/controllers/books_controller.rb
# 省略
+ def destroy
+ @book = Book.find(params[:id])
+ @book.destroy
+ redirect_to books_path, notice: "書籍を削除しました。"
+ end
private
def book_params
params.require(:book).permit(:title, :price, :publish_date,
:description, :new_image)
end
end

show.html.erb

照会画面に削除用のボタンを追加します。削除ボタンを押した際にモーダルフォールを表示します。モーダルフォームはBootstrapのサイトを参考に作成します。 target: "#delete-book"delete-bookと、<div class="modal fade" id="delete-book">delete-bookが一致しなければモーダルフォームは表示されません。

t(".question_book", book: @book.title)のように、tメソッドに引数を渡すことも可能です。YAML側ではquestion_book: "%{book}を削除しますか?"と記述し、受け取った値を展開することが可能です。(次に出てきます。)

<!-- app/views/books/show.html.erb -->
<div class="col-md-10 offset-md-1 mt-5">
<div class="row">
<div class="col-md-4 mb-4">
<%= show_book_image @book %>
</div>
<div class="col-md-8">
<h2><%= @book.title %></h2>
<ul>
<li><b><%= t ".price" %>: </b><%= t(".price_yen", price: (number_with_delimiter @book.price)) %></li>
<li><b><%= t ".publish_date" %>: </b><%= @book.publish_date %></li>
</ul>
</div>
</div>
<hr>
<h4><%= t ".description" %></h4>
<%= simple_format @book.description %>
<%= link_to t(".edit"), edit_book_path(@book), class: "btn btn-primary" %>
+ <%= button_tag t(".delete"), class: "btn btn-warning", data: { toggle: "modal", target: "#delete-book" } %>
<%= link_to t(".return"), books_path, class: "btn btn-secondary" %>
</div>
+ <div class="modal fade" id="delete-book">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="exampleModalLabel"><%= t(".modal_title") %></h5>
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <p><%= t(".question_book", book: @book.title) %></p>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-dismiss="modal"><%= t(".chancel") %></button>
+ <%= link_to t(".delete_action"), book_path(@book), method: :delete, class: "btn btn-danger" %>
+ </div>
+ </div>
+ </div>
+ </div>

config/locales/views/books/ja.ymlファイルに以下の内容を追記します。

# config/locales/views/books/ja.yml
ja:
books:
new:
title: 書籍登録
edit:
title: 書籍更新
show:
price: 価格
price_yen: "¥%{price}"
publish_date: 発売日
description: 商品の説明
edit: 編集
return: 戻る
+ delete: 削除
+ modal_title: 書籍削除
+ question_book: "%{book}を削除しますか?"
+ delete_action: 削除する
+ chancel: キャンセル

書籍一覧機能の作成

書籍の一覧表示機能を作成します。

完成イメージ

indexアクション

一覧表示機能を作成する場合は、indexアクションが必要になります。 先程作成したので、今回はindenアクションを作成しません。

index.html.erb

index.html.erbは先程仮作成していたので、以下の内容を記述していきます。

  • PCサイズ:書籍情報が4つ横に並ぶ

  • タブレットサイズ:書籍情報が2つ横に並ぶ

  • スマホサイズ:書籍情報が1つだけ表示され、縦に並ぶ

+ <% @books.each do |book| %>
+ <div class="col-lg-3 col-md-4 col-sm-6 mb-3">
+ <%= show_book_image book %>
+ <h4 class="mt-2"><%= link_to book.title, book_path(book) %></h4>
+ <div><%= t(".price_yen", price: (number_with_delimiter book.price)) %></div>
+ <p><%= truncate(book.description, length: 20) %></p>
+ </div>
+ <% end %>

config/locales/views/books/ja.ymlファイルに以下の内容を追記します。

# config/locales/views/books/ja.yml
ja:
books:
new:
title: 書籍登録
edit:
title: 書籍更新
show:
price: 価格
price_yen: "¥%{price}"
publish_date: 発売日
description: 商品の説明
edit: 編集
return: 戻る
delete: 削除
modal_title: 書籍削除
question_book: "%{book}を削除しますか?"
delete_action: 削除する
chancel: キャンセル
+ index:
+ price_yen: "¥%{price}"

以上で基本的なCRUD機能の完成となります。

リファクタリング

リファクタリングとは、ソフトウェアの外部的振る舞い(処理結果)を保ちつつ、理解や修正が簡単になるように、内部構造を改善することです。

コントローラを確認すると、同じ記述が存在します。

@book = Book.find(params[:id])が4つのメソッドで使用されています。 @book = Book.find(params[:id])を別のメソッド(アクション)に切り出します。 今回はset_bookというアクションを作成します。 そのメソッドを必要なときに読み込む処理を追加します。 before_actionというフィルタを使用することにより、各アクション(indexやshow)を呼ぶ前に特定の処理を実行することが可能です。 特定の処理の時だけ呼び出したい時はonlyオプションを指定します。 今回のset_bookは、show、edit、update、destroyで呼び出したいので、onlyオプションを追加しています。

※beforeフィルタ以外に、afterフィルタやaroundフィルタが存在します。詳しくはこちらを参照

今回はbefore_action :set_book, only: [:show, :edit, :update, :destroy]としていますが、全てのアクションを実行する前に何か実行する場合は、before_action :XXXXXとします。onlyオプションは使用しません。

# app/controllers/books_controller.rb
class BooksController < ApplicationController
+ before_action :set_book, only: [:show, :edit, :update, :destroy]
def index
@books = Book.all
end
def show
- @book = Book.find(params[:id])
end
def new
@book = Book.new
end
def create
@book = Book.new(book_params)
@book.save
redirect_to @book, notice: "書籍を登録しました。"
end
def edit
- @book = Book.find(params[:id])
end
def update
- @book = Book.find(params[:id])
@book.update(book_params)
redirect_to @book, notice: "書籍を更新しました。"
end
def destroy
- @book = Book.find(params[:id])
@book.destroy
redirect_to books_path, notice: "書籍を削除しました。"
end
private
def book_params
params.require(:book).permit(:name, :price, :publish, :publish_date)
end
+ def set_book
+ @book = Book.find(params[:id])
+ end
end

Categoryモデルの追加

モデル名:Category

書籍は必ず一つのカテゴリーに紐付くように機能を拡張します。 以下の内容でCategoryモデルを作成します。

カラム名

カラム名(日本語)

データ型

name

名前

string

Categoryはseedファイルを使用して初期データをセットアップするため、CRUDの機能は実装しません。 モデルのみ作成します。

$ bin/rails g model category name:string

データベースに反映します。

$ bin/rails db:migrate

ダミーデータ(seedデータ)の作成

開発用のダミーデータをデータベースに登録します。 ダミーデータを登録する際に使用するファイルをシードファイルといいます。 シードファイルはdb/seeds.rbになります。

# db/seeds.rb
categories = %w(文学・評論 人文・思想 社会・政治・法律 ノンフィクション 歴史・地理 ビジネス 科学・テクノロジー 医学 コンピュータ・IT アート 趣味・実用 スポーツ・アウトドア 資格 暮らし 旅行ガイド コミック ライトノベル)
categories.each do |category|
Category.create(name: category)
end

データベースに反映します。

$ bin/rails db:seed

実行後、bin/rails c実行後にCategory.allで確認するか、GUIツールでデータが反映されていることを確認します。

アソシエーション

モデル同士を紐つける仕組みのことを言います。 関連するモデル(app/models/XXX.rb)で宣言することで、アソシエーションが設定され、便利なメソッドが利用可能となります。 今回はBookモデルとCategoryモデルを紐づけします。

Bookは1つのCategoryに紐付いているという状態を表す場合、以下の作業が必要になります。

  • booksテーブルにcategoriesテーブルに紐づくカラムを追加(外部キーと言います)

  • モデル(app/models/XXX.rb)にアソシエーションに関するコードを記述

Bookモデル(booksテーブル)にカラム追加

カラムを追加する場合は、bin/rails g migration Addカラム名Toテーブル名 カラム名:データ型となります。 今回はcategoryモデルへの外部キーを追加するため、カラム名: データ型の部分は、category:referencesとなります。 category_id:integerでも構いませんが、外部キーを追加する場合は、紐付けるモデル名:referencesとしましょう。

$ bin/rails g migration AddCategoriesIdToBooks category:references

カテゴリーで検索する場合もあるので、indexを追加します。追加するカラムにindexを付与する場合は、index: trueを指定します。 そのままデータベースに反映すると、テーブルの一番最後にカラムが追加されます。after: :カラム名before: :カラム名を使用すると、追加する位置を指定することが可能です。 今回はdescriptionの後に追加するため、after: :descriptionとします。 ※SQLiteでは位置を指定しても反映されません。

# db/migrate/YYYYMMDDhhmmss_add_categories_id_to_books.rb
class AddCategoriesIdToBooks < ActiveRecord::Migration[5.2]
def change
- add_reference :books, :category, foreign_key: true
+ add_reference :books, :category, foreign_key: true, index: true, after: :description
end
end

DBに反映します。

$ bin/rails db:migrate

アソシエーションの設定

  • app/models/book.rbbelongs_to :categoryを追記

  • belongs_toの時は関連するモデル名の単数形

  • app/models/category.rbhas_many :booksを追記

  • has_manyの時は関連するモデル名の複数形

以下の図を使用して解説します。

【belongs_to】

@book = Book.find(1)で1番のbookを取得します。 @book.categoryを実行すると、categoriesテーブルのidが1のデータを抽出することが可能です。 bookは一つのカテゴリーに紐付いているので、categoryと単数形になります。 @book.category.nameで「文学・評論」を取得することが可能です。

【has_many】

@category = Category.find(1)で1番のcategoryを取得します。 @category.booksを実行すると、booksテーブルのcategory_idが1のデータを抽出することが可能です。 categoryには複数のbookが存在するため、booksと複数形になります。 @category.booksには複数件のデータが存在するので、@category.books.eachで1件ずつ取り出す事が可能です。

Book.rbにbelongs_toを記述します。

# app/models/book.rb
class Book < ApplicationRecord
+ belongs_to :category
has_one_attached :image
attribute :new_image
# 以下省略

Category.rbにhas_manyを記述します。 has_manyの場合は複数形になるので注意して下さい。

# app/models/category.rb
class Category < ApplicationRecord
+ has_many :books
end

カテゴリー選択

書籍登録時、更新時に、カテゴリーを選択できるようにします。

<!-- app/views/books/_form.html.erb -->
<%= form_with model: book, local: true do |form| %>
<%= render "shared/errors", obj: book %>
<div class="form-group">
<%= form.label :title %>
<%= form.text_field :title, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :new_image %>
<%= form.file_field :new_image, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :price %>
<%= form.number_field :price, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :publish_date %>
<%= form.date_field :publish_date, class: "form-control" %>
</div>
+ <div class="form-group">
+ <%= form.label :category_id %>
+ <%= form.collection_select :category_id, Category.all, :id, :name , { include_blank: t(".include_blank") }, { class: "form-control" } %>
+ </div>
<div class="form-group">
<%= form.label :description %>
<%= form.text_area :description, rows: 10, class: "form-control" %>
</div>
<%= form.submit class: "btn btn-primary" %>
<%= link_to '戻る', books_path, class: "btn btn-secondary" %>
<% end %>

カラムを追加したので、YAMLファイルにも追加します。 アソシエーションを設定しているので、バリデーションがかかります。その場合は関連するモデル名をカラムと同じように追加しないと、エラーメッセージが日本語に変わりせん。 今回はcategory_idとcategoryを追加します。

# config/locales/ja.yml
ja:
activerecord:
models:
book: 書籍情報
attributes:
book:
title: タイトル
price: 価格
publish_date: 発売日
description: 詳細
new_image: 画像
+ category_id: カテゴリー
+ category: カテゴリー
errors:

コレクションセレクトで使用するinclude_blankについてもYAMLファイルに追加します。 _form.html.erbで使用するので、YAMLファイルにはformで記述します。

# config/locales/views/books/ja.yml
ja:
books:
new:
title: 書籍登録
edit:
title: 書籍更新
+ form:
+ include_blank: カテゴリーを選択して下さい
show:
price: 価格

ここまでの作業で登録画面と編集画面でカテゴリーを入力できるようになりました。ただし、このままではカテゴリーを選択しても登録されません。カラムを追加した場合は、必ずストロングパラメータも追加しましょう。

# app/controllers/books_controller.rb
# 省略
private
def book_params
params.require(:book).permit(:title, :price, :publish_date,
- :description, :new_image)
+ :description, :new_image, :category_id)
end
def set_book
@book = Book.find(params[:id])
end
end

実際にカテゴリーが登録できるか確認してみましょう。 詳細ページにカテゴリーを表示していないので、登録後はデータベースのデータを直接確認します。

詳細ページにカテゴリーを表示します。

アソシエーションを使用すると、@book.category.nameをカテゴリー名を取得することが可能です。 ただし、すでに登録されているデータのcategory_idがnullのため、以下のようなエラーが発生します。

&.(ぼっち演算子)を使用することで、メソッドを呼び出せない(nameを呼び出すことができない)場合に、nilを返してくれます。 @book.category.name@book.category&.nameに変更することでエラーが発生しなくなります。

<!-- app/views/books/show.html.erb -->
<div class="col-md-10 offset-md-1 mt-5">
<div class="row">
<div class="col-md-4 mb-4">
<%= show_book_image @book %>
</div>
<div class="col-md-8">
<h2><%= @book.title %></h2>
<ul>
<li><b><%= t ".price" %>: </b><%= t(".price_yen", price: (number_with_delimiter @book.price)) %></li>
<li><b><%= t ".publish_date" %>: </b><%= @book.publish_date %></li>
+ <li><b><%= t ".category" %>: </b><%= @book.category&.name %></li>
</ul>
</div>
</div>
<!-- 省略 -->

YAMLファイルに追記します。

# config/locales/views/books/ja.yml
ja:
books:
new:
title: 書籍登録
edit:
title: 書籍更新
form:
include_blank: カテゴリーを選択して下さい
show:
price: 価格
price_yen: "¥%{price}"
publish_date: 発売日
+ category: カテゴリー
description: 商品の説明
edit: 編集
return: 戻る
delete: 削除
modal_title: 書籍削除
question_book: "%{book}を削除しますか?"
delete_action: 削除する
chancel: キャンセル
index:
price_yen: "¥%{price}"

以上でカテゴリーに関する処理は完了です。

ログイン機能の実装

本のレビュー機能を追加する前に、ログイン機能を実装します。

ログイン管理はdeviseというgemをします。

deviseの実装

# Gemfile
# 省略
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '>= 1.1.0', require: false
gem 'bootstrap', '~> 4.1.3'
gem 'jquery-rails'
gem 'mini_magick'
+ gem 'devise'
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
end
# 省略

gemのインストール後、devise関連ファイルをプロジェクトに追加するために、devise:installを実行します。

$ bundle install
$ bin/rails g devise:install
# deviseをインストールした時に、deviseに関係するメッセージが表示されます。
===============================================================================
Some setup you must do manually if you haven't yet:
1. Ensure you have defined default url options in your environments files. Here
is an example of default_url_options appropriate for a development environment
in config/environments/development.rb:
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
In production, :host should be set to the actual host of your application.
2. Ensure you have defined root_url to *something* in your config/routes.rb.
For example:
root to: "home#index"
3. Ensure you have flash messages in app/views/layouts/application.html.erb.
For example:
<p class="notice"><%= notice %></p>
<p class="alert"><%= alert %></p>
4. You can copy Devise views (for customization) to your app by running:
rails g devise:views

deviseを導入した場合、ログアウト時のリダイレクト先として、rootパスの設定が必要となります。 すでに設定しているのでOKです。

#config/routes/rb
Rails.application.routes.draw do
resources :books
root "books#index"
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end

deviseで管理されるモデルを追加します。 今回はdeviseを使用してUserモデルを作成します。deviseを使用してモデルを作成すると、ログイン管理に必要なカラムが自動で追加されます。ユーザーを識別するキーとしてemailが使用されます。emailカラムは自動で追加されます。 後ほど追加するレビュー機能でユーザーの名前を表示したいので、nick_nameカラムを追加します。

$ bin/rails g devise User nick_name:string

マイグレーションファイルを確認すると、初期段階で複数のカラムが自動で設定されていることが確認できます。

# db/migrate/XXXXXXXXXX_devise_create_users.rb
# frozen_string_literal: true
class DeviseCreateUsers < ActiveRecord::Migration[5.2]
def change
create_table :users do |t|
## Database authenticatable
t.string :email, null: false, default: ""
t.string :encrypted_password, null: false, default: ""
## Recoverable
t.string :reset_password_token
t.datetime :reset_password_sent_at
## Rememberable
t.datetime :remember_created_at
## Trackable
# t.integer :sign_in_count, default: 0, null: false
# t.datetime :current_sign_in_at
# t.datetime :last_sign_in_at
# t.string :current_sign_in_ip
# t.string :last_sign_in_ip
## Confirmable
# t.string :confirmation_token
# t.datetime :confirmed_at
# t.datetime :confirmation_sent_at
# t.string :unconfirmed_email # Only if using reconfirmable
## Lockable
# t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
# t.string :unlock_token # Only if unlock strategy is :email or :both
# t.datetime :locked_at
t.string :nick_name
t.timestamps null: false
end
add_index :users, :email, unique: true
add_index :users, :reset_password_token, unique: true
# add_index :users, :confirmation_token, unique: true
# add_index :users, :unlock_token, unique: true
end
end

データベースに反映します。

$ bin/rails db:migrate

ルーティングファイルを確認すると、ルーティングが自動で追加されていることが確認できます。

# config/routes.rb
Rails.application.routes.draw do
+ devise_for :users
resources :books
root "books#index"
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end

Devise用日本語ファイル

Devise用の日本語ファイルも用意されているので、ダウンロードします。 以下のコマンドでダウンロード後、サーバを再起動します。(落としている方はOK)

$ curl https://gist.githubusercontent.com/kaorumori/7276cec9c2d15940a3d93c6fcfab19f3/raw/a8c4f854988391dd345f04ff100441884c324f2a/devise.ja.yml -o config/locales/devise.ja.yml

手動でダウンロードした方は以下の手順で行って下さい。

  • 公式ページのここバージョン毎の日本語化ファイルがある

  • 4.2.0以上のリンクから、devise.ja.ymlをダウンロード

  • config/locales以下に設置する

  • サーバーを再起動

下準備はこれで完了。サーバーを起動している方は再起動してください。

次はヘッダーにログインしていなければログインへのリンク、ログインしていたらログアウトへのリンクを表示するように修正します。

  • 便利なdevise関連のビューヘルパー

    • モデル名_signed_in?で、ログインしているかをどうか返してくれます。

      • 今回はUserモデルを使用しているので、user_signed_in?となります。

以下で使用している、xxxx_xxx_xx_pathは、bin/rails routesで確認することが可能です。 今回は非ログインの場合は「新規登録」、「ログイン」を表示し、ログイン済みの場合は「設定変更」、「ログアウト」を表示します。 ビューの文言はYAMLファイルに定義します。 追加するメニューは、上から準備に「設定変更」、「ログアウト」、「新規登録」、「ログイン」となります。

<!-- app/views/shared/_header.html.erb -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-3">
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarToggler" aria-controls="navbarTogglerDemo03" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
- <%= link_to "BookShelf", root_path, class: "navbar-brand" %>
+ <%= link_to t(".title"), root_path, class: "navbar-brand" %>
<div class="collapse navbar-collapse" id="navbarToggler">
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
<li class="nav-item">
- <%= link_to "Home", root_path, class: "nav-link" %>
+ <%= link_to t(".home"), root_path, class: "nav-link" %>
</li>
<li class="nav-item">
- <%= link_to "New Book", new_book_path, class: "nav-link" %>
+ <%= link_to t(".new_book"), new_book_path, class: "nav-link" %>
</li>
+ <% if user_signed_in? %>
+ <li class="nav-item">
+ <%= link_to t(".edit_registration"), edit_user_registration_path, class: "nav-link" %>
+ </li>
+ <li class="nav-item">
+ <%= link_to t(".log_out"), destroy_user_session_path, method: :delete, class: "nav-link" %>
+ </li>
+ <% else %>
+ <li class="nav-item">
+ <%= link_to t(".new_registration"), new_user_registration_path, class: "nav-link" %>
+ </li>
+ <li class="nav-item">
+ <%= link_to t(".log_in"), new_user_session_path, class: "nav-link" %>
+ </li>
+ <% end %>
</ul>
<form class="form-inline my-2 my-lg-0">
<input class="form-control mr-sm-2" type="search" placeholder="Search" aria-label="Search">
<button class="btn btn-info my-2 my-sm-0" type="submit">Search</button>
</form>
</div>
</nav>

YAMLファイルにヘッダーで使用する内容を定義します。 まずは今回ユーザーモデルを追加したので、モデルに関するカラムを追加します。

# config/locales/ja.yml
ja:
activerecord:
models:
book: 書籍情報
attributes:
book:
title: タイトル
price: 価格
publish_date: 発売日
description: 詳細
new_image: 画像
category_id: カテゴリー
category: カテゴリー
+ user:
+ email: メールアドレス
+ nick_name: ニックネーム
+ avatar: プロフィール画像
+ password: パスワード
errors:
messages:
record_invalid: "バリデーションに失敗しました: %{errors}"

ヘッダーで使用する項目をYAMLファイルに追加します。

$ mkdir config/locales/views/shared/
$ touch config/locales/views/shared/ja.yml
# config/locales/views/shared/ja.yml
+ ja:
+ shared:
+ header:
+ title: BookShelf
+ home: Home
+ new_book: New Book
+ new_registration: 新規登録
+ edit_registration: 設定変更
+ log_out: ログアウト
+ log_in: ログイン

サーバを立ち上げ確認してみると、新規登録やログイン画面が表示されます。 ただし、ニックネーム用のフィールドが無かったり、デザインを変更したいため、カスタマイズします。

Devise関連の画面をカスタマイズ

Deviseの画面をカスタマイズしたい場合は、以下のコマンド実行して、画面をローカルに取得してくる必要があります。

$ bin/rails g devise:views

生成されるファイルと使われる画面

画面名

パス

ログイン画面

app/views/devise/sessions/new.html.erb

新規登録画面

app/views/devise/registrations/new.html.erb

登録情報変更画面

app/views/devise/registrations/edit.html.erb

パスワードを変更するための メールを送信する画面

app/views/devise/passwords/new.html.erb

パスワード変更画面

app/views/devise/passwords/edit.html.erb

メール認証画面

app/views/devise/confirmations/new.html.erb

アカウントのアンロック画面

app/views/devise/unlocks/new.html.erb

Active Storageの設定

# app/models/user.rb
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
+ has_one_attached :avatar
end

新規登録画面の修正

以下の内容に修正します。

  • bootstrapのclassを適用する

  • 新規登録画面を枠線で囲うためのdivタグを追加する

  • タイトルやボタンに表示する文言をYAMLで管理する

  • ニックネームとプロフィール画像のフィールドを追加する

<!-- app/views/devise/registrations/new.html.erb -->
+<div class="col-md-8 offset-md-2 mt-3">
+ <div class="user-box">
+ <h3 class="text-center"><%= t ".title" %></h3>
+ <%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
+ <%= devise_error_messages! %>
+ <div class="form-group">
+ <%= f.label :email %><br />
+ <%= f.email_field :email, autofocus: true, class: "form-control" %>
+ </div>
+ <div class="form-group">
+ <%= f.label :nick_name %><br />
+ <%= f.text_field :nick_name, class: "form-control" %>
+ </div>
+ <div class="form-group">
+ <%= f.label :avatar %>
+ <%= f.file_field :avatar, class: "form-control" %>
+ </div>
+ <div class="form-group">
+ <%= f.label :password %>
+ <% if @minimum_password_length %>
+ <em>(<%= t(".minimum_password_length", length: @minimum_password_length) %>)</em>
+ <% end %><br />
+ <%= f.password_field :password, autocomplete: "off", class: "form-control" %>
+ </div>
+ <div class="form-group">
+ <%= f.label :password_confirmation %><br />
+ <%= f.password_field :password_confirmation, autocomplete: "off", class: "form-control" %>
+ </div>
+ <div class="form-group">
+ <%= f.submit t(".submit"), class: "btn btn-primary" %>
+ </div>
+ <% end %>
+ <%= render "devise/shared/links" %>
+ </div>
+</div>

新規登録画面のフィールドを囲う枠をCSSでデザインします。

/* app/assets/stylesheets/application.scss */
@import "bootstrap";
+ .user-box {
+ padding: 30px;
+ border: 1px solid #dcdcdc;
+ background-color: #f8f8ff;
+ }

新規登録画面で使用する内容を、YAMLファイルに追記します。

# config/locales/ja.yml
ja:
activerecord:
models:
book: 書籍情報
attributes:
book:
title: タイトル
price: 価格
publish_date: 発売日
description: 詳細
new_image: 画像
image: 画像
category_id: カテゴリー
category: カテゴリー
user:
email: メールアドレス
nick_name: ニックネーム
avatar: プロフィール画像
password: パスワード
+ password_confirmation: パスワード再入力
errors:
messages:

devise関連の画面で使用する項目をYAMLファイルに追加します。

$ mkdir config/locales/views/devise/
$ touch config/locales/views/devise/ja.yml
# config/locales/views/devise/ja.yml
+ ja:
+ devise:
+ registrations:
+ new:
+ title: ユーザー新規登録
+ submit: 新規登録
+ minimum_password_length: "%{length} 文字以上を入力して下さい"

Devise用のストロングパラメータ

deviseで管理されているモデルに、カラムを追加した場合(今回はnick_nameとavatar)は、devise用のストロングパラメータには追加をします。 追加したカラムを、application_controller.rbに独自のメソッドで定義します。

:devise_contoller?とは、deviseを生成した際にできるヘルパーメソッドの一つで、deviseにまつわる画面に行った時に、という意味があります。 全ての画面でconfigure_permitted_parametersをするのを防いでいます。 :sign_upは新規登録用、:account_updateは設定変更用となります。

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
+ before_action :configure_permitted_parameters, if: :devise_controller?
+ protected
+ def configure_permitted_parameters
+ devise_parameter_sanitizer.permit(:sign_up, keys: [:nick_name, :avatar])
+ devise_parameter_sanitizer.permit(:account_update, keys: [:nick_name, :avatar])
+ end
end

アカウントが登録できる事を確認します。

ログイン画面の修正

以下の内容に修正します。

  • bootstrapのclassを適用する

  • 新規登録画面を枠線で囲うためのdivタグを追加する

  • タイトルやボタンに表示する文言をYAMLで管理する

<!-- app/views/devise/sessions/new.html.erb -->
+ <div class="col-md-8 offset-md-2 mt-3">
+ <div class="user-box">
+ <h3 class="text-center"><%= t ".title" %></h3>
+ <%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
+ <div class="form-group">
+ <%= f.label :email %><br />
+ <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "form-control" %>
+ </div>
+ <div class="form-group">
+ <%= f.label :password %><br />
+ <%= f.password_field :password, autocomplete: "current-password", class: "form-control" %>
+ </div>
+ <% if devise_mapping.rememberable? -%>
+ <div class="form-group">
+ <%= f.check_box :remember_me %>
+ <%= f.label :remember_me %>
+ </div>
+ <% end -%>
+ <div class="form-group">
+ <%= f.submit t(".submit"), class: "btn btn-primary" %>
+ </div>
+ <% end %>
+ <%= render "devise/shared/links" %>
+ </div>
+ </div>

ログイン画面で使用する内容を、YAMLファイルに追記します。

# config/locales/ja.yml
ja:
activerecord:
models:
book: 書籍情報
attributes:
book:
title: タイトル
price: 価格
publish_date: 発売日
description: 詳細
new_image: 画像
category_id: カテゴリー
category: カテゴリー
user:
email: メールアドレス
nick_name: ニックネーム
avatar: プロフィール画像
password: パスワード
password_confirmation: パスワード再入力
+ remember_me: ログイン情報を記録する
errors:
messages:
record_invalid: "バリデーションに失敗しました: %{errors}"

タイトルとボタンに表示する内容を追記します。

# config/locales/views/devise/ja.yml
ja:
devise:
registrations:
new:
title: ユーザー新規登録
submit: 新規登録
minimum_password_length: "%{length} 文字以上を入力して下さい"
+ sessions:
+ new:
+ title: ログイン
+ submit: ログイン

ログインとログアウトが出来ること確認します。

アカウント変更画面を修正

以下の内容に修正します。

  • bootstrapのclassを適用する

  • 新規登録画面を枠線で囲うためのdivタグを追加する

  • タイトルやボタンに表示する文言をYAMLで管理する

  • ニックネームとプロフィール画像のフィールドを追加する

<!-- app/views/devise/registrations/edit.html.erb-->
+ <div class="col-md-8 offset-md-2 mt-3">
+ <div class="user-box">
+ <h3 class="text-center"><%= t ".title" %></h3>
+ <%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>
+ <%= devise_error_messages! %>
+ <div class="form-group">
+ <%= f.label :email %><br />
+ <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "form-control" %>
+ </div>
+ <div class="form-group">
+ <%= f.label :nick_name %><br />
+ <%= f.text_field :nick_name, class: "form-control" %>
+ </div>
+ <% if current_user.avatar.attached? %>
+ <%= image_tag current_user.avatar.variant(resize: "300x300"), class: "img-thumbnail" %>
+ <% end %>
+ <div class="form-group">
+ <%= f.label :avatar %>
+ <%= f.file_field :avatar, class: "form-control" %>
+ </div>
+ <% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>
+ <div>Currently waiting confirmation for: <%= resource.unconfirmed_email %></div>
+ <% end %>
+ <div class="form-group">
+ <%= f.label :password %> <i>(<%= t ".change_email" %>)</i><br />
+ <%= f.password_field :password, autocomplete: "new-password", class: "form-control" %>
+ <% if @minimum_password_length %>
+ <br />
+ <em><%= t(".minimum_password_length", length: @minimum_password_length) %></em>
+ <% end %>
+ </div>
+ <div class="form-group">
+ <%= f.label :password_confirmation %><br />
+ <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "form-control" %>
+ </div>
+ <div class="form-group">
+ <%= f.label :current_password %> <i>(<%= t ".current_password" %>)</i><br />
+ <%= f.password_field :current_password, autocomplete: "current-password", class: "form-control" %>
+ </div>
+ <div class="form-group">
+ <%= f.submit t(".submit"), class: "btn btn-primary" %>
+ </div>
+ <% end %>
+ <div class="form-inline mt-2">
+ <%= button_to t(".account_delete"), registration_path(resource_name), data: { confirm: t(".confirm") }, method: :delete, class: "btn btn-warning" %>
+ <%= link_to t(".return"), :back, class: "btn btn-secondary ml-2" %>
+ </div>
+ </div>
+ </div>

アカウント変更画面で使用する内容を、YAMLファイルに追記します。

# config/locales/ja.yml
ja:
activerecord:
models:
book: 書籍情報
attributes:
book:
title: タイトル
price: 価格
publish_date: 発売日
description: 詳細
new_image: 画像
category_id: カテゴリー
category: カテゴリー
user:
email: メールアドレス
nick_name: ニックネーム
avatar: プロフィール画像
password: パスワード
password_confirmation: パスワード再入力
+ current_password: 現在のパスワード
remember_me: ログイン情報を記録する
# config/locales/views/devise/ja.yml
ja:
devise:
registrations:
new:
title: ユーザー新規登録
password_confirmation: パスワード再入力
submit: 新規登録
minimum_password_length: "%{length} 文字以上を入力して下さい"
+ edit:
+ title: ユーザー編集
+ change_email: 変更したくない場合は空白のままにしてください。
+ submit: 更新
+ minimum_password_length: "%{length} 文字以上を入力して下さい"
+ current_password: 変更を確認するために現在のパスワードが必要です
+ account_delete: アカウントを削除する
+ return: 戻る
+ confirm: アカウント削除
sessions:
new:
title: ログイン
remember_me: ログイン情報を記録する
submit: ログイン

実際に変更できるか確認します。

ページネーション

画面送り機能(ページネーション)を実装します。 gem kaminariを使用します。

Gemfileに以下の内容を追記します。

# Gemfile
# 省略
gem 'bootsnap', '>= 1.1.0', require: false
gem 'bootstrap', '~> 4.1.3'
gem 'jquery-rails'
gem 'mini_magick'
gem 'devise'
+ gem 'kaminari'
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
end
# 省略

biコマンドを実行します。(gemのインストール)

$ bi

コントローラの修正

コントローラを以下のように修正します。

  • allメソッドからpageメソッドに変更

  • pageメソッドの引数にparams[:page]を指定する

rails kaminari 使い方等で検索すると、詳しい使い方が出てきます。 本家のサイトはこちら

# app/controllers/books_controller.rb
class BooksController < ApplicationController
before_action :set_book, only: [:show, :edit, :update, :destroy]
def index
- @books = Book.all
+ @books = Book.page(params[:page])
end
def new
@book = Book.new
end
# 省略

初期設定は1ページに20件となっております。 現在はデータが少ないので、1ページ4件とします。1ページの表示件数を変更する場合はperメソッドを使用します。

class BooksController < ApplicationController
before_action :set_book, only: [:show, :edit, :update, :destroy]
def index
- @books = Book.page(params[:page])
+ @books = Book.page(params[:page]).per(4)
end
def new
@book = Book.new
end

ビューの修正

コントローラを修正したら、今度は書籍一覧(index.html.erb)ビューを修正します。 kaminariにpaginateメソッドが用意されているので、paginateメソッドを使用します。引数は、本の情報を保持している、@booksになります。

ページネーションは常にBootstrapの12カラム分使用したいので、classにcol-12を指定します。

<!-- app/views/books/index.html.erb -->
<% @books.each do |book| %>
<div class="col-md-3 col-sm-6 mb-3">
<%= show_book_image book %>
<h4 class="mt-2"><%= link_to book.title, book_path(book) %></h4>
<div><%= t(".price_yen", price: (number_with_delimiter book.price)) %></div>
<p><%= truncate(book.description, length: 20) %></p>
</div>
<% end %>
+ <div class="col-12">
+ <%= paginate @books %>
+ </div>

確認

サーバを起動して確認します。

$ bin/rails s

見た目があまり良くないので、Bootstrapを適用します。

Bootstrapの適用

以下のコマンドでBootstrap4のデザインを適用します。

$ bin/rails g kaminari:views bootstrap4
# 以下のコマンドが表示されたら成功です
Running via Spring preloader in process 58006
downloading app/views/kaminari/_first_page.html.erb from kaminari_themes...
create app/views/kaminari/_first_page.html.erb
downloading app/views/kaminari/_gap.html.erb from kaminari_themes...
create app/views/kaminari/_gap.html.erb
downloading app/views/kaminari/_last_page.html.erb from kaminari_themes...
create app/views/kaminari/_last_page.html.erb
downloading app/views/kaminari/_next_page.html.erb from kaminari_themes...
create app/views/kaminari/_next_page.html.erb
downloading app/views/kaminari/_page.html.erb from kaminari_themes...
create app/views/kaminari/_page.html.erb
downloading app/views/kaminari/_paginator.html.erb from kaminari_themes...
create app/views/kaminari/_paginator.html.erb
downloading app/views/kaminari/_prev_page.html.erb from kaminari_themes...
create app/views/kaminari/_prev_page.html.erb

確認

サーバを起動して確認します。

$ bin/rails s

Bootstrapのデザインが適用されていることを確認します。

ページネーションを中央寄せにする

bootstrapのclassを指定することで中央寄せにすることが可能です。

<!-- app/views/books/index.html.erb -->
<% @books.each do |book| %>
<div class="col-md-3 col-sm-6 mb-3">
<%= show_book_image book %>
<h4 class="mt-2"><%= link_to book.title, book_path(book) %></h4>
<div><%= t(".price_yen", price: (number_with_delimiter book.price)) %></div>
<p><%= truncate(book.description, length: 20) %></p>
</div>
<% end %>
+ <div class="col-12">
<%= paginate @books %>
+ </div>
<!-- app/views/kaminari/_paginator.html.erb -->
<%= paginator.render do %>
<nav>
- <ul class="pagination">
+ <ul class="pagination justify-content-center">
<%= first_page_tag unless current_page.first? %>
<%= prev_page_tag unless current_page.first? %>
<% each_page do |page| %>
<% if page.left_outer? || page.right_outer? || page.inside_window? %>
<%= page_tag page %>
<% elsif !page.was_truncated? -%>
<%= gap_tag %>
<% end %>
<% end %>
<%= next_page_tag unless current_page.last? %>
<%= last_page_tag unless current_page.last? %>
</ul>
</nav>
<% end %>

以下のように中央に寄っていればOKです。

Reviewモデルの追加

モデル名:Review

ログイン機能が完成したので、ユーザーがレビューを登録できるように、レビュー投稿機能を追加します。 ユーザーは1つの本に対して1つのレビューしか投稿できません。

以下の内容でReviewモデルを作成します。 book_idはbook:referencesで作成します。 user_idはuser:referencesで作成します。

カラム名

カラム名(日本語)

データ型

title

タイトル

string

body

本文

text

evaluation

評価

integer

book_id

BookID

integer

user_id

ユーザーID

integer

今回も、bin/rails g resourceコマンドを使用して作成します。

$ bin/rails g resource review title:string body:text evaluation:integer book:references user:references

以下のカラムに成約を付けます。

  • タイトル:必須。50文字まで。

  • 本文:必須。500文字まで。

  • 評価:必須。

book_idとuser_idの複合インデックスを作成し、ユニーク制約を設定します。

# db/migrate/yyyyMMddHHmmss_create_reviews.rb
class CreateReviews < ActiveRecord::Migration[5.2]
def change
create_table :reviews do |t|
- t.string :title
- t.text :body
- t.integer :evaluation
+ t.string :title, null: false, length: 50
+ t.text :body, null: false, length: 500
+ t.integer :evaluation, null: false
t.references :book, foreign_key: true
t.references :user, foreign_key: true
t.timestamps
end
+ add_index :reviews, [:book_id, :user_id], unique: true
end
end

データベースに反映します。

$ bin/rails db:migrate

YAMLファイルに追加

Reviewモデルに関連するカラムを追加します。

# config/locales/ja.yml
ja:
activerecord:
models:
book: 書籍情報
attributes:
book:
title: タイトル
price: 価格
publish_date: 発売日
description: 詳細
new_image: 画像
category_id: カテゴリー
category: カテゴリー
user:
email: メールアドレス
nick_name: ニックネーム
avatar: プロフィール画像
password: パスワード
+ review:
+ title: タイトル
+ body: 本文
+ evaluation: 評価
+ book_id: 書籍
+ book: 書籍
+ user_id: ユーザー
+ user: ユーザー

ルーティングの追加

レビューは、ある本に必ず紐付いています。その場合、ルーティングをネストさせることで、book_idをパラメータとして取得することが可能です。 今回はレビューの一覧は本の詳細ページの下部に表示させるため、except: indexを指定します

# config/routes.rb
Rails.application.routes.draw do
- resources :reviews
devise_for :users
- resources :books
+ resources :books do
+ resources :reviews, except: :index
+ end
root "books#index"
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end

bin/rails routes|(パイプ)を使用して引数を渡すことで、検索結果を絞る事が可能となります。

$ bin/rails routes | grep reviews
book_reviews POST /books/:book_id/reviews(.:format) reviews#create
new_book_review GET /books/:book_id/reviews/new(.:format) reviews#new
edit_book_review GET /books/:book_id/reviews/:id/edit(.:format) reviews#edit
book_review GET /books/:book_id/reviews/:id(.:format) reviews#show
PATCH /books/:book_id/reviews/:id(.:format) reviews#update
PUT /books/:book_id/reviews/:id(.:format) reviews#update
DELETE /books/:book_id/reviews/:id(.:format) reviews#destroy

レビューの管理機能追加

本の詳細ページの下部にレビューの登録画面へ遷移するボタンを追加します。

レビュー登録画面へ遷移用ボタン追加

ログインしている時は、「レビューを投稿する」ボタンを表示しますが、ログインしていな時は、ボタンを非表示にします。

ログイン時

非ログイン時

user_signed_in?メソッドを使用して、ログイン状態を判断し、ボタンを制御します。

<!-- app/views/books/show.html.erb -->
<div class="col-md-10 offset-md-1 mt-5">
<div class="row">
<div class="col-md-4 mb-4">
<%= show_book_image @book %>
</div>
<div class="col-md-8">
<h2><%= @book.title %></h2>
<ul>
<li><b><%= t ".price" %>: </b><%= t(".price_yen", price: (number_with_delimiter @book.price)) %></li>
<li><b><%= t ".publish_date" %>: </b><%= @book.publish_date %></li>
<li><b><%= t ".category" %>: </b><%= @book.category&.name %></li>
</ul>
</div>
</div>
<hr>
<h4><%= t ".description" %></h4>
<%= simple_format @book.description %>
<%= link_to t(".edit"), edit_book_path(@book), class: "btn btn-primary" %>
<%= button_tag t(".delete"), class: "btn btn-warning", data: { toggle: "modal", target: "#delete-book" } %>
+ <% if user_signed_in? %>
+ <%= link_to t(".review_new"), new_book_review_path(@book), class: "btn btn-info" %>
+ <% end %>
<%= link_to t(".return"), books_path, class: "btn btn-secondary" %>
</div>
<!-- 省略 -->

YAMLファイルに追加

本の詳細ページに追加するボタンの文言を追加します。

# config/locales/views/books/ja.yml
ja:
books:
new:
title: 書籍登録
edit:
title: 書籍更新
form:
include_blank: カテゴリーを選択して下さい
show:
price: 価格
price_yen: "¥%{price}"
publish_date: 発売日
category: カテゴリー
description: 商品の説明
edit: 編集
return: 戻る
delete: 削除
modal_title: 書籍削除
question_book: "%{book}を削除しますか?"
delete_action: 削除する
chancel: キャンセル
+ review_new: レビューを投稿する
index:
price_yen: "¥%{price}"
# 省略

レビュー登録画面

アソシエーションとバリデーション

アソシエーションとバリデーションを設定します。

  • 1件のReviewに関連する書籍は1件なので、belongs_to :book

  • 1件のReviewに関連するユーザーは1件なので、belongs_to :user

  • 評価必須

  • タイトル必須

  • 本文必須

  • booK_idとuser_idはセットでユニーク(validates_uniqueness_ofメソッドを使用する)

# app/models/review.rb
class Review < ApplicationRecord
+ belongs_to :book
+ belongs_to :user
+ validates :evaluation, presence: true
+ validates :title, presence: true, length: { maximum: 50 }
+ validates :body, presence: true, length: { maximum: 500 }
+ validates_uniqueness_of :book_id, scope: :user_id
end

書籍が削除された場合、関連するレビューデータも一緒に削除します。 書籍情報が削除されたレビューデータは、残っていても表示される事はないので同じタイミングで削除します。

dependentオプションにdestroyを指定します。 :dependent => :destroyを省略してdependent: :destroyとします。

# app/models/book.rb
class Book < ApplicationRecord
belongs_to :category
+ has_many :reviews, dependent: :destroy
has_one_attached :image
attribute :new_image
# 省略

newアクションとcreateアクション

データを登録する場合は、newとcreateアクションが必要になります。 ストロングパラメータと一緒に作成します。

# app/controllers/reviews_controller.rb
class ReviewsController < ApplicationController
+ before_action :set_book, only: [:new, :create]
+ def new
+ @review = Review.new
+ end
+ def create
+ @review = Review.new(review_params)
+ @review.attributes = {
+ book_id: params[:book_id],
+ user_id: current_user.id
+ }
+ if @review.save
+ redirect_to @review.book, notice: "レビューを登録しました。"
+ else
+ render :new
+ end
+ end
+ private
+ def review_params
+ params.require(:review).permit(:title, :body, :evaluation)
+ end
+ def set_book
+ @book = Book.find(params[:book_id])
+ end
end

index.html.erb、_form.html.erb

レビュー登録画面と、登録画面、更新画面で使用するパーシャルフォームを作成します。

$ touch app/views/reviews/new.html.erb
$ touch app/views/reviews/_form.html.erb

今回はパーシャルフォームに、@bookと@reviewを渡す

<!-- app/views/reviews/new.html.erb -->
+ <div class="col-md-8 offset-md-2 mt-3">
+ <h2 class="text-center"><%= t '.title' %></h2>
+ <div class="mb-3">
+ <%= show_book_small_image @book %>
+ <span><%= @book.title %></span>
+ </div>
+ <hr>
+ <%= render partial: 'form', locals: { book: @book, review: @review } %>
+ </div>
# app/helpers/books_helper.rb
module BooksHelper
def show_book_image(book)
if book&.image&.attached?
image_tag book.image.variant(resize: "280x280"), class: "img-thumbnail"
else
image_tag "no_image.png", class: "img-thumbnail", width: 280
end
end
+ def show_book_small_image(book)
+ if book&.image&.attached?
+ image_tag book.image.variant(resize: "50x50"), class: "img-thumbnail"
+ else
+ image_tag "no_image.png", class: "img-thumbnail", width: 50
+ end
+ end
end

レビュー登録画面で評価を1〜5まで選択できるようにします。 railsのenum(列挙型)を使用します。モデルファイルにenum カラム名: { 項目名1: 値1, 項目名2: 値2 ... }と定義します。 カラム名は複数形となります。今回はevaluationsとなります。

# app/models/review.rb
class Review < ApplicationRecord
belongs_to :book
belongs_to :user
+ enum evaluations: { one: 1, two: 2, three: 3, four: 4, five: 5 }
validates :evaluation, presence: true
validates :title, presence: true, length: { maximum: 50 }
validates :body, presence: true, length: { maximum: 500 }
validates_uniqueness_of :book_id, scope: :user_id
end
  • 評価: selectメソッドを使用

  • タイトル: text_filedメソッドを使用

  • 本文: text_areaメソッドを使用

selectメソッドを使用して、enumで定義した内容を選択できるようにします。 フォームオブジェクト.select :カラム名, モデル名.定義したカラム名.values, { inclede_blank等のオプション }, clsss: "指定したいクラス名"となります。 enumの詳しい使い方はこちら

<!-- app/views/reviews/_form.html.erb -->
+ <%= form_with model: [book, review], local: true do |form| %>
+ <%= render "shared/errors", obj: review %>
+ <div class="form-group">
+ <%= form.label :evaluation %>
+ <%= form.select :evaluation, Review.evaluations.values, {}, class: "form-control" %>
+ </div>
+ <div class="form-group">
+ <%= form.label :title %>
+ <%= form.text_field :title, class: "form-control" %>
+ </div>
+ <div class="form-group">
+ <%= form.label :body %>
+ <%= form.text_area :body, rows: 10, class: "form-control" %>
+ </div>
+ <%= form.submit class: "btn btn-primary" %>
+ <%= link_to t(".back"), book, class: "btn btn-secondary" %>
+ <% end %>

日本語化

ビュー用のYAMLファイルを保存するディレクトリを作成します。 作成したディレクトリの中に、ja.ymlファイルを作成します。

$ mkdir config/locales/views/reviews/
$ touch config/locales/views/reviews/ja.yml
# config/locales/views/reviews/ja.yml
+ ja:
+ reviews:
+ new:
+ title: レビュー登録
+ form:
+ back: 戻る

以下のようなレビュー登録画面が表示されていることを確認します。

書籍照会画面修正

書籍照会画面の下部に、レビューの一覧を表示します。

_review.html.erb

レビューは複数件存在する可能性があるので、以下のようなコードになります。

@reviews.each do |review|
review.title
... 省略
end

do 〜 endの中をパーシャルフォームに切り出し、collectionオプションを使用して呼び出す事によって、eachメソッドが不要となります。 まずはパーシャルフォームを作成します。

$ touch app/views/reviews/_review.html.erb

each〜doの中に記述しようとしていた内容をパーシャルフォームに記述します。

<!-- app/views/reviews/_review.html.erb -->
+ <div>
+ <div>
+ <% if review.user.avatar.attached? %>
+ <%= image_tag review.user.avatar.variant(resize: "60x60"), class: "img-thumbnail" %>
+ <% else %>
+ <%= image_tag "no_image.png", class: "img-thumbnail", width: 60 %>
+ <% end %>
+ <span><%= review.user.nick_name %></span>
+ </div>
+ <div>
+ <div>
+ <span class="text-warning"><%= "★" * review.evaluation %></span>
+ <b><%= link_to review.title, book_review_path(review.book, review) %></b>
+ </div>
+ <small><%= review.created_at.strftime("%Y年%m月%d日") %></small>
+ </div>
+ <%= simple_format review.body %>
+ </div>

書籍紹介画面から先程作成したパーシャルフォームを呼び出します。collectionを使用することで、1件1件パーシャルフォームを呼ぶことが可能となります。 パーシャルフォームの使い方はこちら

<!-- app/views/books/show.html.erb -->
<div class="col-md-10 offset-md-1 mt-5">
<div class="row">
<div class="col-md-4 mb-4">
<%= show_book_image @book %>
</div>
<div class="col-md-8">
<h2><%= @book.title %></h2>
<ul>
<li><b><%= t ".price" %>: </b><%= t(".price_yen", price: (number_with_delimiter @book.price)) %></li>
<li><b><%= t ".publish_date" %>: </b><%= @book.publish_date %></li>
<li><b><%= t ".category" %>: </b><%= @book.category&.name %></li>
</ul>
</div>
</div>
<hr>
<h4><%= t ".description" %></h4>
<%= simple_format @book.description %>
<%= link_to t(".edit"), edit_book_path(@book), class: "btn btn-primary" %>
<%= button_tag t(".delete"), class: "btn btn-warning", data: { toggle: "modal", target: "#delete-book" } %>
<% if user_signed_in? %>
<%= link_to t(".review_new"), new_book_review_path(@book), class: "btn btn-info" %>
<% end %>
<%= link_to t(".return"), books_path, class: "btn btn-secondary" %>
+ <hr>
+ <%= render partial: "reviews/review", collection: @book.reviews %>
</div>
<div class="modal fade" id="delete-book">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel"><%= t(".modal_title") %></h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p><%= t(".question_book", book: @book.title) %></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal"><%= t(".chancel") %></button>
<%= link_to t(".delete_action"), book_path(@book), method: :delete, class: "btn btn-danger" %>
</div>
</div>
</div>
</div>

N+1問題

N+1問題とは、Tree状の情報をデータベースから読み出す際、全レコードの取得に一つ+各レコード分だけSQLを発行してしまう問題です。 こちらが参考になります。 現状、書籍一覧にアクセスすると、以下のSQLが実行されます。

修正前

  • 最初に本の情報を4件取得する

  • 本1冊に対して画像を取得するためのSQLが、2件ずつ発行されている

  • 1ページに表示する本の情報を増やした場合(例えば4件→8件)にすると、画像を取得するSQLがその分多く実行される

ActiveStorage用のN+1対策として、with_attached_imageが用意されているので、以下の通り修正します。

# app/controllers/books_controller.rb
class BooksController < ApplicationController
before_action :set_book, only: [:show, :edit, :update, :destroy]
def index
- @books = Book.page(params[:page]).per(4)
+ @books = Book.with_attached_image.page(params[:page]).per(4)
end
def new
@book = Book.new
end
# 省略
end

修正後

同じように、書籍照会画面のSQLも確認します。

(例) 照会する書籍に、レビューが2件あった場合。

修正前

  • usersテーブルへSQLを2回発行している

  • 書籍に紐付くレビューのユーザー情報を、その都度取得しにいくのではなく先読みする必要がある(メモリ上に保持しておく)

includesメソッドを使用することで、関連する情報を先読みすることが可能です。 書籍に紐付くユーザー情報を取得するにはレビューを経由する必要があります。

以下のように書くことで取得することが可能です。 Book.with_attached_image.includes(reviews: :user).find(params[:id])

書籍に紐付くユーザー情報を取得する際、毎回レビューを経由するとコードの記述量が増えます。その場合は、アソシエーションのhas_many :throughを使用します。

has_many :through関連付けについては、こちらを参照下さい。

# app/models/book.rb
class Book < ApplicationRecord
belongs_to :category
has_many :reviews, dependent: :destroy
+ has_many :users, through: :reviews
has_one_attached :image
attribute :new_image

has_many :users, through: :reviewsを追加することで以下のように記述することが可能となります。 Book.with_attached_image.includes(:users).find(params[:id])

以下のように修正します。showアクションではset_bookを使用しないので、before_actionも修正します。

# app/controllers/books_controller.rb
class BooksController < ApplicationController
- before_action :set_book, only: [:show, :edit, :update, :destroy]
+ before_action :set_book, only: [:edit, :update, :destroy]
def index
@books = Book.with_attached_image.page(params[:page]).per(4)
end
def new
@book = Book.new
end
def create
@book = Book.new(book_params)
if @book.save
redirect_to @book, notice: "書籍を登録しました。"
else
render :new
end
end
def show
+ @book = Book.with_attached_image.includes(reviews: :user).find(params[:id])
end
# 省略
private
def book_params
params.require(:book).permit(:title, :price, :publish_date,
:description, :new_image, :category_id)
end
def set_book
@book = Book.find(params[:id])
end
end

修正後

レビュー照会画面

レビュー照会画面を作成します。

showアクション

set_reviewメソッドを作成し、before_actionで実行します。

# app/controllers/reviews_controller.rb
class ReviewsController < ApplicationController
- before_action :set_book, only: [:new, :create]
+ before_action :set_book, only: [:show, :new, :create]
+ before_action :set_review, only: :show
def new
@review = Review.new
end
def create
@review = Review.new(review_params)
@review.attributes = {
book_id: params[:book_id],
user_id: current_user.id
}
if @review.save
redirect_to @review.book, notice: "レビューを登録しました。"
else
render :new
end
end
+ def show
+ end
private
def review_params
params.require(:review).permit(:title, :body, :evaluation)
end
def set_book
@book = Book.find(params[:book_id])
end
+ def set_review
+ @review = Review.find(params[:id])
+ end
end

show.html.erb

レビュー詳細画面用のビューファイルを作成します。

$ touch app/views/reviews/show.html.erb
<!-- app/views/reviews/show.html.erb -->
+ <div class="col-md-8 offset-md-2 mt-5">
+ <h2 class="text-center"><%= t ".title" %></h2>
+ <div>
+ <% if @review.user.avatar.attached? %>
+ <%= image_tag @review.user.avatar.variant(resize: "60x60"), class: "img-thumbnail" %>
+ <% else %>
+ <%= image_tag "no_image.png", class: "img-thumbnail", width: 60 %>
+ <% end %>
+ <span><%= @review.user.nick_name %></span>
+ </div>
+ <hr>
+ <div>
+ <div>
+ <span class="text-warning"><%= "★" * @review.evaluation %></span>
+ <b><%= @review.title %></b>
+ </div>
+ <small><%= @review.created_at.strftime("%Y年%m月%d日") %></small>
+ </div>
+ <%= simple_format @review.body %>
+ <% if user_signed_in? && current_user.id == @review.user_id %>
+ <%= link_to t(".edit"), edit_book_review_path(@book, @review), class: "btn btn-info" %>
+ <% end %>
+ <%= link_to t(".back"), @book, class: "btn btn-secondary" %>
+ </div>

YAMLファイルに追記します。

# config/locales/views/reviews/ja.yml
ja:
reviews:
new:
title: レビュー登録
+ show:
+ title: カスタマーレビュー
+ edit: 編集
+ back: 戻る

レビュー編集画面

editアクションとupdateアクション

# app/controllers/reviews_controller.rb
class ReviewsController < ApplicationController
- before_action :set_book, only: [:show, :new, :create]
+ before_action :set_book, only: [:show, :new, :create, :edit]
- before_action :set_review, only: :show
+ before_action :set_review, only: [:show, :edit, :update]
def new
@review = Review.new
end
def create
@review = Review.new(review_params)
@review.attributes = {
book_id: params[:book_id],
user_id: current_user.id
}
if @review.save
redirect_to @review.book, notice: "レビューを登録しました。"
else
render :new
end
end
def show
end
+ def edit
+ end
+ def update
+ if @review.update(review_params)
+ redirect_to @review.book, notice: "レビューを更新しました。"
+ else
+ render :edit
+ end
+ end
private
def review_params
params.require(:review).permit(:title, :body, :evaluation)
end
def set_book
@book = Book.find(params[:book_id])
end
def set_review
@review = Review.find(params[:id])
end
end

edit.html.erb

レビュー更新用のビューファイルを作成します。

$ touch app/views/reviews/edit.html.erb

レビュー登録画面と同じパーシャルフォームを呼びます。

<!-- app/views/reviews/edit.html.erb -->
+ <div class="col-md-8 offset-md-2 mt-3">
+ <h2 class="text-center"><%= t '.title' %></h2>
+ <div class="mb-3">
+ <%= show_book_small_image @book %>
+ <span><%= @book.title %></span>
+ </div>
+ <hr>
+ <%= render partial: 'form', locals: { book: @book, review: @review } %>
+ </div>

YAMLファイルに追記します。

# config/locales/views/reviews/ja.yml
ja:
reviews:
new:
title: レビュー登録
+ edit:
+ title: レビュー更新
show:
title: カスタマーレビュー
edit: 編集
back: 戻る

このまま戻るボタンを押すと、書籍の照会画面に戻ってしまいます。レビューの登録時は書籍の照会画面から遷移するため、同じパーシャルフォームを使用するとそのような動きになります。 遷移元の画面に戻りたい場合はlink_to:backを指定します。

<%= form_with model: [book, review], local: true do |form| %>
<%= render "shared/errors", obj: review %>
<div class="form-group">
<%= form.label :evaluation %>
<%= form.select :evaluation, Review.evaluations.values, {}, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :title %>
<%= form.text_field :title, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :body %>
<%= form.text_area :body, rows: 10, class: "form-control" %>
</div>
<%= form.submit class: "btn btn-primary" %>
- <%= link_to '戻る', book, class: "btn btn-secondary" %>
+ <%= link_to '戻る', :back, class: "btn btn-secondary" %>
<% end %>

レビュー削除機能

レビューの削除機能を、レビュー照会画面に追加します。

削除時のメッセージはモーダルで表示します。

destroyアクション

# app/controllers/reviews_controller.rb
class ReviewsController < ApplicationController
before_action :set_book, only: [:show, :new, :create, :edit]
- before_action :set_review, only: [:show, :edit, :update]
+ before_action :set_review, only: [:show, :edit, :update, :destroy]
def new
@review = Review.new
end
def create
@review = Review.new(review_params)
@review.attributes = {
book_id: params[:book_id],
user_id: current_user.id
}
if @review.save
redirect_to @review.book, notice: "レビューを登録しました。"
else
render :new
end
end
def show
end
def edit
end
def update
if @review.update(review_params)
redirect_to @review.book, notice: "レビューを更新しました。"
else
render :edit
end
end
+ def destroy
+ @review.destroy
+ redirect_to @review.book, notice: "レビューを削除しました。"
+ end
private
def review_params
params.require(:review).permit(:title, :body, :evaluation)
end
def set_book
@book = Book.find(params[:book_id])
end
def set_review
@review = Review.find(params[:id])
end
end

show.html.erb

レビュー照会画面に削除ボタンとモーダルウィンドウを追加します。

<!-- app/views/reviews/show.html.erb -->
<div class="col-md-8 offset-md-2 mt-5">
<h2 class="text-center"><%= t ".title" %></h2>
<div>
<% if @review.user.avatar.attached? %>
<%= image_tag @review.user.avatar.variant(resize: "60x60"), class: "img-thumbnail" %>
<% else %>
<%= image_tag "no_image.png", class: "img-thumbnail", width: 60 %>
<% end %>
<span><%= @review.user.nick_name %></span>
</div>
<hr>
<div>
<div>
<span class="text-warning"><%= "★" * @review.evaluation %></span>
<b><%= @review.title %></b>
</div>
<small><%= @review.created_at.strftime("%Y年%m月%d日") %></small>
</div>
<%= simple_format @review.body %>
<% if user_signed_in? && current_user.id == @review.user_id %>
<%= link_to t(".edit"), edit_book_review_path(@book, @review), class: "btn btn-info" %>
+ <%= button_tag t(".delete"), class: "btn btn-warning", data: { toggle: "modal", target: "#delete-review" } %>
<% end %>
<%= link_to t(".back"), @book, class: "btn btn-secondary" %>
</div>
+ <div class="modal fade" id="delete-review">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="exampleModalLabel"><%= t(".modal_title") %></h5>
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <span aria-hidden="true">&times;</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <p><%= t(".question_review", review: @review.title) %></p>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-dismiss="modal"><%= t(".chancel") %></button>
+ <%= link_to t(".delete_action"), book_review_path(@book, @review), method: :delete, class: "btn btn-danger" %>
+ </div>
+ </div>
+ </div>
+ </div>
# config/locales/views/reviews/ja.yml
ja:
reviews:
new:
title: レビュー登録
edit:
title: レビュー更新
show:
title: カスタマーレビュー
edit: 編集
back: 戻る
+ delete: 削除
+ modal_title: レビュー削除
+ question_review: "%{review}を削除しますか?"
+ delete_action: 削除する
+ chancel: キャンセル

scope(スコープ)

scopeとはコントローラ等に記述した複数のクエリ(where等を複数結合した記述)を、モデルのメソッドとして切り出す仕組みのことを言います。

書籍一覧画面に本を取得する際、発売日(publish_date)の降順でデータを取得するようにします。 データを並び替える場合はorderを使用します。

  • 降順: order(カラム名: :desc)

  • 昇順: order(カラム名: :asc):ascは省略可能なので、order(:カラム名)でも可。

今回はBook.with_attached_image.page(params[:page]).per(4).order(publish_date: :desc)となります。

# app/controllers/books_controller.rb
class BooksController < ApplicationController
before_action :set_book, only: [:edit, :update, :destroy]
def index
- @books = Book.with_attached_image.page(params[:page]).per(4)
+ @books = Book.with_attached_image.page(params[:page]).per(4).order(publish_date: :desc)
end
# 省略
end

今後画面が増えてきた場合、他の画面でも発売日の降順(新発売の商品を優先)で表示する事が増える可能性あります。 その場合はスコープとして外に切り出して、コントローラではスコープを呼ぶようにします。

# app/models/book.rb
class Book < ApplicationRecord
belongs_to :category
has_many :reviews, dependent: :destroy
has_many :users, through: :reviews
has_one_attached :image
attribute :new_image
validates :title, presence: true, length: { maximum: 50 }
validates :price, presence: true,
numericality: {
only_integer: true,
greater_than: 1
}
validates :publish_date, presence: true
validates :description, presence: true, length: { maximum: 1000 }
+ scope :find_newest_books, -> { order(publish_date: :desc) }
before_save do
if new_image
self.image = new_image
end
end
end

先程作成したスコープに置き換えます。 まだリファクタリングできそうなので、page(params[:page])の部分もスコープ内に含めます。 params[:page]はコントローラ内でしか参照できないので、スコープにパラメータとしてparams[:page]を渡す必要があります。

# app/controllers/books_controller.rb
class BooksController < ApplicationController
before_action :set_book, only: [:edit, :update, :destroy]
def index
- @books = Book.with_attached_image.page(params[:page]).per(4).order(publish_date: :desc)
+ @books = Book.with_attached_image.page(params[:page]).per(4).find_newest_books
end
# 省略
end

スコープではparams[:page]pという変数を受け取ります。

# app/models/book.rb
class Book < ApplicationRecord
belongs_to :category
has_many :reviews, dependent: :destroy
has_many :users, through: :reviews
has_one_attached :image
attribute :new_image
validates :title, presence: true, length: { maximum: 50 }
validates :price, presence: true,
numericality: {
only_integer: true,
greater_than: 1
}
validates :publish_date, presence: true
validates :description, presence: true, length: { maximum: 1000 }
- scope :find_newest_books, -> { order(publish_date: :desc) }
+ scope :find_newest_books, -> (p) { page(p).per(4).order(publish_date: :desc) }
before_save do
if new_image
self.image = new_image
end
end
end

コントローラ

# app/controllers/books_controller.rb
class BooksController < ApplicationController
before_action :set_book, only: [:edit, :update, :destroy]
def index
- @books = Book.with_attached_image.page(params[:page]).per(4).find_newest_books
+ @books = Book.with_attached_image.find_newest_books(params[:page])
end
# 省略
end

検索機能

書籍一覧画面に検索機能を追加します。 ransackというgemを使用することで、簡単に検索機能を実装することが可能です。

Gemfilemにransackを追加します。

# Gemfile
# 省略
gem 'bootsnap', '>= 1.1.0', require: false
gem 'bootstrap', '~> 4.1.3'
gem 'jquery-rails'
gem 'mini_magick'
gem 'devise'
+ gem 'ransack'
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
end
# 省略

Gemfileを修正したらbundle installを行います。

$ bi

_header.html.erbの修正

仮で作成していたヘッダーの検索フォームを修正します。 通常のフォームはform_withを使用しますが、ransackの場合はsearch_form_forというビューヘルパーが用意されているので、そちらを使用します。 search_form_forの引数として、@qを渡します。

今回は検索フォームに入力された内容が、「タイトル、もしくは本文に含まれる」書籍を検索するので、search_fieldにはtitle_or_description_contを設定します。 仮に検索フォームに入力された内容が、「タイトル含まれる」といった条件で検索する場合は、title_contとなります。

<!-- app/views/shared/_header.html.erb -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-3">
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarToggler" aria-controls="navbarTogglerDemo03" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<%= link_to t(".title"), root_path, class: "navbar-brand" %>
<div class="collapse navbar-collapse" id="navbarToggler">
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
<li class="nav-item">
<%= link_to t(".home"), root_path, class: "nav-link" %>
</li>
<li class="nav-item">
<%= link_to t(".new_book"), new_book_path, class: "nav-link" %>
</li>
<% if user_signed_in? %>
<li class="nav-item">
<%= link_to t(".edit_registration"), edit_user_registration_path, class: "nav-link" %>
</li>
<li class="nav-item">
<%= link_to t(".log_out"), destroy_user_session_path, method: :delete, class: "nav-link" %>
</li>
<% else %>
<li class="nav-item">
<%= link_to t(".new_registration"), new_user_registration_path, class: "nav-link" %>
</li>
<li class="nav-item">
<%= link_to t(".log_in"), new_user_session_path, class: "nav-link" %>
</li>
<% end %>
</ul>
- <form class="form-inline my-2 my-lg-0">
- <input class="form-control mr-sm-2" type="search" placeholder="Search" aria-label="Search">
- <button class="btn btn-info my-2 my-sm-0" type="submit">Search</button>
- </form>
+ <%= search_form_for @q, class: "form-inline my-2 my-lg-0" do |f| %>
+ <%= f.search_field :title_or_description_cont, class: "form-control mr-sm-2" %>
+ <%= f.submit t(".search"), class: "btn btn-info my-2 my-sm-0" %>
+ <% end %>
</div>
</nav>

application_controller.rb

検索フォームはログイン画面やアカウント設定画面の場合でも表示されるため、books_contoroller.rbではなくapplication_controller.rbに処理を記述します。 set_searchメソッドを作成し、before_actionで処理を呼びます。

  • params[:q]とすることで、入力された検索条件を取得する事が可能

  • @qに検索結果がセットされるので、その値をもとに、books_controller.rbのindexアクションと同じ処理を記述する

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :configure_permitted_parameters, if: :devise_controller?
+ before_action :set_search
+ def set_search
+ @q = Book.ransack(params[:q])
+ @books = @q.result.with_attached_image.find_newest_books(params[:page])
+ end
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [:nick_name, :avatar])
devise_parameter_sanitizer.permit(:account_update, keys: [:nick_name, :avatar])
end
end
# app/controllers/books_controller.rb
class BooksController < ApplicationController
before_action :set_book, only: [:show, :edit, :update, :destroy]
def index
- @books = Book.with_attached_image.find_newest_books(params[:page])
end
def new
@book = Book.new
end
# 省略

もし検索フォームがヘッダーではなく、app/views/books/index.html.erb の中に存在した場合は、application_controller.rbを修正せずにbooks_contorollerを以下のように修正します。

# app/controllers/books_controller.rb
class BooksController < ApplicationController
before_action :set_book, only: [:edit, :update, :destroy]
def index
- @books = Book.with_attached_image.find_newest_books(params[:page])
+ @q = Book.ransack(params[:q])
+ @books = @q.result.with_attached_image.find_newest_books(params[:page])
end
def new
@book = Book.new
end
# 省略

YAMLファイルに追加します。

# config/locales/views/shared/ja.yml
---
ja:
shared:
header:
title: BookShelf
home: Home
new_book: New Book
new_registration: 新規登録
edit_registration: 設定変更
log_out: ログアウト
log_in: ログイン
+ search: 検索
Contents
アプリ作成前の準備
.gitignore
Bookモデル作成
マイスレーションファイルの修正
Bootstrapの導入
Gemfileの修正
application.jsの修正
application.cssの修正
パーシャルフォーム(部分テンプレート)
ルートパスの設定
indexアクション
index.html.erb
確認
Bookの登録機能
newアクションとcreateアクション
flash
new.html.erbと_form.html.erb
Bookの照会機能
showアクション
show.htmlと_flash.html.erb
バリデーション
モデルにバリデーションを設定
バリデーションのチェックとエラーメッセージの表示
エラーメッセージの確認
国際化対応
画像の登録機能
コールバック
利用可能なコールバック
画像登録フィールドの追加
照会画面に画像を表示
ビューヘルパー
編集機能の作成
editアクションとupdateアクション
edit.html.erb
削除機能の作成
destroyアクション
show.html.erb
書籍一覧機能の作成
indexアクション
index.html.erb
リファクタリング
Categoryモデルの追加
ダミーデータ(seedデータ)の作成
アソシエーション
Bookモデル(booksテーブル)にカラム追加
アソシエーションの設定
カテゴリー選択
ログイン機能の実装
deviseの実装
Devise用日本語ファイル
Devise関連の画面をカスタマイズ
Active Storageの設定
新規登録画面の修正
Devise用のストロングパラメータ
ログイン画面の修正
アカウント変更画面を修正
ページネーション
コントローラの修正
ビューの修正
確認
Bootstrapの適用
確認
ページネーションを中央寄せにする
Reviewモデルの追加
YAMLファイルに追加
ルーティングの追加
レビューの管理機能追加
レビュー登録画面へ遷移用ボタン追加
YAMLファイルに追加
レビュー登録画面
アソシエーションとバリデーション
newアクションとcreateアクション
index.html.erb、_form.html.erb
日本語化
書籍照会画面修正
_review.html.erb
N+1問題
レビュー照会画面
showアクション
show.html.erb
レビュー編集画面
editアクションとupdateアクション
edit.html.erb
レビュー削除機能
destroyアクション
show.html.erb
scope(スコープ)
検索機能
_header.html.erbの修正
application_controller.rb