未経験からのフルスタックエンジニア

スキルをつけよう!未経験からフリーランスエンジニアへの成長記録

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

近況一言メモ

夏本番に向かって、フリーランスは体が資本ということもあり、 体力をつける・体を作っていかないといけない。
しみじみ、実感するここ最近。

概要

ancestryで作成したカテゴリーデータを用いて、選択肢を動的に変化させる機能を実装しました。
データの作成方法は以下のリンクでご確認ください。

『ancestryによる多階層構造の実現』 atora1992.hatenablog.com 『多階層構造のカテゴリーを実現する、seedを作ってみた』 atora1992.hatenablog.com

作成したテーブルの例

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

実装した機能

実際に作った画面です
サイズも出てきていますが、それはまた別の機会に

atora1992.hatenablog.com

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)

コード

resources :products, only: [:index, :show, :new, :edit, :destroy] do
    #Ajaxで動くアクションのルートを作成
    collection do
      get 'get_category_children', defaults: { format: 'json' }
      get 'get<div class="code-title" data-title="Gemfile">_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.rbjsonに限定したので、通常のアクションと同じ書き方で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点あります。

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

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