LoginSignup
19
36

More than 3 years have passed since last update.

【Rails】多階層カテゴリーの作成(Gem ancestry)

Last updated at Posted at 2020-04-19

メルカリのクローンアプリ作成に際して、商品カテゴリーテーブル、商品出品ページ、出品商品の編集ページを実装しました。
初心者にも、すごく便利に感じたancestryに関する備忘録です。

最終ゴール

商品出品ページAlt text

実装したい動作

  • 親カテゴリーの表示(haml)
  • 親カテゴリーが選択されたら、紐づく子カテゴリーを表示(Ajax)
  • 子カテゴリーが選択されたら、紐づく孫カテゴリーを表示(Ajax)
  • 孫カテゴリーが選択されたら、サイズセレクトボックスを表示(jQuery)

0. 準備

  • gemの導入 → bundle install
Gemfile
gem 'ancestry'
  • モデル
item.rb
class Item < ApplicationRecord
  belongs_to :category
end
category.rb
class Category < ApplicationRecord
  has_many :items
  has_ancestry  #この記述を追加することで階層化できる
end
  • 階層化したいデータを db/seeds.rbに書き、rails db:seedすると、categoriesテーブルにデータが入る!!
  • seeds.rbとは、何者か? 簡単に言うと、初期データのこと。
    例えば、DBをリセットした場合、データも消えてしまう。そんな時、seeds.rbに初期データを残しておくと、rails db:seedで復元できる。つまり、データの変更や削除は基本行わないけど、DBで管理したいって時が使い時!と理解しました。
  • 当初、ancestryカラムにnull制約をかけていたため、以下のエラーが発生しました。null制約を外してから、再度rails db:seedすると、テーブルに無事データが入りました。
エラー
Mysql2::Error: Field 'ancestry' doesn't have a default value
seeds.rb
#レディース
lady = Category.create(name: "レディース")
lady_1 = lady.children.create(name: "トップス")
lady_1.children.create([{name: "Tシャツ/カットソー(半袖/袖なし)"},{name: "Tシャツ/カットソー(七分/長袖)"},{name: "シャツ/ブラウス(半袖/袖なし)"},{name: "シャツ/ブラウス(七分/長袖)"},{name: "ポロシャツ"},{name: "キャミソール"},{name: "タンクトップ"},{name: "ホルターネック"},{name: "ニット/セーター"},{name: "チュニック"},{name: "カーディガン/ボレロ"},{name: "アンサンブル"},{name: "ベスト/ジレ"},{name: "パーカー"},{name: "トレーナー/スウェット"},{name: "ベアトップ/チューブトップ"},{name: "ジャージ"},{name: "その他"}])
lady_2 = lady.children.create(name: "ジャケット/アウター")
lady_2.children.create([{name: "テーラードジャケット"},{name: "ノーカラージャケット"},{name: "Gジャン/デニムジャケット"},{name: "レザージャケット"},{name: "ダウンジャケット"},{name: "ライダースジャケット"},{name: "ミリタリージャケット"},{name: "ダウンベスト"},{name: "ジャンパー/ブルゾン"},{name: "ポンチョ"},{name: "ロングコート"},{name: "トレンチコート"},{name: "ダッフルコート"},{name: "ピーコート"},{name: "チェスターコート"},{name: "モッズコート"},{name: "スタジャン"},{name: "毛皮/ファーコート"},{name: "スプリングコート"},{name: "スカジャン"},{name: "その他"}])
        :

