【Rails】セレクトボックスを活用した多階層構造データの表示について

# 経緯
とあるプログラミングスクール受講生です。
存知の方も多いでしょう「フリマクローンサイト」作成にあたり、出品カテゴリーをajaxで実装しましたので、自分の頭の整理を兼ねてまとめていきたいと思います。

なお、カテゴリーの設定は「ancestry」というGemを使用しております。
今回の記事につきましては、カテゴリー設定後を想定しております。

※説明が必要ない方はコードのみ追ってもらえれば実装できると思います。

# 完成イメージ
![category.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643366/524e374a-98f0-ebc6-fcc6-e0dfbdc2cad8.gif)

# コード
```haml:ビュー(items.new.html.haml)
.form-title
  =f.label "カテゴリ"
  .form-title__required
    %label 必須
.form-input-select
  = f.select :category, @category_parent_array, {}, {class: 'listing-select-wrapper__box--select', id: 'parent_category'}
.listing-product-detail__category
```
- 今回、重要なのは下３行のみです。他はご自由に。
- = f.select :category, @category_parent_array, {}, {class: 'listing-select-wrapper__box--select', id: 'parent_category'}
の **{}** は超重要ですので消さないでください。  
→　軽く触れておきます。{}の部分が２つありますね。**一つ目が「オプション」**の記載、**二つ目（今回記載がある方）が「HTMLオプション」**となります。  
「オプション」を設定しない場合は空欄でも{}の記載しておかないと、「HTMLオプション」が反映されません。

```ruby:コントローラー（items_controller.rb）
  def new
    @category_parent_array = ["---"]
    Category.where(ancestry: nil).each do |parent|
      @category_parent_array << parent.name
    end
  end

  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
```

デモを見てもらうと、カテゴリーが三段階で表示されているのが確認できると思います。
・ 一段目の表示がnew
・ 二段目の表示がget_category_children
・ 三段目の表示がget_category_grandchildren
となっております。

複雑な部分はnewのコードでしょうか。
@category_parent_array に　**"---"　しか入っていない配列**を代入していますね。
次の行のeach文で先ほど設定した@category_parent_arrayの**配列にカテゴリを１件ずつ代入**しています。
(ancestry: nil)につきましては、「ancestry」でカテゴリーを設定した後に保存データを確認してみてください。意味がわかると思います。

**二段落目、三段落目の記載はajaxでの処理**となりますので、アクションを分けています。
params[:parent_name]、params[:child_id]については、ajaxでJavaScriptから飛んでくる値です。
あとで設定しますので、覚えておきましょう。

```ruby:routes.rb
 resources :items do
    collection do
      get 'get_category_children', defaults: { format: 'json' }
      get 'get_category_grandchildren', defaults: { format: 'json' }
    end
  end
```
先ほどコントローラーで出てきた**２つのajax用アクション**を設定しています。
itemsにネストしております。
軽くcollectionについて簡単すると、routingに**idが付くのがmember**、**付かないのがcollection**です。
今回は、**「個（id）」を特定する必要がないので、collection**ですね。

コントローラーは基本的に**処理をビューに返す**のですが、
defaults: { format: 'json' }の設定をしておくと、**デフォルトでjsonファイルに処理を返す**ようになります。
(コントローラーでrespond_toを使用してjsonファイルに振り分ける必要が無くなります。)

```ruby:get_category_children.json.jbuilder
json.array! @category_children do |child|
  json.id child.id
  json.name child.name
end
```
```ruby:get_category_grandchildren.json.jbuilder
json.array! @category_grandchildren do |grandchild|
  json.id grandchild.id
  json.name grandchild.name
end
```
ファイルの場所、間違えないでくださいね！！ビューと同じ場所に格納します。

コントローラーで二段落目、三段落目のアクション処理を行うと、このjsonファイルに飛びます。
（routes.rbで先ほど設定しましたね。）
コントローラーで設定した変数をここでajax用データに変換している訳ですね。

ちなみに、**json.array!**は配列形式のデータをコントローラーから受け取る時に設定します。


ajax処理の流れは、
**ビュー（カテゴリー選択）→JavaScript（発火）→コントローラー（処理）→json.jbuilder（データ変換）→JavaScript（処理）→ビュー**
の繰り返し（と認識しています）。

さて、最後にJavaScriptのお出ましです。

```javascript:JS(items.js)
$(function)(){

  //子カテゴリー、孫カテゴリーのセレクトボックスの選択肢
  function appendOption(category){
    //value="${category.name}"については、ストロングパラメーターでの値の取り方によってcategory.idの場合もあると思います。
    var html = `<option value="${category.name}" datacategory="${category.id}">${category.name}</option>`;
    return html;
  }

  //子カテゴリーのビュー作成
  function appendChildrenBox(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>
                        </div>
                      </div>`;
    $('.listing-product-detail__category').append(childSelectHtml);
  }

　//孫カテゴリーのビュー作成
  function appendGrandchildrenBox(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>
                              </div>
                            </div>`;
    $('.listing-product-detail__category').append(grandchildSelectHtml);
  }

  //親カテゴリーが選択された時の処理（子カテゴリーの表示）
  $("#parent_category").on('change', function(){
    //選択された親カテゴリーの値を取得
    var parentCategory = document.getElementById('parent_category').value;
    //選択された親カテゴリーが"---"（初期設定）のままだとfalse、変わっているとtrue
    if (parentCategory != "---"){
      $.ajax({
        url: 'get_category_children',
        type: 'GET',
        //コントローラーに飛ばす値です。
        data: { parent_name: parentCategory },
        dataType: 'json'
      })
      .done(function(children){
        //まず、既に表示されている子、孫カテゴリーを削除
        $('#children_wrapper').remove();
        $('#grandchildren_wrapper').remove();
        //insertHTMLという変数にカテゴリーのセレクトボックスの選択肢を入れる。（一番最初の段落で設けた変数）
        var insertHTML = '';
        children.forEach(function(child){
          insertHTML += appendOption(child);
        });
        //2段落目で設定した子カテゴリーのビューの呼び出し
        appendChildrenBox(insertHTML);
      })
      .fail(function(){
        alert('カテゴリー取得に失敗しました');
      })
    }else{
      $('#children_wrapper').remove();
      $('#grandchildren_wrapper').remove();
    }
  });
  
  //子カテゴリーが選択された時の処理（孫カテゴリーの表示）
  $('.listing-product-detail__category').on('change', '#child_category', function(){
    var childId = $('#child_category option:selected').data('category');
    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);
          });
          appendGrandchildrenBox(insertHTML);
        }
      })
      .fail(function(){
        alert('カテゴリー取得に失敗しました');
      })
    }else{
      $('#grandchildren_wrapper').remove();
      $('#size_wrapper').remove();
      $('#brand_wrapper').remove();
    }
  });
});
```
はい。言いたいことはわかります。私、エスパーですから。
そんな皆さんに私から**頑張れ**という便利な言葉を送ります。

ここにつきましてはあまりに長いので、**コードにコメントアウトで処理の説明をしています**。
イマイチわかりにくかったら各自調べてもらえればと思います。

だらだらと長い記事を最後まで読んでいただき、ありがとうございました。
○○キャンプの受講生はLGTM必須で。

# 参考とさせていただいたサイト

[https://qiita.com/ATORA1992/items/bd824f5097caeee09678](https://qiita.com/ATORA1992/items/bd824f5097caeee09678) 
@ATORA1992様（とてもわかりやすい記事でした。ありがとうございました！！）