80
105

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-07-06

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

.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の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();
    }
  });
});

以下のコードで、親カテゴリーボックスの変化を検知して、イベントが発火します。

.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の中身

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

.js
$('.listing-product-detail__category').on('change', '#child_category', function()

(2)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を取得

長いコードを読んでいただきありがとうございます。
少しでも、誰かの手助けになれば嬉しいです。

80
105
6

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
80
105

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?