【Rails】 gem ancestry  カテゴリー機能実装について

# はじめに
草野と申します。
今回の投稿は、プログラミングスクールで学んだことを復習がてらにアウトプットを行います。
自分用のメモのため、文章は拙いですが、少しでも初学者の助けになればと考えています。
内容は、表題にもあるとおり、スクールで行ったチーム開発によるフリマアプリのカテゴリー機能実装についてです。未熟な点も多いと思います。不備等ありましたらご指摘ください。随時改善して行こうと思います。

# 完成品
親カテゴリーが選択されるとイベント発火し、子、孫とセレクトボックスが表示されます。

**商品出品時のカテゴリー登録画面**
[![Image from Gyazo](https://i.gyazo.com/2cdfbf0c9404425a2b4d6d5c2816058e.gif)](https://gyazo.com/2cdfbf0c9404425a2b4d6d5c2816058e)


**商品詳細情報のカテゴリー情報呼び出し画面**
<img width="1430" alt="qiita_カテゴリー商品情報.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634232/d8462ab2-f229-8e77-763f-697df17f5ab5.png">



# 実装手順

**1. DBへモデルの作成・アソシエーション定義**

 - gem ancestry のインストール
 - categoryモデルの作成
 - categorysマイグレーションファイルの作成
 - itemモデルとのアソシエーション定義
 - CSVファイル読み込みによるDB内へレコードの追加

**2.カテゴリー登録機能**

 - 子、孫カテゴリーのJSON用ルーティング設定
 - itemsコントローラーへ親カテゴリーのインスタンス変数定義
 - itemsコントローラーへ子、孫カテゴリーのJSON用メソッド定義
 - json.jbuilderファイルへJSONデータへの変換記述
 - JavaScriptによる親、子、孫の選択時の動作を設定
 - html.hamlファイルへ呼び出し記述

**3.カテゴリー情報の呼び出し**

 - itemsコントローラーのshowメソッド内に親、子、孫それぞれのインスタンス変数を定義
 - itemsコントローラーで定義したインスタンス変数を用いてhamlファイルに記述しビューへ呼び出し



# DBへモデルの作成・アソシエーション定義

Ruby on Railsの”ancestry”というgemを使用し、カテゴリー機能を追加します。

```rb:Gemfile&nbsp;
gem 'ancestry'
```
ターミナルでrails g model categoryコマンドによりcategoryモデルを作成。
has_ancestoryを記述。

```rb:app/models/category.rb&nbsp;
class Category < ApplicationRecord
  has_ancestry
  has_many :items
end
```
categorysマイグレーションファイルに下記の通り記述し、ターミナルでrails db:migrateコマンドを実行

```rb:db/category.rb&nbsp;
class CreateCategories < ActiveRecord::Migration[5.2]
  def change
    create_table :categories do |t|
      t.string :name,     null: false
      t.string :ancestry
      t.timestamps
    end
    add_index :categories, :ancestry
  end
end
```
googleスプレットシートにてカテゴリーを全て記述。
Aの列がid、Bの列がname（カテゴリー名）、Cの列がancestry（親子孫を見分ける数値）となります。カテゴリーを全てCSVファイルに記述したのですが、なんと1368行もあり、入力作業に苦労しました。（前職で行っていた事務仕事を思い出します。）ちなみに孫なしカテゴリーがあるため、ancestryの数値がズレないよう注意です。
データの保存方法は、ファイル → ダウンロード → カンマ区切りの値（.csv 現在のシート） の手順で保存できます。
[![Image from Gyazo](https://i.gyazo.com/17d47af98e3bf238ed8a4a211f8d03d4.png)](https://gyazo.com/17d47af98e3bf238ed8a4a211f8d03d4)
[![Image from Gyazo](https://i.gyazo.com/8cf020e411c5540eff47b1c0be6c82ad.png)](https://gyazo.com/8cf020e411c5540eff47b1c0be6c82ad)
ダウンロードしたCSVファイルを直接dbファイル内へ入れます。
<img width="747" alt="Cursor_と_category_csv_—_freemarket_sample_75a-2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/634232/5aaccd35-521f-73df-80e2-4929befdaa78.png">

seeds.rbファイル内へ以下の通り記述し、ターミナルでrails db:seedコマンドを実行するとCSVファイルを読み込み自動でDBのレコードが生成されます。
記述内容についての解説ですが、foreachの後に読み込みたいファイルの指定を行います。
その下の記述については、モデル名.create(カラム名 => 読み込みたい列)となります。
row[0] → Aの列がid
row[1] → Bの列がname（カテゴリー名）
row[2] → Cの列がancestry（親子孫を見分ける数値）

```rb:db/seeds.rb&nbsp;
require "csv"

CSV.foreach('db/category.csv') do |row|
  Category.create(:id => row[0], :name => row[1], :ancestry => row[2])
end 
```

# カテゴリー登録機能
子、孫カテゴリーのJSON用ルーティング設定
collection do 内の上２つが今回作成したJSON用のルーティングになります。
defaults:{fomat:'json'}と記述することでJSON専用になります。

```rb:config/routes.rb&nbsp;
  resources :items do
    resources :comments,  only: [:create, :destroy]
    resources :favorites, only: [:create, :destroy]
    collection do
      get 'get_category_children', defaults: { fomat: 'json'}
      get 'get_category_grandchildren', defaults: { fomat: 'json'}
      get 'search'
      get 'post_done'
      get 'delete_done'
      get 'detail_search'
      get 'update_done'
    end
  end
```
itemsコントローラーへ親カテゴリーのインスタンス変数定義
以下の記述をnewメソッド内に定義します。（実装を進めると他のアクションでも使用するため、後々privateに定義し直しbefore_actionにてリファクタリングを行うことになります。）

```rb:app/controllers/items_controller.rb&nbsp;
@category_parent_array = Category.where(ancestry: nil)
```
itemsコントローラーへ子、孫カテゴリーのJSON用メソッド定義
params[]の括弧内の記述は、後々に解説するJavaScriptファイル内のajaxで送られてくる :parent_id と :child_id を記述します。

```rb:app/controllers/items_controller.rb&nbsp;
  def get_category_children
    @category_children = Category.find("#{params[:parent_id]}").children
  end

  def get_category_grandchildren
    @category_grandchildren = Category.find("#{params[:child_id]}").children
  end
```
 json.jbuilderファイルを作成の上、JSONデータへの変換記述

```rb:app/views/items/get_category_children.json.jbuilder&nbsp;
json.array! @category_children do |child|
  json.id child.id
  json.name child.name
end
```
```rb:app/views/items/get_category_grandchildren.json.jbuilder&nbsp;
json.array! @category_grandchildren do |grandchild|
  json.id grandchild.id
  json.name grandchild.name
end
```
JavaScriptにより親、子、孫の選択時の動作を設定

```rb:app/assets/javascripts/category.js&nbsp;
$(function(){
  function appendOption(category){
    var html = `<option value="${category.id}">${category.name}</option>`;
    return html;
  }
  function appendChildrenBox(insertHTML){
    var childSelectHtml = "";
    childSelectHtml = `<div class="category__child" id="children_wrapper">
                        <select id="child__category" name="item[category_id]" class="serect_field">
                          <option value="">---</option>
                          ${insertHTML}
                        </select>
                      </div>`;
    $('.append__category').append(childSelectHtml);
  }
  function appendGrandchildrenBox(insertHTML){
    var grandchildSelectHtml = "";
    grandchildSelectHtml = `<div class="category__child" id="grandchildren_wrapper">
                              <select id="grandchild__category" name="item[category_id]" class="serect_field">
                                <option value="">---</option>
                                ${insertHTML}
                                </select>
                            </div>`;
    $('.append__category').append(grandchildSelectHtml);
  }

  $('#item_category_id').on('change',function(){
    var parentId = document.getElementById('item_category_id').value;
    if (parentId != ""){
      $.ajax({
        url: '/items/get_category_children/',
        type: 'GET',
        data: { parent_id: parentId },
        dataType: 'json'
      })
      .done(function(children){
        $('#children_wrapper').remove();
        $('#grandchildren_wrapper').remove();
        var insertHTML = '';
        children.forEach(function(child){
          insertHTML += appendOption(child);
        });
        appendChildrenBox(insertHTML);
      })
      .fail(function(){
        alert('カテゴリー取得に失敗しました');
      })
    }else{
      $('#children_wrapper').remove();
      $('#grandchildren_wrapper').remove();
    }
  });
  $('.append__category').on('change','#child__category',function(){
    var childId = document.getElementById('child__category').value;
    if(childId != "" && childId != 46 && childId != 74 && childId != 134 && childId != 142 && childId != 147 && childId != 150 && childId != 158){
      $.ajax({
        url: '/items/get_category_grandchildren',
        type: 'GET',
        data: { child_id: childId },
        dataType: 'json'
      })
      .done(function(grandchildren){
        $('#grandchildren_wrapper').remove();
        var insertHTML = '';
        grandchildren.forEach(function(grandchild){
          insertHTML += appendOption(grandchild);
        });
        appendGrandchildrenBox(insertHTML);
      })
      .fail(function(){
        alert('カテゴリー取得に失敗しました');
      })
    }else{
      $('#grandchildren_wrapper').remove();
    }
  })
});

```
上記の記述について解説します。
中段にある以下の記述で最初に表示されている親セレクトボックスでカテゴリーを選択した際にイベントが発火するようにしています。２行目の記述は、選択したカテゴリーのidを取得し、変数定義しています。

```rb:①
$('#item_category_id').on('change',function(){
    var parentId = document.getElementById('item_category_id').value;
```
次にajaxにより①で取得したカテゴリーのidをparent_idに入れ、先ほどルーティングしたJSON用のルートを通ってコントローラーへparent_idを渡し、子カテゴリーのレコードを取得します。

```rb:②
      $.ajax({
        url: '/items/get_category_children/',
        type: 'GET',
        data: { parent_id: parentId },
        dataType: 'json'
      })
```
ajax通信に成功した場合、②で取得した子カテゴリーのレコードを5行目のforEachメソッドで展開します。
2、3行目のremoveメソッドは、親セレクトボックスで再度別のカテゴリーが選択された場合、子、孫のセレクトボックスを消すために記述しています。

```rb:③
.done(function(children){
        $('#children_wrapper').remove();
        $('#grandchildren_wrapper').remove();
        var insertHTML = '';
        children.forEach(function(child){
          insertHTML += appendOption(child);
        });
        appendChildrenBox(insertHTML);
      })
```
上段に記述しているappendOptionは、③の6行目appendOption(child)の引数で先ほど取得した子レコードを渡し、それぞれidとname（カテゴリー名）をoptionタグに埋め込みを行っています。

```rb:④
  function appendOption(category){
    var html = `<option value="${category.id}">${category.name}</option>`;
    return html;
  }
```
appendChildrenBoxは、④の内容を③の6行目に記述しているinsertHTMLに入れ、③の8行目に記述しているappendChildrenBox(insertHTML)の引数で④のoptionタグを渡しています。
\${insertHTML}の記述でoptionタグを埋め込み最後に$('.append__category').append(childSelectHtml);の記述でブラウザに非同期で子セレクトボックスを表示させるという流れになります。孫セレクトボックスの流れも同様です。

```rb:⑤
  function appendChildrenBox(insertHTML){
    var childSelectHtml = "";
    childSelectHtml = `<div class="category__child" id="children_wrapper">
                        <select id="child__category" name="item[category_id]" class="serect_field">
                          <option value="">---</option>
                          ${insertHTML}
                        </select>
                      </div>`;
    $('.append__category').append(childSelectHtml);
  }
```
ajax通信に失敗した場合はアラートが表示されます。

```rb:⑥
      .fail(function(){
        alert('カテゴリー取得に失敗しました');
      })
```
ajaxの上に記述しているif文についてはhamlファイルのcollection_selectのオプションであるinclude_blankを用いて初期値をnilに設定しており、こちらがnilからidのあるoptionを選択するとajax通信が始まる。逆に"選択してください"という初期値に戻すとelseになり、孫セレクトボックスを消すようにしています。

```rb:⑦
 if (parentId != ""){
# 途中の記述は省略
    }else{
      $('#grandchildren_wrapper').remove();
```
子と孫の記述の違いについてですが、孫の記述には、javaScriptにより追加されたものをイベント発火の対象にする場合は下記の通りに記述する必要があります。
\$(調査範囲).on(イベント名,イベントが起こる場所,function(){
また、if文で孫なしカテゴリーでない場合にajax通信が始まるよう条件分岐を設定しまています。

```rb:⑧
$('.append__category').on('change','#child__category',function(){
    var childId = document.getElementById('child__category').value;
    if(childId != "" && childId != 46 && childId != 74 && childId != 134 && childId != 142 && childId != 147 && childId != 150 && childId != 158){
```
セレクトボックスを非同期でブラウザに表示させるに当たり、注意点が１つ。
子、孫共にselectタグにnameオプションで"item[category_id]"としている箇所についてです。nameオプションにより親、子、孫のどのcategory_idをitemsテーブルに保存するのかという点について指定しています。

**子のみにnameオプションをつけ、孫カテゴリーまで入力**
→　DBでは、子のidが保存
**孫のみにnameオプションをつけ、孫なしカテゴリー入力**
→　DBでは、親のcategory_idが保存
**子、孫共にnameオプションをつけ,孫カテゴリーまで入力**
→　DBでは、最後に非同期で表示されたnameオプションのついたselectタグが優先して保存。

なぜ孫のcategory_idを保存する必要があるかというとカテゴリー情報呼び出しの際に孫のcategory_idを元に親、子のレコードを呼び出すことができるからです。

```rb:⑨
  function appendChildrenBox(insertHTML){
    var childSelectHtml = "";
    childSelectHtml = `<div class="category__child" id="children_wrapper">
                        <select id="child__category" name="item[category_id]" class="serect_field">
                          <option value="">---</option>
                          ${insertHTML}
                        </select>
                      </div>`;
    $('.append__category').append(childSelectHtml);
  }
  function appendGrandchildrenBox(insertHTML){
    var grandchildSelectHtml = "";
    grandchildSelectHtml = `<div class="category__child" id="grandchildren_wrapper">
                              <select id="grandchild__category" name="item[category_id]" class="serect_field">
                                <option value="">---</option>
                                ${insertHTML}
                                </select>
                            </div>`;
    $('.append__category').append(grandchildSelectHtml);
  }
```

hamlファイルに親セレクトボックスを表示させます。

```rb:app/views/items/_form.html.haml&nbsp;
    .append__category
      .category
        .form__label
          .lavel__name 
            カテゴリー
          .lavel__Required
            [必須]
        =f.collection_select :category_id, @category_parent_array, :id, :name,{ include_blank: "選択してください"},class:"serect_field"
```

# カテゴリー情報の呼び出し
itemsコントローラーのshowメソッド内に親、子、孫それぞれのインスタンス変数を定義

```rb:app/controllers/items_controller.rb&nbsp;
@category_id = @item.category_id
@category_parent = Category.find(@category_id).parent.parent
@category_child = Category.find(@category_id).parent
@category_grandchild = Category.find(@category_id)
```
itemsコントローラーで先ほど定義したインスタンス変数を用いて登録されたカテゴリーをhamlファイルでビューに呼び出します。if文で孫なしor孫ありで表示を条件分岐させています。（今回リンクにはパスを指定していないので＃とさせて頂いています。）

```rb:app/views/items/_main_show.html.haml&nbsp;
            %table 
              %tr 
                %th 出品者
                %td= @user.nickname
              %tr 
                %th カテゴリー
                - if [46, 74, 134, 142, 147, 150, 158].include?(@category_id)
                  %td
                    = link_to "#{@category_child.name}","#"
                    %br= link_to "#{@category_grandchild.name}","#" 
                -else
                  %td
                    = link_to "#{@category_parent.name}","#"
                    %br= link_to "#{@category_child.name}","#"
                    = link_to "#{@category_grandchild.name}","#"
              %tr
                %th ブランド
                %td= @item.brand_name
              %tr
                %th 商品のサイズ
                %td
              %tr
                %th 商品の状態
                %td= @item.item_status
              %tr
                %th 配送料の負担
                %td= @item.delivery_fee
              %tr
                %th 発送元の地域
                %td= link_to "#{@item.shipping_origin}","#"
              %tr
                %th 発送日の目安
                %td= @item.days_until_shipping
```

#参考にさせて頂いた記事

[ancestryによる多階層構造データを用いて、動的カテゴリーセレクトボックスを実現する~Ajax~](https://qiita.com/ATORA1992/items/bd824f5097caeee09678)
[【翻訳】Gem Ancestry公式ドキュメント](https://qiita.com/Rubyist_SOTA/items/49383aa7f60c42141871)