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

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

選択されたカテゴリーに応じて、動的に変化するサイズセレクトボックスを作成してみた

この記事は以下の記事の続きとなっております。
『ancestryによる多階層構造データを用いて、動的カテゴリーセレクトボックスを実現する~Ajax~』 atora1992.hatenablog.com

やりたいこと

カテゴリーに応じたサイズ選択欄を生成したい。 Image from Gyazo Image from Gyazo

概略

準備

  1. products_sizesテーブルを作成する
  2. ancestryを用いて、サイズの種類(親に当たる)ごとにデータを作成する
  3. カテゴリーのidとサイズ(親)のidを結びつける中間テーブル(category_sizesテーブル)を作成する

作成するテーブルは以下の通りです。
products_sizesテーブル

id size ancestry
1 洋服のサイズ nil
2 XXS以下 1
3 XS(SS) 1
4 S 1
: : :
12 メンズ靴のサイズ nil
13 23.5 cm以下 12
14 24 cm 12
15 24.5cm 12
16 25 cm 12
: : :

category_sizesテーブル

id category_id products_size_id
1 2 1
2 22 1
3 270 12
: : :

ancestryの導入方法と、データの作成方法は以下の記事を参考にしてください。
カテゴリーは以下の記事で作成した想定です。

atora1992.hatenablog.com

atora1992.hatenablog.com

products_sizesテーブルでは、親(ancestry = nil)にサイズの種類、それと紐付くようにサイズの詳細をデータで入れておきます。
category_sizesテーブルでは、カテゴリーレコードのidとサイズの親のidを紐づけています。カテゴリーidは親であろうと、子であろうと、孫であろうと問題ありません。サイズと紐づけたいものを入力してください。

実装

  1. 孫カテゴリーが選択される
  2. その変化でイベントが発火する(category.js)
  3. 選択された情報を取得しAjax通信を開始する(category.js)
  4. 選択されたカテゴリーに紐付くサイズのインスタンスがあれば、中間テーブルを用いてその配列を取得し、json形式に加工する(products_controller.rb)
  5. サイズ配列を元に、セレクトボックスを作成する(category.js)

コード

 resources :products, only: [:index, :show, :new, :create, :edit, :update, :destroy] do
    collection do
      ~省略~
      get 'get_size', defaults: { format: 'json' }
    end
  end
class CategorySize < ApplicationRecord
  belongs_to :category
  belongs_to :products_size
end
class Category < ApplicationRecord
   has_many :products
   has_many :category_sizes
   has_many :products_sizes, through: :category_sizes
   has_ancestry
end
class ProductsSize < ApplicationRecord
  has_many :products
  has_many :category_sizes
  has_many :categories, through: :category_sizes
  has_ancestry
end
$(function(){
  // サイズセレクトボックスのオプションを作成
  function appendSizeOption(size){
    var html = `<option value="${size.size}">${size.size}</option>`;
    return html;
  }
  // サイズ・ブランド入力欄の表示作成
  function appendSizeBox(insertHTML){
    var sizeSelectHtml = '';
    sizeSelectHtml = `<div class="listing-product-detail__size" id= 'size_wrapper'>
                        <label class="listing-default__label" for="サイズ">サイズ</label>
                        <span class='listing-default--require'>必須</span>
                        <div class='listing-select-wrapper__added--size'>
                          <div class='listing-select-wrapper__box'>
                            <select class="listing-select-wrapper__box--select" id="size" name="size_id>
                              <option value="---">---</option>
                              ${insertHTML}
                            <select>
                            <i class='fas fa-chevron-down listing-select-wrapper__box--arrow-down'></i>
                          </div>
                        </div>
                      </div>`;
    $('.listing-product-detail__category').append(sizeSelectHtml);
  }
  // 孫カテゴリー選択後のイベント
  $('.listing-product-detail__category').on('change', '#grandchild_category', function(){
    var grandchildId = $('#grandchild_category option:selected').data('category'); //選択された孫カテゴリーのidを取得
    if (grandchildId != "---"){ //孫カテゴリーが初期値でないことを確認
      $.ajax({
        url: 'get_size',
        type: 'GET',
        data: { grandchild_id: grandchildId },
        dataType: 'json'
      })
      .done(function(sizes){
        $('#size_wrapper').remove(); //孫が変更された時、サイズ欄以下を削除する
        $('#brand_wrapper').remove();
        if (sizes.length != 0) {
        var insertHTML = '';
          sizes.forEach(function(size){
            insertHTML += appendSizeOption(size);
          });
          appendSizeBox(insertHTML);
        }
      })
      .fail(function(){
        alert('サイズ取得に失敗しました');
      })
    }else{
      $('#size_wrapper').remove(); //孫カテゴリーが初期値になった時、サイズ欄以下を削除する
      $('#brand_wrapper').remove();
    }
  });
});
class ProductsController < ApplicationController
   # 孫カテゴリーが選択された後に動くアクション
   def get_size
      selected_grandchild = Category.find("#{params[:grandchild_id]}") #孫カテゴリーを取得
      if related_size_parent = selected_grandchild.products_sizes[0] #孫カテゴリーと紐付くサイズ(親)があれば取得
         @sizes = related_size_parent.children #紐づいたサイズ(親)の子供の配列を取得
      else
         selected_child = Category.find("#{params[:grandchild_id]}").parent #孫カテゴリーの親を取得
         if related_size_parent = selected_child.products_sizes[0] #孫カテゴリーの親と紐付くサイズ(親)があれば取得
            @sizes = related_size_parent.children #紐づいたサイズ(親)の子供の配列を取得
         end
      end
   end
