ancestryによる多階層構造データを用いて、動的カテゴリーセレクトボックスを実現する~Ajax~

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

Image from Gyazo


概略


  1. 親カテゴリーが選択される

  2. その変化でイベントが発火する(category.js)

  3. 選択された情報を取得しAjax通信を開始する(category.js)

  4. 選択されたカテゴリーの子カテゴリーの配列をjsonで取得する(products_controller.rb)

  5. 子カテゴリー配列を元に、セレクトボックスを作成する(category.js)

  6. 子カテゴリーが選択される

  7. その変化でイベントが発火する(category.js)

  8. 選択された情報を取得しAjax通信を開始する(category.js)

  9. 選択された子カテゴリーの子(孫)カテゴリーの配列をjsonで取得する(products_controller.rb)

  10. 孫カテゴリー配列を元に、セレクトボックスを作成する(category.js)


コード


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


products_controller.rb

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



get_category_children.json.jbuilder

json.array! @category_children do |child|

json.id child.id
json.name child.name
end


get_category_grandchildren.json.jbuilder

json.array! @category_grandchildren do |grandchild|

json.id grandchild.id
json.name grandchild.name
end


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


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);
}
// 孫カテゴリーの表示作成
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


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


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


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"メソッドで検索をかける。

検索でヒットしたインスタンスを一つずつ取り出し、名前のみ配列に追加。


products_controller.rb

   # 親カテゴリーが選択された後に動くアクション

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

まずは、親カテゴリーが選択された時の挙動から。


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点あります。


  1. 変化を捉える方法

  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を取得

長いコードを読んでいただきありがとうございます。

少しでも、誰かの手助けになれば嬉しいです。