13
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-06-14

はじめに

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

完成品

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

商品出品時のカテゴリー登録画面
Image from Gyazo

商品詳細情報のカテゴリー情報呼び出し画面
qiita_カテゴリー商品情報.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を使用し、カテゴリー機能を追加します。

Gemfile 
gem 'ancestry'

ターミナルでrails g model categoryコマンドによりcategoryモデルを作成。
has_ancestoryを記述。

app/models/category.rb 
class Category < ApplicationRecord
  has_ancestry
  has_many :items
end

categorysマイグレーションファイルに下記の通り記述し、ターミナルでrails db:migrateコマンドを実行

db/category.rb 
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
Image from Gyazo
ダウンロードしたCSVファイルを直接dbファイル内へ入れます。
Cursor_と_category_csv_—_freemarket_sample_75a-2.png

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

db/seeds.rb 
require "csv"

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

カテゴリー登録機能

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

config/routes.rb 
  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にてリファクタリングを行うことになります。)

app/controllers/items_controller.rb 
@category_parent_array = Category.where(ancestry: nil)

itemsコントローラーへ子、孫カテゴリーのJSON用メソッド定義
params[]の括弧内の記述は、後々に解説するJavaScriptファイル内のajaxで送られてくる :parent_id と :child_id を記述します。

app/controllers/items_controller.rb 
  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データへの変換記述

app/views/items/get_category_children.json.jbuilder 
json.array! @category_children do |child|
  json.id child.id
  json.name child.name
end
app/views/items/get_category_grandchildren.json.jbuilder 
json.array! @category_grandchildren do |grandchild|
  json.id grandchild.id
  json.name grandchild.name
end

JavaScriptにより親、子、孫の選択時の動作を設定

app/assets/javascripts/category.js 
$(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();
    }
  })
});

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

$('#item_category_id').on('change',function(){
    var parentId = document.getElementById('item_category_id').value;

次にajaxにより①で取得したカテゴリーのidをparent_idに入れ、先ほどルーティングしたJSON用のルートを通ってコントローラーへparent_idを渡し、子カテゴリーのレコードを取得します。

      $.ajax({
        url: '/items/get_category_children/',
        type: 'GET',
        data: { parent_id: parentId },
        dataType: 'json'
      })

ajax通信に成功した場合、②で取得した子カテゴリーのレコードを5行目のforEachメソッドで展開します。
2、3行目のremoveメソッドは、親セレクトボックスで再度別のカテゴリーが選択された場合、子、孫のセレクトボックスを消すために記述しています。

.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タグに埋め込みを行っています。

  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);の記述でブラウザに非同期で子セレクトボックスを表示させるという流れになります。孫セレクトボックスの流れも同様です。

  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通信に失敗した場合はアラートが表示されます。

      .fail(function(){
        alert('カテゴリー取得に失敗しました');
      })

ajaxの上に記述しているif文についてはhamlファイルのcollection_selectのオプションであるinclude_blankを用いて初期値をnilに設定しており、こちらがnilからidのあるoptionを選択するとajax通信が始まる。逆に"選択してください"という初期値に戻すとelseになり、孫セレクトボックスを消すようにしています。

 if (parentId != ""){
# 途中の記述は省略
    }else{
      $('#grandchildren_wrapper').remove();

子と孫の記述の違いについてですが、孫の記述には、javaScriptにより追加されたものをイベント発火の対象にする場合は下記の通りに記述する必要があります。
$(調査範囲).on(イベント名,イベントが起こる場所,function(){
また、if文で孫なしカテゴリーでない場合にajax通信が始まるよう条件分岐を設定しまています。

$('.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){

セレクトボックスを非同期でブラウザに表示させるに当たり、注意点が1つ。
子、孫共にselectタグにnameオプションで"item[category_id]"としている箇所についてです。nameオプションにより親、子、孫のどのcategory_idをitemsテーブルに保存するのかという点について指定しています。

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

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

  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ファイルに親セレクトボックスを表示させます。

app/views/items/_form.html.haml 
    .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メソッド内に親、子、孫それぞれのインスタンス変数を定義

app/controllers/items_controller.rb 
@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孫ありで表示を条件分岐させています。(今回リンクにはパスを指定していないので#とさせて頂いています。)

app/views/items/_main_show.html.haml 
            %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~
【翻訳】Gem Ancestry公式ドキュメント

13
19
1

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?