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](https://i.gyazo.com/d5b494cd4eedaa986ffc22125d57b7ac.gif)](https://gyazo.com/d5b494cd4eedaa986ffc22125d57b7ac)

#概略
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で動かすアクション用のルートを設定する。

```.rb
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の２ファイルは、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();
    }
  });
});
```
以下のコードで、親カテゴリーボックスの変化を検知して、イベントが発火します。

```.js
$('#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*にしました。
親カテゴリーが初期値になった場合は、子カテゴリー以下のビュー表示を削除します。

```.js
　　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*"で作成。

```.js
 // カテゴリーセレクトボックスのオプションを作成
  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の中身

（１）子カテゴリーが選択された時にイベントを発火するには、JSで追加したhtml要素を認識する必要があります。
その場合、親カテゴリーと同じように書いても認識してくれません。
親カテゴリーと子カテゴリーのセレクトボックスを含む大きな枠・変化しないもの（今回は"*$('.listing-product-detail__category')*"）に対して"*.on*"でイベントを拾い、発動するべき対象を指定する必要があります。
それが、以下の部分です。

```.js
$('.listing-product-detail__category').on('change', '#child_category', function()
```
（２）Ajaxでコントローラーに渡す値を、カテゴリーの名前そのものではなく、レコードのidにしています。
というのも、名前で検索をかけると、子カテゴリーでは同じ名前が複数あり、意図したものを取得してくれないからです。
レコードのidを取得するために、JSで追加するoptionタグにdata属性を持たせ、data属性を用いて選択されたカテゴリーのレコードidを取得しています

```.js
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*"の部分）

```.js
var childId = $('#child_category option:selected').data('category'); //選択された子カテゴリーのidを取得
``` 
長いコードを読んでいただきありがとうございます。
少しでも、誰かの手助けになれば嬉しいです。