前回記事
Rails 選択ボックスの使い方 1 ー 蔵書管理アプリに「本のジャンル」を追加してみる 基本編 (select)
はじめに
基本編では、selectメソッドを使った選択ボックスで以下のように蔵書管理アプリに「本のジャンル」(Genre)を登録できる機能を実装しました。
しかし、現状の仕様では以下の課題がある状態です。
[課題]
選択ボックスの内容が既に決まっている為、本のジャンルをアプリ上で
追加・削除したいというニーズに対応できない。
本記事では選択ボックス応用編として「collection_select」の使い方を解説します。
collection_selectを使うとDBに保存したデータを選択肢として表示できるようになります。
まず始めに準備としてscaffoldで「本のジャンル管理機能」(CRUD)を追加します。
次にcollection_selectを使ってDB経由で選択肢表示ができる選択ボックスを作成します。
ユーザーが好みに合わせて「本のジャンル」をアプリ上で自由に追加・削除できる機能を追加することが目標です。
[補足]
DBのテーブル内容が分かりやすいように、本記事ではSeqelProを使って解説します。
関連記事
データベースの操作がGUIで分かりやすく操作できるSequel Proの最低限の使い方
Sequel PRO:データベースに接続する方法
目指すゴール
(1) 蔵書管理アプリに「本のジャンルCRUD機能」を追加する
(2) collection_selectを使った選択ボックスの作り方を理解する
実装の流れ
1 scaffoldで「本のジャンルCRUD機能」を追加
2 アソシエーションを定義(Genreモデル&Bookモデル)
3 books_controllerを編集
4 collection_selectを使って選択ボックスを書き換え
5 蔵書一覧ページをアソシエーション利用で書き換え
1 scaffoldで「本のジャンルCRUD機能」を追加
1) scaffoldコマンドを実行
始めにscaffoldコマンドを実行して「本のジャンル」管理機能を一気に追加します。
genresテーブルのカラムはstring型のnameカラムのみとしておきます。
[bookapp] $ rails g scaffold genre name:string
2) migration実行
scaffoldで関連ファイル生成後はmigrationを実行します。
これによってDBにgenresテーブルが新規で追加されます。
[bookapp] $ rake db:migrate
== CreateGenres: migrating =====================================
-- create_table(:genres)
== CreateGenres: migrated ============================
3) 蔵書一覧ページにリンクを追加
Topページ(books#index)のViewにジャンル一覧ページへのリンクを追加します。
marginも併せて設定しておくことにします。
<p id="notice"><%= notice %></p>
<h1>Books</h1>
(中略)
<%= link_to 'New Book', new_book_path %>
+ <%= link_to 'Genres', genres_path, style: 'margin-left:15px' %>
4)ジャンル一覧ページにテストデータを追加
この段階でテストデータとしていくつか本のジャンルを追加しておきます。
(テストデータは適宜変更してください)
蔵書一覧画面へのリンクも併せて追加しておきましょう。
<p id="notice"><%= notice %></p>
<h1>Genres</h1>
(中略)
<%= link_to 'New Genre', new_genre_path %>
+ <%= link_to 'Books', books_path, style: 'margin-left:15px' %>
2 アソシエーション定義(Genreモデル&Bookモデル)
DB経由で選択ボックスを表示できるように準備を進めます。
まず始めに必要な作業はアソシエーションの定義です。
GenreモデルとBookモデルとの間に1対多のアソシエーションを設定していきます。
1) Booksテーブルのgenreカラムを削除
現段階では、「本のジャンル」はBooksテーブルのgenreカラムに保存する仕様です。
Genresテーブルに保存する仕様に移行する為、Booksテーブルのgenreカラムを削除します。
migrationファイルを作成し、カラムの削除を実行します。
$ rails g migration RemoveGenreFromBooks genre:string
$ rake db:migrate
2) Booksテーブルに外部キーを追加 (genre_id)
アソシエーションで本のジャンルが参照できるようにBooksテーブルに外部キー(genre_id)を追加します。
$ rails g migration AddGenreIdToBooks
$ rake db:migrate
class AddGenreIdToBooks < ActiveRecord::Migration[5.2]
def change
add_reference :books, :genre, foreign_key: true
end
end
[補足]
関連記事
Railsで外部キー制約のついたカラムを作る時のmigrationの書き方
3)Genreモデル&Bookモデルにアソシエーションを追加
テーブルの準備が完了したら、アソシエーションを定義します。
GenreモデルとBookモデルに以下を追加してください。
併せて「ジャンル名」、「書名」に簡単なバリデーションを追加しておくことにします。
class Genre < ApplicationRecord
+ has_many :books
+ validates :name, presence:true
end
class Book < ApplicationRecord
+ belongs_to :genre, optional: true
+ validates :title, presence:true
end
[解説]
optional:trueについて
本アプリでは「ジャンルはとりあえず空欄にしておきたい」というニーズに対応する為、
「ジャンル名」は任意とします。
しかし、Rails5ではbelongs_toにはデフォルトでNOT NULL制約が付いている為
「belongs_to :genre」のみでは以下のようなエラーが発生します。
belongs_toにoptional:trueを追加することで、外部キーがnullでもDB保存可能になります。
関連記事
Rails5からbelongs_to関連はデフォルトでrequired: trueになる
3 books_controllerを編集
次にgenresテーブル経由で「本のジャンル」の選択ボックスを表示できるように
books_controllerを編集します。必要な作業は以下の2つです。
(1) before_actionでset_genresメソッドを定義
(2) strong parameterを「genre→gebnre_id」に変更
class BooksController < ApplicationController
before_action :set_book, only: [:show,:edit,:update,:destroy]
+ before_action :set_genres, only: [:index, :new, :edit, :create,:update]
(中略)
private
def set_book
@book = Book.find(params[:id])
end
def book_params
- params.require(:book).permit(:title, :author, :genre)
+ params.require(:book).permit(:title, :author, :genre_id)
end
+
+ def set_genre
+ @genres = Genre.all
+ end
end
4 collection_selectを使って選択ボックスを書き換え
ここまでの作業で必要な準備が一通り完了しました。
次に「本のジャンル」選択ボックスをcollection_selectで書き換えます。
_form.html.erbの部分テンプレートを以下のように編集してください。
<%= form_with(model: book, local: true) do |form| %>
(中略)
<div class="field">
<%= form.label :genre %>
- <%= form.select :genre, ['小説', 'プログラミング', 'ビジネス'], {include_blank: "---"} %>
+ <%= form.collection_select :genre_id, @genres, :id, :name, {include_blank: "---"} %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<% end %>
[解説]
1 collection_selectの使い方
[構文]
collection_select プロパティ名(シンボル), 選択肢の配列(インスタンス変数), value属性,表示テキスト,{オプション}, {HTMLオプション}(id,class等)
例: <%= form.collection_select :genre_id, @genres, :id, :name, {include_blank: "---"} %>
2 selectとcollection_selectの違い
選択ボックス表示に使用するselectとcollection_selectですが、改めて両者の違いを整理しておきます。
[性質面]
select: 既定の選択肢を表示
例: <%= form.select :genre, ['小説', 'プログラミング', 'ビジネス'], {include_blank: "---"} %>
collection_select: DBより選択肢を生成
本アプリではgenresテーブルに保存された「ジャンル名」を選択ボックスに表示しています。
[構文面] プロパティ名の後に必須となる引数の数が違う
select: 1つのみ(選択肢のみ)
=> ['小説', 'プログラミング', 'ビジネス']
collection_select: 3つ必要(選択肢の配列、value属性、表示テキスト)
=> @genres, :id, :name
5 蔵書一覧ページをアソシエーション利用で書き換え
最後にgenresテーブル経由で「本のジャンル」を表示できるように蔵書一覧ページをアソシエーション利用で書き換えます。
genreがnilの場合にエラーが発生するのを防ぐ為、if分岐でジャンル名がある場合のみ
蔵書一覧ページに表示するようにします。
<p id="notice"><%= notice %></p>
<h1>Books</h1>
(中略)
<tbody>
<% @books.each do |book| %>
<tr>
<td><%= book.title %></td>
<td><%= book.author %></td>
- <td><%= book.genre %></td>
+ <td><%= book.genre.name if book.genre.present? %></td>
<td><%= link_to 'Show', book %></td>
<td><%= link_to 'Edit', edit_book_path(book) %></td>
<td><%= link_to 'Destroy', book, method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>
<% end %>
</tbody>
ここまで出来たら以下、想定通りに動くか確認します。
(1) 蔵書一覧データにジャンルを追加できる
(2) Booksテーブルにgenre_idが外部キーとして保存されている
上記例のように両方確認ができれば、選択ボックスの使い方応用編も完成です。
補足
before_action(set_genres)にcreate/updateが必要な理由
books_controllerでbefore_action(set_genres)を定義した時、
「一覧(index)、新規登録(new)、編集(edit)」以外になぜcreate/updateも設定する必要があるのか疑問に思われた方もいらっしゃるかもしれません。
理由は「バリデーションエラーが発生した際にViewが表示できない不具合が発生するのを防ぐ為」です。
本アプリでは「書名」にはpresence:trueのバリデーションを設定していますが、before_actionの対象にcreate/updateがない場合、バリデーションエラー発生と同時に以下エラーが出ます。
これはcreate/updateでcollection_selectの選択肢に使用しているインスタンス変数@genresが定義されていない為に発生するエラーです。
before_actionの対象にcreate/updateを追加することで上記エラーは回避可能になります。
関連記事
collection_selectにインスタンス変数を渡すと、undefined method `map' for nil:NilClass が発生するエラー