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

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

## 最終ゴール
**商品出品ページ**![Alt text](https://gyazo.com/8a459683e98e1cccdac99b978e56e4e5/raw)

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

## 0. 準備
- gemの導入 → `bundle install`

```Ruby:Gemfile
gem 'ancestry'
```
- モデル

```Ruby:item.rb
class Item < ApplicationRecord
  belongs_to :category
end
```
```Ruby: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
```

```Ruby: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で、親カテゴリーに紐づく子カテゴリーを表示。孫カテゴリーも同様の流れ。

```haml: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に格納。

```Ruby: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をフォーマットにしておく。

```Ruby: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に格納。

```Ruby:get_category_children.json.jbuilder
json.array! @category_children do |child|
  json.id child.id
  json.name child.name
end
```
孫カテゴリーのidとnameを取得し、＠category_grandchildrenに格納。

```Ruby:get_category_grandchildren.json.jbuilder
json.array! @category_grandchildren do |grandchild|
  json.id grandchild.id
  json.name grandchild.name
end
```
## 3. 親カテゴリーの選択をきっかけに、紐づく子カテゴリーを表示
子カテゴリー、孫カテゴリー用に、selectボックスのoptionの入れ物を定義しておく。

```JS:item.js
function appendOption(category) {
  let html = 
    `<option value="${category.id}" data-category="${category.id}">${category.name}</option>`;
  return html;
}
      :
```
子カテゴリーのselectボックスを定義。

```JS: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ボックスも定義。

```JS: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ボックスを初期値に戻す。

```JS: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ボックスを初期値に戻す。

```JS: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`で、表示させるような仕様。
   
```css:_item.scss
#size_box{
  display: none;
}
```

```JS: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だけを取得して、配列を定義していた。`new`、`create`アクションのみではこれでも問題はない。

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

```Ruby: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](https://gyazo.com/d2ca886de098bad9717862f85b272e2a/raw)