end
json.array! @sizes do |size|
  json.id size.id
  json.size size.size
end

細かく見ていこう

routes.rb

 resources :products, only: [:index, :show, :new, :create, :edit, :update, :destroy] do
    collection do
      ~省略~
      get 'get_size', defaults: { format: 'json' }
    end
  end

Ajaxで動かすアクション用のルートを設定する。

defaults: { format: 'json' }

で、アクションのリスポンスをjsonに限定しています。

model

class CategorySize < ApplicationRecord
  belongs_to :category
  belongs_to :products_size
end
class Category < ApplicationRecord
   has_many :products
   has_many :category_sizes
   has_many :products_sizes, through: :category_sizes
   has_ancestry
end
class ProductsSize < ApplicationRecord
  has_many :products
  has_many :category_sizes
  has_many :categories, through: :category_sizes
  has_ancestry
end

各モデルでアソシエーションを記述します。
中間テーブルのアソシエーションの記述で、throughのみ書いて、has_many :category_sizesを書くのを忘れやすいので注意してください。

category.js

基本的には、選択された孫カテゴリーのidを取得して、コントローラのアクションget_sizeにidを送り、返ってきたjsonを用いてセレクトボックスを作成するだけです。
ただし、サイズ欄自体が不必要なカテゴリーもあったので、下記の部分で場合わけをし、コントローラからのjsonが空の場合は、特に何もしない仕様にしています。

if (sizes.length != 0)

また、孫カテゴリーセレクトボックスのHTMLは以下のように実装しています。

<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="prefecture">
         <option value="---" data-category="---">---</option>
         <option value="カテゴリーの名前" data-category="カテゴリーid">カテゴリーの名前</option>
      </select>
      <i class='fas fa-chevron-down listing-select-wrapper__box--arrow-down'></i>
   </div>
</div>`

atora1992.hatenablog.com

を参照してください。

products_controller.rb

今回一番重要なのは、コントローラでカテゴリーに紐付くサイズレコードの配列を取得する部分です。
自身の実装では、孫カテゴリーと結びつくサイズもあれば、子カテゴリーと紐付くサイズもあったので、この両者で場合わけしています。

class ProductsController < ApplicationController
   # 孫カテゴリーが選択された後に動くアクション
   def get_size
      selected_grandchild = Category.find("#{params[:grandchild_id]}") #JSから送られてきた、孫カテゴリーのidを元に、選択された孫カテゴリーのレコードを取得
      if related_size_parent = selected_grandchild.products_sizes[0] #孫カテゴリーと紐付くサイズ(親)があれば取得
         @sizes = related_size_parent.children #紐づいたサイズ(親)の子供の配列を取得
      else
         selected_child = Category.find("#{params[:grandchild_id]}").parent #選択された孫カテゴリーの親(子カテゴリー)のレコードを取得
         if related_size_parent = selected_child.products_sizes[0] #子カテゴリーと紐付くサイズ(親)があれば取得
            @sizes = related_size_parent.children #紐づいたサイズ(親)の子供の配列を取得
         end
      end
   end
end

まずは、選択された孫カテゴリーのレコードを取得します。

selected_grandchild = Category.find("#{params[:grandchild_id]}")

ここから、第一段階の場合わけとして、孫カテゴリーと紐付くサイズがあるかどうかを判断します。
ある場合は、中間テーブルを介してサイズの親のレコードを取得し、ancestryのメソッド.childrenでサイズの詳細の配列を取得します。

if related_size_parent = selected_grandchild.products_sizes[0] #孫カテゴリーと紐付くサイズ(親)があれば取得
   @sizes = related_size_parent.children #紐づいたサイズ(親)の子供の配列を取得
else

孫カテゴリーと紐付くサイズがない場合には、第二段階の場合わけに入ります。
今度は、選択された子カテゴリーと紐付くサイズがあるかどうかを判断します。
子カテゴリーのレコードは、孫カテゴリーのレコードに対してancestryのメソッド.parentで取得します。

else
    selected_child = Category.find("#{params[:grandchild_id]}").parent #選択された孫カテゴリーの親(子カテゴリー)のレコードを取得
    if related_size_parent = selected_child.products_sizes[0] #子カテゴリーと紐付くサイズ(親)があれば取得
       @sizes = related_size_parent.children #紐づいたサイズ(親)の子供の配列を取得
    end
end

まとめ

肝は、中間テーブルでカテゴリーとサイズを紐づけておくことです。
ancestryを導入しておけば、スムーズに関連するデータを取り出すことができます。
今回は、孫カテゴリーと子カテゴリーのみをサイズと紐づけましたが、親カテゴリーに対して行っても、コントローラの記述(場合わけ)を増やすだけで対応できるかと思います。