作成されたcategoriesテーブル
親カテゴリーのancestryは、NULL
子カテゴリーのancestryには、親のidが入り、紐づいている
孫カテゴリーのancestryには、親と子のidが入り、紐づいている( 表記: 親のid/子のid

id name ancestry
1 レディース NULL
3 トップス 1
2 Tシャツ/カットソー(半袖/袖なし) 1/2
: : :
21 ジャケット/アウター 1
22 テーラードジャケット 1/21
: : :

1. 親カテゴリーの作成(ビュー)

  • 親カテゴリーのみhamlに記載。子カテゴリー、孫カテゴリーは設置場所のみ確保。
  • 親カテゴリーが選択されたタイミングで、JSで、親カテゴリーに紐づく子カテゴリーを表示。孫カテゴリーも同様の流れ。
item.html.haml
      :
.item_input__body
      :
  .item_input__body__category#category_box
    .pulldown#partent_box
      = f.select :category_id, options_for_select( @category_parent_array.map{|c| [c[:name], c[:id]]}),{include_blank: "選択してください"}, { class: "parent_category_box", id: "parent_category"}
      = icon('fa', 'chevron-down')

    .pulldown.item_input__body__category__children#children_box
      -#親カテゴリー選択によって子カテゴリー表示
    .pulldown.item_input__body__category__grandchildren#grandchildren_box
      -#子カテゴリー選択によって孫カテゴリー表示
      :
  #size_box  
      :   
    .pulldown#size_selectbox
      =f.collection_select :size_id, Size.all, :id, :name, {include_blank: "選択してください"}, {class: "size_box"}
      = icon('fa', 'chevron-down')
      :

2.親カテゴリー ⇄ 子カテゴリー ⇄ 孫カテゴリーの関係を定義

(コントローラー)
親カテゴリー: ancestryがNULL のレコードを取得し、@category_parent_arrayに格納(複数のビューで使い回したいので、インスタンス変数で定義)。
子カテゴリーと孫カテゴリーは、親カテゴリーから、Ajax通信で紐づくデータを取得。
子カテゴリー:親カテゴリーのnameから、紐づく子カテゴリー一覧を取得し、@category_childrenに格納。
孫カテゴリー:子カテゴリーのidから、紐づく孫カテゴリー一覧を取得し、@category_grandchildrenに格納。

items_controller.rb
class ItemsController < ApplicationController
  before_action :set_category, only: [:new, :edit, :create, :update, :destroy]
  before_action :set_item, only: [:show, :edit, :update, :destroy, :purchase, :buy]

  #jsonで親の名前で検索し、紐づく小カテゴリーの配列を取得
  def get_category_children
    @category_children = Category.find(params[:parent_name]).children
  end

  #jsonで子カテゴリーに紐づく孫カテゴリーの配列を取得
  def get_category_grandchildren
    @category_grandchildren = Category.find("#{params[:child_id]}").children
  end

  private
  #親カテゴリー
  def set_category  
    @category_parent_array = Category.where(ancestry: nil)
  end

  def item_params
    params.require(:item).permit(
      :category_id,
      :size_id, 
      #省略
      )
  end
end

(ルーティング)
resources :自動で生成されないactionへのパスを設定
member :パスに、:id情報が付く場合に使用
collection :パスに、:id情報が付かない場合に使用

※ 子カテゴリーと孫カテゴリーは、jsonをフォーマットにしておく。

routes.rb
resources :items do
  collection do
    get 'get_category_children', defaults: { format: 'json' }
    get 'get_category_grandchildren', defaults: { format: 'json' }
  end
  member do
    get 'get_category_children', defaults: { format: 'json' }
    get 'get_category_grandchildren', defaults: { format: 'json' }
  end
end

(jbuilder)
子カテゴリーのidとnameを取得し、@category_childrenに格納。

get_category_children.json.jbuilder
json.array! @category_children do |child|
  json.id child.id
  json.name child.name
end

孫カテゴリーのidとnameを取得し、@category_grandchildrenに格納。

get_category_grandchildren.json.jbuilder
json.array! @category_grandchildren do |grandchild|
  json.id grandchild.id
  json.name grandchild.name
end

3. 親カテゴリーの選択をきっかけに、紐づく子カテゴリーを表示

子カテゴリー、孫カテゴリー用に、selectボックスのoptionの入れ物を定義しておく。

item.js
function appendOption(category) {
  let html = 
    `<option value="${category.id}" data-category="${category.id}">${category.name}</option>`;
  return html;
}
      :

子カテゴリーのselectボックスを定義。

item.js
      :
//子カテゴリーのHTML
function appendChildrenBox(insertHTML) {
  let childSelectHtml = '';
  childSelectHtml = 
    `<select class="item_input__body__category__children--select" id="children_category">
       <option value="" data-category="" >選択してください</option>
       ${insertHTML}</select>
     <i class = "fa fa-chevron-down"></i>`;
  $('#children_box').append(childSelectHtml);
}
      :

同様に、孫カテゴリーのselectボックスも定義。

item.js
      :
//孫カテゴリーのHTML
function appendGrandchildrenBox(insertHTML) {
  let grandchildSelectHtml = '';
  grandchildSelectHtml = 
    `<select class="item_input__body__category__grandchildren--select" id="grandchildren_category" name="item[category_id]">
       <option value="" data-category="" >選択してください</option>
       ${insertHTML}</select>
     <i class = "fa fa-chevron-down"></i>`;
  $('#grandchildren_box').append(grandchildSelectHtml);
}
      :

親カテゴリーを選択したタイミングで、Ajax通信。親カテゴリーを選択し直す度に、下位のselectボックスを初期値に戻す。

item.js
      :
//親カテゴリー選択によるイベント
$(document).on("change","#parent_category", function() {
  //選択された親カテゴリーの名前取得 → コントローラーに送る
  let parentCategory =  $("#parent_category").val();
  if (parentCategory != "") {
    $.ajax( {
      type: 'GET',
      url: 'get_category_children',
      data: { parent_name: parentCategory },
      dataType: 'json'
    })
    .done(function(children) {
      //親カテゴリーが変更されたら、子/孫カテゴリー、サイズを削除し、初期値にする
      $("#children_box").empty();
      $("#grandchildren_box").empty();
      $('.size_box').val('');
      $('#size_box').css('display', 'none');
      let insertHTML = '';
      children.forEach(function(child) {
        insertHTML += appendOption(child);
      });
      appendChildrenBox(insertHTML);
    })
    .fail(function() {
      alert('error:子カテゴリーの取得に失敗');
    })
  }else{
    $("#children_box").empty();
    $("#grandchildren_box").empty();
    $('.size_box').val('');
    $('#size_box').css('display', 'none');
  }
});
      :

4. 子カテゴリーの選択をきっかけに、紐づく孫カテゴリーを表示

上記同様、子カテゴリーを選択したタイミングで、Ajax通信。子カテゴリーを選択し直す度に、下位のselectボックスを初期値に戻す。

item.js
      :
//子カテゴリー選択によるイベント発火
$(document).on('change', '#children_box', function() {
  //選択された子カテゴリーidを取得
  let childId = $('#children_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_box").empty();
        $('.size_box').val('');
        $('#size_box').css('display', 'none');
        let insertHTML = '';
        grandchildren.forEach(function(grandchild) {
          insertHTML += appendOption(grandchild);
        });
        appendGrandchildrenBox(insertHTML);
      }
    })
    .fail(function() {
      alert('error:孫カテゴリーの取得に失敗');
    })
  }else{
    $("#grandchildren_box").empty();
    $('.size_box').val('');
    $('#size_box').css('display', 'none');      
  }
});
      :

