はじめに
親のカテゴリを変更すると、子のカテゴリの中身が動的に変わるセレクトボックスってよくありますよね。
たとえばこんなやつです↓
この動きを実現するごく簡単なサンプルアプリを作ってみたので、今回はそれを紹介します。
対象となるRubyとRailsのバージョン
今回のサンプルアプリは以下の環境で動作します。
- Rails 4.2.1
- Ruby 2.2.1
サンプルアプリのソースコード
今回作ったサンプルアプリはGitHubに置いています。
READMEにセットアップ方法も載せているので、ご自身のローカル環境で動かしてみてください。
ざっくりとした処理の流れ
この動きを実現するためにはこういう処理の流れになります。
- ユーザーが親カテゴリが変更する
- JavaScriptのchangeイベントが呼ばれる
- JavaScriptがサーバーに子カテゴリの一覧を問い合わせる
- サーバーが子カテゴリの一覧をJSONで返す
- JavaScriptがサーバーから子カテゴリの一覧を受け取る
- 子カテゴリセレクトボックスの中身をサーバーから受け取った子カテゴリの一覧で置き換える
いわゆるAjaxってやつですね。
言葉にすると簡単ですが、技術的にはいろいろな要素が絡んでくるので初心者の方にとっては結構大変かもしれません。
そこで、今から動作に必要な各コードを順番に説明していきます。
モデル
モデルはItem, Category, SubCategoryの3種類です。
class Item < ActiveRecord::Base
belongs_to :category
belongs_to :sub_category
end
class Category < ActiveRecord::Base
has_many :sub_categories, ->{ order(:id) }
end
class SubCategory < ActiveRecord::Base
belongs_to :category
end
モデル図で表すとこんな感じになります。
ルーティング
routes.rbは以下のようになっています。
Rails.application.routes.draw do
resources :items
resources :categories, only: [] do
resources :sub_categories, only: :index
end
root 'items#index'
end
sub_categories#index
がAjaxで呼ばれて子カテゴリの一覧を返すアクションです。
View
Viewはslimを使って書きました。
また、Bootstrap向けのHTMLをレンダリングするためにRails Bootstrap Formsというgemのbootstrap_form_for
メソッドを使っています。
= bootstrap_form_for @item do |f|
= f.text_field :name
- category_options = Category.order(:id).map { |c| [c.name, c.id, data: { children_path: category_sub_categories_path(c) }] }
= f.select :category_id, category_options, { include_blank: true }, class: 'select-parent'
- sub_categories = @item.category.try(:sub_categories) || []
- sub_category_options = sub_categories.map { |c| [c.name, c.id] }
= f.select :sub_category_id, sub_category_options, { include_blank: true }, class: 'select-children'
= f.submit 'Save', class: 'btn btn-primary'
注目してほしいのは以下の部分です。
category_options = Category.order(:id).map { |c|
[c.name, c.id, data: { children_path: category_sub_categories_path(c) }]
}
セレクトボックスの中身を作る際にdata属性をオプションで渡しています。
この配列をf.select
に渡すと以下のようなHTMLが出力されます。
<select class="form-control select-parent" name="item[category_id]" id="item_category_id">
<option value=""></option>
<option data-children-path="/categories/1/sub_categories" value="1">本</option>
<option data-children-path="/categories/2/sub_categories" value="2">家電・カメラ・AV機器</option>
<option data-children-path="/categories/3/sub_categories" value="3">ホーム・キッチン</option>
</select>
data-children-path
に子カテゴリを取得する際のpathが入るのがポイントです。
このあとのJavaScriptにてこのpathを使います。
CoffeeScript (JavaScript)
JavaScriptの処理はCoffeeScriptで書いています。
$ ->
do ->
replaceSelectOptions = ($select, results) ->
$select.html $('<option>')
$.each results, ->
option = $('<option>').val(this.id).text(this.name)
$select.append(option)
replaceChildrenOptions = ->
childrenPath = $(@).find('option:selected').data().childrenPath
$selectChildren = $(@).closest('form').find('.select-children')
if childrenPath?
$.ajax
url: childrenPath
dataType: "json"
success: (results) ->
replaceSelectOptions($selectChildren, results)
error: (XMLHttpRequest, textStatus, errorThrown) ->
console.error("Error occurred in replaceChildrenOptions")
console.log("XMLHttpRequest: #{XMLHttpRequest.status}")
console.log("textStatus: #{textStatus}")
console.log("errorThrown: #{errorThrown}")
else
replaceSelectOptions($selectChildren, [])
$('.select-parent').on
'change': replaceChildrenOptions
このコードは大きく分けて3つの処理にわかれています。
まず、以下のコードで親カテゴリ変更時のイベントを設定しています。
$('.select-parent').on
'change': replaceChildrenOptions
次に、replaceChildrenOptions
がchangeイベントで呼ばれるメソッドです。
何をやっているのかわかりやすくするためにコメントを入れてみます。
replaceChildrenOptions = ->
# 選択された親カテゴリのオプションから、data-children-pathの値を読み取る
childrenPath = $(@).find('option:selected').data().childrenPath
# 子カテゴリのセレクトボックスを取得する
$selectChildren = $(@).closest('form').find('.select-children')
if childrenPath?
# childrenPathが存在する = 親カテゴリが選択されている場合、
# ajaxでサーバーに子カテゴリの一覧を問い合わせる
$.ajax
url: childrenPath
dataType: "json"
success: (results) ->
# サーバーから受け取った子カテゴリの一覧でセレクトボックスを置き換える
replaceSelectOptions($selectChildren, results)
error: (XMLHttpRequest, textStatus, errorThrown) ->
# 何らかのエラーが発生した場合
console.error("Error occurred in replaceChildrenOptions")
console.log("XMLHttpRequest: #{XMLHttpRequest.status}")
console.log("textStatus: #{textStatus}")
console.log("errorThrown: #{errorThrown}")
else
# 親カテゴリが未選択だったので、子カテゴリの選択肢をクリアする
replaceSelectOptions($selectChildren, [])
最後に、replaceSelectOptions
は子カテゴリのセレクトボックスの中身を置き換えるためのメソッドです。
いったん空の選択肢を追加したあと、サーバーから受け取った値を順番に詰め込んでいます。
replaceSelectOptions = ($select, results) ->
$select.html $('<option>')
$.each results, ->
option = $('<option>').val(this.id).text(this.name)
$select.append(option)
コントローラー
こちらはAjaxのリクエストを受け取り、JSONを返すサーバーサイドの処理です。
コントローラーはこんな感じになっています。
class SubCategoriesController < ApplicationController
def index
category = Category.find(params[:category_id])
render json: category.sub_categories.select(:id, :name)
end
end
セキュリティ面を考慮してidとname以外は返却しないようにしています。
http://localhost:3000/categories/1/sub_categories のようなURLにアクセスすると子カテゴリの一覧がJSONとして返ってきます。
[
{
id: 1,
name: "和書"
},
{
id: 2,
name: "洋書"
},
{
id: 3,
name: "コミック"
},
{
id: 4,
name: "雑誌"
}
]
ちなみに親カテゴリを変更したときのログはこんなふうに出力されます。
Started GET "/categories/1/sub_categories" for ::1 at 2015-04-10 08:29:23 +0900
Processing by SubCategoriesController#index as JSON
Parameters: {"category_id"=>"1"}
Category Load (0.1ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = ? LIMIT 1 [["id", 1]]
SubCategory Load (0.6ms) SELECT "sub_categories"."id", "sub_categories"."name" FROM "sub_categories" WHERE "sub_categories"."category_id" = ? ORDER BY "sub_categories"."id" ASC [["category_id", 1]]
Completed 200 OK in 15ms (Views: 1.1ms | ActiveRecord: 0.7ms)
自分の作ったアプリがうまく動かないときはログを見て、正しくリクエストとレスポンスが処理されているか確認してください。
アプリケーション側の主要なコードはこんな感じです。
いまいちピンと来ていない人は冒頭で説明した「ざっくりとした処理の流れ」をもう一度見直して、処理とコードの対応関係を確認してみてください。
テストコード (RSpec)
最後にRSpecで書いたテストコードの一部を載せておきます。
require 'rails_helper'
feature 'Items' do
background do
{'本' => %w(和書 洋書), '家電・カメラ・AV機器' => %w(家電 カメラ)}.each do |category_name, sub_category_names|
category = Category.create(name: category_name)
sub_category_names.each do |sub_category_name|
category.sub_categories.create(name: sub_category_name)
end
end
end
scenario 'Manage items', js: true do
# 新規作成画面を開く
visit root_path
click_on 'New Item'
# セレクトボックスのデフォルト値を検証する
expect(page).to have_select 'Category', options: ['', '本', '家電・カメラ・AV機器']
expect(page).to have_select 'Sub category', options: ['']
# カテゴリを変更するとサブカテゴリの項目が変わることを検証する
select '本', from: 'Category'
expect(page).to have_select 'Sub category', options: ['', '和書', '洋書']
select '家電・カメラ・AV機器', from: 'Category'
expect(page).to have_select 'Sub category', options: ['', '家電', 'カメラ']
select '', from: 'Category'
expect(page).to have_select 'Sub category', options: ['']
# 以下省略
end
end
今回はUI側の動作確認がメインなので、フィーチャスペックを使います。
また、JavaScriptが必要不可欠なので、scenario 'Manage items', js: true do
の部分でjs: true
のオプションを付けました。
background
ではテスト用のデータを作っています。
scenario
以下がメインのテストです。
コメントで書いているとおり、カテゴリを変更するとサブカテゴリの項目が変わることを検証しています。
テストコードの全体はGitHub上のコードを見てください。
またRSpecやフィーチャスペックの読み方、書き方については以前書いたこちらの記事を参考にしてください。
まとめ
というわけで今回はセレクトボックスの中身がAjaxで動的に変わるサンプルアプリを紹介してみました。
「こういうの、やってみたいんだけどやり方がわからなかった」という人の参考になれば幸いです!
【PR】「Everyday Rails - RSpecによるRailsテスト入門」を読んでテストが書けるRailsプログラマになろう!
UIの処理を変更のたびに毎回手作業で確認するのは非常に面倒です。
しかし、テストを書いておけば何度でも自動的にUIが壊れていないことを検証できます。
今回のサンプルアプリを開発する際も、テストコードを書いていたおかげで積極的にリファクタリングすることができました。
「テストコードが大事なのはわかるけど、何をどうしたらいいか全然わからないんだよな~」という人は僕が翻訳した電子書籍、「Everyday Rails - RSpecによるRailsテスト入門」をぜひ読んでみてください。
この本は初心者向けにやさしくテストコードの書き方を説明しているので、「テストって全然わからないんだけど」という人でも大丈夫です!
対応している電子書籍のフォーマットは、PDF、EPUB、Kindleの3種類です。
興味のある方はこちらのページからご購入ください。よろしくお願いします!