ancestryで作成したカテゴリーデータを用いて、選択肢を動的に変化させる機能を実装しました。
データの作成方法は以下のリンクでご確認ください。
『ancestryによる多階層構造の実現』
https://qiita.com/ATORA1992/items/03eb78e212080072ab9f
『多階層構造のカテゴリーを実現する、seedを作ってみた』
https://qiita.com/ATORA1992/items/617088f885117532454e
作成したテーブルの例
id | name | ancestry |
---|---|---|
1 | メンズ | nil |
2 | トップス | 1 |
3 | すべて | 1/2 |
4 | Tシャツ/カットソー(半袖/袖なし) | 1/2 |
5 | Tシャツ/カットソー(七分/長袖) | 1/2 |
###開発環境
Ruby 2.5.1
Rails 5.2.3
jQuery 1.12.4
#実装した機能
実際に作った画面です
サイズも出てきていますが、それはまた別の機会に
https://qiita.com/ATORA1992/items/39f748cead7f9274f4a6
#概略
- 親カテゴリーが選択される
- その変化でイベントが発火する(category.js)
- 選択された情報を取得しAjax通信を開始する(category.js)
- 選択されたカテゴリーの子カテゴリーの配列をjsonで取得する(products_controller.rb)
- 子カテゴリー配列を元に、セレクトボックスを作成する(category.js)
- 子カテゴリーが選択される
- その変化でイベントが発火する(category.js)
- 選択された情報を取得しAjax通信を開始する(category.js)
- 選択された子カテゴリーの子(孫)カテゴリーの配列をjsonで取得する(products_controller.rb)
- 孫カテゴリー配列を元に、セレクトボックスを作成する(category.js)
#コード
resources :products, only: [:index, :show, :new, :edit, :destroy] do
#Ajaxで動くアクションのルートを作成
collection do
get 'get_category_children', defaults: { format: 'json' }
get 'get_category_grandchildren', defaults: { format: 'json' }
end
end
class ProductsController < ApplicationController
def new
#セレクトボックスの初期値設定
@category_parent_array = ["---"]
#データベースから、親カテゴリーのみ抽出し、配列化
Category.where(ancestry: nil).each do |parent|
@category_parent_array << parent.name
end
end
# 以下全て、formatはjsonのみ
# 親カテゴリーが選択された後に動くアクション
def get_category_children
#選択された親カテゴリーに紐付く子カテゴリーの配列を取得
@category_children = Category.find_by(name: "#{params[:parent_name]}", ancestry: nil).children
end
# 子カテゴリーが選択された後に動くアクション
def get_category_grandchildren
#選択された子カテゴリーに紐付く孫カテゴリーの配列を取得
@category_grandchildren = Category.find("#{params[:child_id]}").children
end
end
json.array! @category_children do |child|
json.id child.id
json.name child.name
end
json.array! @category_grandchildren do |grandchild|
json.id grandchild.id
json.name grandchild.name
end
.listing-form-box
.listing-product-detail__category
= f.label 'カテゴリー', class: 'listing-default__label'
%span.listing-default--require 必須
.listing-select-wrapper
.listing-select-wrapper__box
= f.select :category, @category_parent_array, {}, {class: 'listing-select-wrapper__box--select', id: 'parent_category'}
%i.fas.fa-chevron-down.listing-select-wrapper__box--arrow-down
$(function(){
// カテゴリーセレクトボックスのオプションを作成
function appendOption(category){
var html = `<option value="${category.name}" data-category="${category.id}">${category.name}</option>`;
return html;
}
// 子カテゴリーの表示作成
function appendChidrenBox(insertHTML){
var childSelectHtml = '';
childSelectHtml = `<div class='listing-select-wrapper__added' id= 'children_wrapper'>
<div class='listing-select-wrapper__box'>
<select class="listing-select-wrapper__box--select" id="child_category" name="category_id">
<option value="---" data-category="---">---</option>
${insertHTML}
<select>
<i class='fas fa-chevron-down listing-select-wrapper__box--arrow-down'></i>
</div>
</div>`;
$('.listing-product-detail__category').append(childSelectHtml);
}
// 孫カテゴリーの表示作成
function appendGrandchidrenBox(insertHTML){
var grandchildSelectHtml = '';
grandchildSelectHtml = `<div class='listing-select-wrapper__added' id= 'grandchildren_wrapper'>
<div class='listing-select-wrapper__box'>
<select class="listing-select-wrapper__box--select" id="grandchild_category" name="category_id">
<option value="---" data-category="---">---</option>
${insertHTML}
</select>
<i class='fas fa-chevron-down listing-select-wrapper__box--arrow-down'></i>
</div>
</div>`;
$('.listing-product-detail__category').append(grandchildSelectHtml);
}
// 親カテゴリー選択後のイベント
$('#parent_category').on('change', function(){
var parentCategory = document.getElementById('parent_category').value; //選択された親カテゴリーの名前を取得
if (parentCategory != "---"){ //親カテゴリーが初期値でないことを確認
$.ajax({
url: 'get_category_children',
type: 'GET',
data: { parent_name: parentCategory },
dataType: 'json'
})
.done(function(children){
$('#children_wrapper').remove(); //親が変更された時、子以下を削除するする
$('#grandchildren_wrapper').remove();
$('#size_wrapper').remove();
$('#brand_wrapper').remove();
var insertHTML = '';
children.forEach(function(child){
insertHTML += appendOption(child);
});
appendChidrenBox(insertHTML);
})
.fail(function(){
alert('カテゴリー取得に失敗しました');
})
}else{
$('#children_wrapper').remove(); //親カテゴリーが初期値になった時、子以下を削除するする
$('#grandchildren_wrapper').remove();
$('#size_wrapper').remove();
$('#brand_wrapper').remove();
}
});
// 子カテゴリー選択後のイベント
$('.listing-product-detail__category').on('change', '#child_category', function(){
var childId = $('#child_category option:selected').data('category'); //選択された子カテゴリーのidを取得
if (childId != "---"){ //子カテゴリーが初期値でないことを確認
$.ajax({
url: 'get_category_grandchildren',
type: 'GET',
data: { child_id: childId },
dataType: 'json'
})
.done(function(grandchildren){
if (grandchildren.length != 0) {
$('#grandchildren_wrapper').remove(); //子が変更された時、孫以下を削除するする
$('#size_wrapper').remove();
$('#brand_wrapper').remove();
var insertHTML = '';
grandchildren.forEach(function(grandchild){
insertHTML += appendOption(grandchild);
});
appendGrandchidrenBox(insertHTML);
}
})
.fail(function(){
alert('カテゴリー取得に失敗しました');
})
}else{
$('#grandchildren_wrapper').remove(); //子カテゴリーが初期値になった時、孫以下を削除する
$('#size_wrapper').remove();
$('#brand_wrapper').remove();
}
});
});
##細かく見ていこう
###routes.rb
resources :products, only: [:index, :show, :new, :edit, :destroy] do
#Ajaxで動くアクションのルートを作成
collection do
get 'get_category_children', defaults: { format: 'json' }
get 'get_category_grandchildren', defaults: { format: 'json' }
end
end
Ajaxで動かすアクション用のルートを設定する。
defaults: { format: 'json' }
で、アクションのリスポンスをjsonに限定しています。
###new.html.haml
.listing-form-box
.listing-product-detail__category
= f.label 'カテゴリー', class: 'listing-default__label'
%span.listing-default--require 必須
.listing-select-wrapper
.listing-select-wrapper__box
//親カテゴリーのセレクトボックスの生成
= f.select :category, @category_parent_array, {}, {class: 'listing-select-wrapper__box--select', id: 'parent_category'}
%i.fas.fa-chevron-down.listing-select-wrapper__box--arrow-down
ビューでは、初期値の親カテゴリーボックスのみ記載する。
セレクトボックスの選択肢は、products_controller.rbで作成したインスタンス変数を利用する。
※jbuilderの2ファイルは、new.html.hamlと同じフォルダーに入れてください。
##products_controller.rb
def new
#セレクトボックスの初期値設定
@category_parent_array = ["---"]
#データベースから、親カテゴリーのみ抽出し、配列化
Category.where(ancestry: nil).each do |parent|
@category_parent_array << parent.name
end
end
アクションnewで、親カテゴリーの選択肢配列を作成する。
親カテゴリーのパス(カラム名:ancestry)の値は*"nil*"なので、".where"メソッドで検索をかける。
検索でヒットしたインスタンスを一つずつ取り出し、名前のみ配列に追加。
# 親カテゴリーが選択された後に動くアクション
def get_category_children
#選択された親カテゴリーに紐付く子カテゴリーの配列を取得
@category_children = Category.find_by(name: "#{params[:parent_name]}", ancestry: nil).children
end
# 子カテゴリーが選択された後に動くアクション
def get_category_grandchildren
#選択された子カテゴリーに紐付く孫カテゴリーの配列を取得
@category_grandchildren = Category.find("#{params[:child_id]}").children
end
routes.rbでjsonに限定したので、通常のアクションと同じ書き方でOK
jbuilderに渡す変数を作成する。
ancestryを導入しているので、".children"メソッドで、選択されたものの子カテゴリーの配列を取得する。
###category.js
まずは、親カテゴリーが選択された時の挙動から。
$(function(){
// カテゴリーセレクトボックスのオプションを作成
function appendOption(category){
var html = `<option value="${category.name}" data-category="${category.id}">${category.name}</option>`;
return html;
}
// 子カテゴリーの表示作成
function appendChidrenBox(insertHTML){
var childSelectHtml = '';
childSelectHtml = `<div class='listing-select-wrapper__added' id= 'children_wrapper'>
<div class='listing-select-wrapper__box'>
<select class="listing-select-wrapper__box--select" id="child_category" name="category_id">
<option value="---" data-category="---">---</option>
${insertHTML}
<select>
<i class='fas fa-chevron-down listing-select-wrapper__box--arrow-down'></i>
</div>
</div>`;
$('.listing-product-detail__category').append(childSelectHtml);
}
// 親カテゴリー選択後のイベント
$('#parent_category').on('change', function(){
var parentCategory = document.getElementById('parent_category').value; //選択された親カテゴリーの名前を取得
if (parentCategory != "---"){ //親カテゴリーが初期値でないことを確認
$.ajax({
url: 'get_category_children',
type: 'GET',
data: { parent_name: parentCategory },
dataType: 'json'
})
.done(function(children){
$('#children_wrapper').remove(); //親が変更された時、子以下を削除するする
$('#grandchildren_wrapper').remove();
$('#size_wrapper').remove();
$('#brand_wrapper').remove();
var insertHTML = '';
children.forEach(function(child){
insertHTML += appendOption(child);
});
appendChidrenBox(insertHTML);
})
.fail(function(){
alert('カテゴリー取得に失敗しました');
})
}else{
$('#children_wrapper').remove(); //親カテゴリーが初期値になった時、子以下を削除するする
$('#grandchildren_wrapper').remove();
$('#size_wrapper').remove();
$('#brand_wrapper').remove();
}
});
});
以下のコードで、親カテゴリーボックスの変化を検知して、イベントが発火します。
$('#parent_category').on('change', function(){
var parentCategory = document.getElementById('parent_category').value; //選択された親カテゴリーの名前を取得
"document.getElementById('parent_category').value"
で、セレクトボックスで選択されたvalueを取得します。
e.g. トップのgifでいうと、"parentCategory = "レディース""となります
親カテゴリーで選択されたvalueが初期値でないことを確認して、Ajax通信を開始します。
products_controller.rbで受け取るparamsのキーはparent_nameにしました。
親カテゴリーが初期値になった場合は、子カテゴリー以下のビュー表示を削除します。
if (parentCategory != "---"){ //親カテゴリーが初期値でないことを確認
$.ajax({
url: 'get_category_children',
type: 'GET',
data: { parent_name: parentCategory },
dataType: 'json'
})
~省略~
}else{
$('#children_wrapper').remove(); //親カテゴリーが初期値になった時、子以下を削除するする
$('#grandchildren_wrapper').remove();
$('#size_wrapper').remove();
$('#brand_wrapper').remove();
}
Ajax通信後、jbuilderで成形したjsonを受け取る。(childrenとして命名)
Ajaxに成功した場合にも、子カテゴリー以下の表示をリセットするために、".remove()"を行う。
リセットしたのち、"appendOption"でセレクトボックスの選択肢(optionタグ)をchildrenを元に作成。
作成した選択肢を含めたセレクトボックスを"appendChildrenBox"で作成。
// カテゴリーセレクトボックスのオプションを作成
function appendOption(category){
var html = `<option value="${category.name}" data-category="${category.id}">${category.name}</option>`;
return html;
}
// 子カテゴリーの表示作成
function appendChidrenBox(insertHTML){
var childSelectHtml = '';
childSelectHtml = `<div class='listing-select-wrapper__added' id= 'children_wrapper'>
<div class='listing-select-wrapper__box'>
<select class="listing-select-wrapper__box--select" id="child_category" name="category_id">
<option value="---" data-category="---">---</option>
${insertHTML}
<select>
<i class='fas fa-chevron-down listing-select-wrapper__box--arrow-down'></i>
</div>
</div>`;
$('.listing-product-detail__category').append(childSelectHtml);
}
~省略~
.done(function(children){
$('#children_wrapper').remove(); //親が変更された時、子以下を削除するする
$('#grandchildren_wrapper').remove();
$('#size_wrapper').remove();
$('#brand_wrapper').remove();
var insertHTML = '';
children.forEach(function(child){
insertHTML += appendOption(child);
});
appendChidrenBox(insertHTML);
})
.fail(function(){
alert('カテゴリー取得に失敗しました');
})
孫カテゴリー作成も同様ですが、重要な変更点が2点あります。
- 変化を捉える方法
- Ajax通信でコントローラーに渡すparamsの中身
(1)子カテゴリーが選択された時にイベントを発火するには、JSで追加したhtml要素を認識する必要があります。
その場合、親カテゴリーと同じように書いても認識してくれません。
親カテゴリーと子カテゴリーのセレクトボックスを含む大きな枠・変化しないもの(今回は"$('.listing-product-detail__category')")に対して".on"でイベントを拾い、発動するべき対象を指定する必要があります。
それが、以下の部分です。
$('.listing-product-detail__category').on('change', '#child_category', function()
(2)Ajaxでコントローラーに渡す値を、カテゴリーの名前そのものではなく、レコードのidにしています。
というのも、名前で検索をかけると、子カテゴリーでは同じ名前が複数あり、意図したものを取得してくれないからです。
レコードのidを取得するために、JSで追加するoptionタグにdata属性を持たせ、data属性を用いて選択されたカテゴリーのレコードidを取得しています
function appendOption(category){
var html = `<option value="${category.name}" data-category="${category.id}">${category.name}</option>`;
return html;
}
~省略~
var childId = $('#child_category option:selected').data('category'); //選択された子カテゴリーのidを取得
#まとめ
コードが長くなりましたが、概略が理解できれば実装方法は色々あると思います。
自分は、孫カテゴリー作成の注意点(2)を最初せずに、名前で検索をかけていたので、取得されるカテゴリー配列が意図したものにならず、少しつまりました。
特に、セレクトボックスで"選択された"という情報をどう判断したら良いかがわからずつまりました。
(下記コードの"option:selected"の部分)
var childId = $('#child_category option:selected').data('category'); //選択された子カテゴリーのidを取得
長いコードを読んでいただきありがとうございます。
少しでも、誰かの手助けになれば嬉しいです。