5. 孫カテゴリーの選択をきっかけに、サイズselectボックスを表示

サイズのselectボックスは、hamlで実装しており、cssで、隠している状態。孫カテゴリーを選択すると、display: blockで、表示させるような仕様。

_item.scss
#size_box{
  display: none;
}
item.js
      :
//孫カテゴリー選択によるイベント発火
$(document).on('change', '#grandchildren_box', function() {
  let grandchildId = $('#grandchildren_category option:selected').data('category');
  if (grandchildId != "") {
    $('.size_box').val('');
    $('#size_box').css('display', 'block');
  } else {
    $('.size_box').val('');
    $('#size_box').css('display', 'none');
  }
});

振り返り

商品出品ページの実装当初は、親カテゴリーはnameだけを取得して、配列を定義していた。newcreateアクションのみではこれでも問題はない。

items_controller.rb
#親カテゴリー
@category_parent_array = Category.where(ancestry: nil).pluck(:name)

しかし、上記のitemsコントローラーで分かる様に、itemsテーブルには、孫カテゴリーのidを保存している。なので、商品した出品の編集ページ(editアクション)では、孫カテゴリーのid情報から、紐づく子カテゴリー、親カテゴリーを取得し、表示させたい。つまり、親カテゴリーにはnameに加え、id情報も必要になった。
pluckメソッドにidを追加 もokだが、ancestryがnilのレコードを取得する方がシンプルという事で、結果、以下のようにしました。

items_controller.rb
    :
def edit
  #カテゴリーデータ取得
  @grandchild_category = @item.category
  @child_category = @grandchild_category.parent 
  @category_parent = @child_category.parent

  #カテゴリー一覧を作成
  @category = Category.find(params[:id])
  # 紐づく孫カテゴリーの親(子カテゴリー)の一覧を配列で取得
  @category_children = @item.category.parent.parent.children
  # 紐づく孫カテゴリーの一覧を配列で取得
  @category_grandchildren = @item.category.parent.children
end
    :
private
#親カテゴリー
def set_category  
  @category_parent_array = Category.where(ancestry: nil)
end
    :

※ 参考までに、editアクションでは、
各selectボックスを選択された状態にするため、各カテゴリーのデータ取得
孫カテゴリーに紐づく子カテゴリー、親カテゴリー一覧取得
を行っています。

商品編集ページ
Alt text

19
36
0

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
19
36