[Rails]カテゴリー機能

# はじめに
アプリ開発において、ancestryというgemを用いてカテゴリー機能を加えたのでまとめました。

# 目次
1. カテゴリー選択・保存
2. カテゴリー検索表示
3. カテゴリー検索結果表示

# 1. カテゴリー選択・保存
![カテゴリー選択・保存](https://i.gyazo.com/863a3b34ce4bc36b38bb775ae3b8c916.gif)

### categoriesテーブルの作成
ancestryをインストールします。

```ruby:gemfile
gem 'ancestry'
```

次にcategoryモデルを作成します。

##### ターミナル
```
rails g model category
```

has_ancestryを記述します。

```ruby:app/models/category.rb
class Category < ApplicationRecord
  has_many :posts
  has_ancestry
end
```

以下のようにマイグレーションファイルに記述します。
indexについては[こちら](https://qiita.com/seiya1121/items/fb074d727c6f40a55f22)

```ruby:db/migrate/20XXXXXXXXXXXX_create_categories.rb
class CreateCategories < ActiveRecord::Migration[6.0]
  def change
    create_table :categories do |t|
      t.string :name,     index: true, null: false
      t.string :ancestry, index: true
      t.timestamps
    end
  end
end
```

googleスプレッドシートにカテゴリーを記述していきます。
Aの列がid、Bの列がname（カテゴリー名）、Cの列がancestry（親子孫を見分ける数値）となります。
データの保存方法は、ファイル → ダウンロード → カンマ区切りの値（.csv 現在のシート） の手順で保存できます。

![カテゴリー記述例](https://i.gyazo.com/d050781758ba384923168fae3b88da34.png)
![カテゴリー記述例](https://i.gyazo.com/3ee594f734865a74717e87db4e8b7691.png)

ダウンロードしたcsvファイルはdbフォルダに配置します。

seeds.rbファイル内へ以下の通り記述します。

```ruby:db/seeds.rb 
require "csv"

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

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

### ルーティング
子、孫カテゴリーをjson形式でルーティングを設定します。

```ruby:config/routes.rb
Rails.application.routes.draw do
  ~略~
  resources :posts do
    collection do
      get 'top'
      get 'get_category_children', defaults: { format: 'json' }
      get 'get_category_grandchildren', defaults: { format: 'json' }
      get 'name_search'
    end
  ~略~
end
```

### コントローラー
postsコントローラーに親カテゴリーを定義します。
複数箇所で使用するためbefore_actionを使って定義します。

```ruby:app/controllers/posts_controller.rb
def set_parents
  @parents = Category.where(ancestry: nil)
end
```

postsコントローラーに子、孫カテゴリーのメソッドを定義します。

```ruby:app/controllers/posts_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データへ変換します。

```ruby:app/views/posts/get_category_children.json.jbuilder 
json.array! @category_children do |child|
  json.id child.id
  json.name child.name
end
```

```ruby:app/views/posts/get_category_grandchildren.json.jbuilder 
json.array! @category_grandchildren do |grandchild|
  json.id grandchild.id
  json.name grandchild.name
end
```

### ビュー
javascriptでカテゴリー選択時の動作を設定します。

```javascript
:app/javascript/category_post.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="post[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="post[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: '/posts/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);
        if (insertHTML == "") {
          $('#children_wrapper').remove();
        }
      })
      .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 != ""){
      $.ajax({
        url: '/posts/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);
        if (insertHTML == "") {
          $('#grandchildren_wrapper').remove();
        }
      })
      .fail(function(){
        alert('カテゴリー取得に失敗しました');
      })
    }else{
      $('#grandchildren_wrapper').remove();
    }
  })
});
```

新規投稿ページにカテゴリーセレクトボックスを表示させます。

```ruby:app/views/posts/new.html.erb
<div class="append__category">
  <div class="category">
    <div class="form__label">
      <div class="weight-bold-text lavel__name ">
        カテゴリー
      </div>
      <div class="lavel__Required">
        <%= f.collection_select :category_id, @parents, :id, :name,{ include_blank: "選択してください"},class:"serect_field", id:"item_category_id" %>
      </div>
    </div>
  </div>
</div>
```

# 2. カテゴリー検索表示
![カテゴリー検索表示](https://i.gyazo.com/dbbbf7ad9f16545c30c180185b516643.gif)
### コントローラー

```ruby:app/controllers/posts_controller.rb
def top
  respond_to do |format|
    format.html
    format.json do
      if params[:parent_id]
        @childrens = Category.find(params[:parent_id]).children
      elsif params[:children_id]
        @grandChilds = Category.find(params[:children_id]).children
      elsif params[:gcchildren_id]
        @parents = Category.where(id: params[:gcchildren_id])
      end
    end
  end
end
```

### ビュー
javascriptでどの親カテゴリーにの上にマウスがいるのか、それに属する子カテゴリーや孫カテゴリーを取得しています。

```javascript
:app/javascript/category.js
$(document).ready(function () {
  // 親カテゴリーを表示
  $('#categoBtn').hover(function (e) {
    e.preventDefault();
    e.stopPropagation();
    $('#tree_menu').show();
    $('.categoryTree').show();
  }, function () {
    // あえて何も記述しない
  });

  // 非同期にてヘッダーのカテゴリーを表示
  function childBuild(children) {
    let child_category = `
                        <li class="category_child">
                          <a href="/posts/${children.id}/search"><input class="child_btn" type="button" value="${children.name}" name= "${children.id}">
                          </a>
                        </li>
                        `
    return child_category;
  }

  function gcBuild(children) {
    let gc_category = `
                        <li class="category_grandchild">
                          <a href="/posts/${children.id}/search"><input class="gc_btn" type="button" value="${children.name}" name= "${children.id}">
                          </a>
                        </li>
                        `
    return gc_category;
  }

  // 親カテゴリーを表示
  $('#categoBtn').hover(function (e) {
    e.preventDefault();
    e.stopPropagation();
    timeOut = setTimeout(function () {
      $('#tree_menu').show();
      $('.categoryTree').show();
    }, 500)
  }, function () {
    clearTimeout(timeOut)
  });

  // 子カテゴリーを表示
  $('.parent_btn').hover(function () {
    $('.parent_btn').css('color', '');
    $('.parent_btn').css('background-color', '');
    let categoryParent = $(this).attr('name');
    timeParent = setTimeout(function () {
      $.ajax({
          url: '/posts/top',
          type: 'GET',
          data: {
            parent_id: categoryParent
          },
          dataType: 'json'
        })
        .done(function (data) {
          $(".categoryTree-grandchild").hide();
          $(".category_child").remove();
          $(".category_grandchild").remove();
          $('.categoryTree-child').show();
          data.forEach(function (child) {
            let child_html = childBuild(child)
            $(".categoryTree-child").append(child_html);
          });
          $('#tree_menu').css('max-height', '490px');
        })
        .fail(function () {
          alert("カテゴリーを選択してください");
        });
    }, 400)
  }, function () {
    clearTimeout(timeParent);
  });

  // 孫カテゴリーを表示
  $(document).on({
    mouseenter: function () {
      $('.child_btn').css('color', '');
      $('.child_btn').css('background-color', '');
      let categoryChild = $(this).attr('name');
      timeChild = setTimeout(function () {
        $.ajax({
            url: '/posts/top',
            type: 'GET',
            data: {
              children_id: categoryChild
            },
            dataType: 'json'
          })
          .done(function (gc_data) {
            $(".category_grandchild").remove();
            $('.categoryTree-grandchild').show();
            gc_data.forEach(function (gc) {
              let gc_html = gcBuild(gc)
              $(".categoryTree-grandchild").append(gc_html);
              let parcol = $('.categoryTree').find(`input[name="${gc.root}"]`);
              $(parcol).css('color', 'white');
              $(parcol).css('background-color', '#b1e9eb');
            });
            $('#tree_menu').css('max-height', '490px');
          })
          .fail(function () {
            alert("カテゴリーを選択してください");
          });
      }, 400)
    },
    mouseleave: function () {
      clearTimeout(timeChild);
    }
  }, '.child_btn');

  // 孫カテゴリーを選択時
  $(document).on({
    mouseenter: function () {
      let categoryGc = $(this).attr('name');
      timeGc = setTimeout(function () {
        $.ajax({
            url: '/posts/top',
            type: 'GET',
            data: {
              gcchildren_id: categoryGc
            },
            dataType: 'json'
          })
          .done(function (gc_result) {
            let childcol = $('.categoryTree-child').find(`input[name="${gc_result[0].parent}"]`);
            $(childcol).css('color', 'white');
            $(childcol).css('background-color', '#b1e9eb');
            $('#tree_menu').css('max-height', '490px');
          })
          .fail(function () {
            alert("カテゴリーを選択してください");
          });
      }, 400)
    },
    mouseleave: function () {
      clearTimeout(timeGc);
    }
  }, '.gc_btn');


  // カテゴリー一覧ページのボタン
  $('#all_btn').hover(function (e) {
    e.preventDefault();
    e.stopPropagation();
    $(".categoryTree-grandchild").hide();
    $(".categoryTree-child").hide();
    $(".category_grandchild").remove();
    $(".category_child").remove();
  }, function () {
    // あえて何も記述しないことで親要素に外れた際のアクションだけを伝搬する
  });

  // カテゴリーを非表示(カテゴリーメニュから0.8秒以上カーソルを外したら消える)
  $(document).on({
    mouseleave: function (e) {
      e.stopPropagation();
      e.preventDefault();
      timeChosed = setTimeout(function () {
        $(".categoryTree-grandchild").hide();
        $(".categoryTree-child").hide();
        $(".categoryTree").hide();
        $(this).hide();
        $('.parent_btn').css('color', '');
        $('.parent_btn').css('background-color', '');
        $(".category_child").remove();
        $(".category_grandchild").remove();
      }, 800);
    },
    mouseenter: function () {
      timeChosed = setTimeout(function () {
        $(".categoryTree-grandchild").hide();
        $(".categoryTree-child").hide();
        $(".categoryTree").hide();
        $(this).hide();
        $('.parent_btn').css('color', '');
        $('.parent_btn').css('background-color', '');
        $(".category_child").remove();
        $(".category_grandchild").remove();
      }, 800);
      clearTimeout(timeChosed);
    }
  }, '#tree_menu');

  // カテゴリーボタンの処理
  $(document).on({
    mouseenter: function (e) {
      e.stopPropagation();
      e.preventDefault();
      timeOpened = setTimeout(function () {
        $('#tree_menu').show();
        $('.categoryTree').show();
      }, 500);
    },
    mouseleave: function (e) {
      e.stopPropagation();
      e.preventDefault();
      clearTimeout(timeOpened);
      $(".categoryTree-grandchild").hide();
      $(".categoryTree-child").hide();
      $(".categoryTree").hide();
      $("#tree_menu").hide();
      $(".category_child").remove();
      $(".category_grandchild").remove();
    }
  }, '.header__headerInner__nav__listsLeft__item');
});
```

トップ画面にカテゴリー選択ウィンドウをセットします。

```ruby:app/views/posts/top.html.erb
  <div class="item-categories">
    <h2>
      カテゴリー一覧
    </h2>
    <%= link_to  posts_path, class: "category-button", id: 'categoBtn' do %>
      カテゴリーから探す
    <% end %>
    <div id="tree_menu">
      <ul class="categoryTree">
        <% @parents.each do |parent| %>
          <li class="category_parent">
            <%= link_to search_post_path(parent) do %>
              <input type="button" value="<%= parent.name %>" name="<%= parent.id %>" class="parent_btn">
            <% end %>
          </li>
        <% end %>
      </ul>
      <ul class="categoryTree-child">
      </ul>
      <ul class="categoryTree-grandchild">
      </ul>
    </div>
  </div>
```

# 3. カテゴリー検索結果表示
![カテゴリいー検索結果表示](https://i.gyazo.com/b2736c7e21e6a5113c938b04bca04ceb.gif)

### ルーティング
カテゴリーをidで区別するため、memberを用いてsearchアクションを定義しています。

```ruby:config/routes.rb
resources :posts do
    ~略~
    member do
      get 'search'
    end
   ~略~
end
```

### コントローラー
クリックしたカテゴリーが、親カテゴリー、子カテゴリー、孫カテゴリーのどれなのかで条件分岐しています。

```ruby:app/controllers/posts_controller.rb
  def search
    @category = Category.find_by(id: params[:id])

    if @category.ancestry == nil
      category = Category.find_by(id: params[:id]).indirect_ids
      if category.empty?
        @posts = Post.where(category_id: @category.id).order(created_at: :desc)
      else
        @posts = []
        find_item(category)
      end

    elsif @category.ancestry.include?("/")
      @posts = Post.where(category_id: params[:id]).order(created_at: :desc)

    else
      category = Category.find_by(id: params[:id]).child_ids
      @posts = []
      find_item(category)
    end
  end

  def find_item(category)
    category.each do |id|
      post_array = Post.where(category_id: id).order(created_at: :desc)
      if post_array.present?
        post_array.each do |post|
          if post.present?
            @posts.push(post)
          end
        end
      end
    end
  end
```

### ビュー
```ruby:app/views/posts/search.html.erb
  <div class="item-categories">
    <h2>
      カテゴリー一覧
    </h2>
    <%= link_to  posts_path, class: "category-button", id: 'categoBtn' do %>
      カテゴリーから探す
    <% end %>
    <div id="tree_menu">
      <ul class="categoryTree">
        <% @parents.each do |parent| %>
          <li class="category_parent">
            <%= link_to search_post_path(parent) do %>
              <input type="button" value="<%= parent.name %>" name="<%= parent.id %>" class="parent_btn">
            <% end %>
          </li>
        <% end %>
      </ul>
      <ul class="categoryTree-child">
      </ul>
      <ul class="categoryTree-grandchild">
      </ul>
    </div>
  </div>
```

# 参考リンク
https://qiita.com/k_suke_ja/items/aee192b5174402b6e8ca
https://qiita.com/Sobue-Yuki/items/9c1b05a66ce6020ff8c1 https://qiita.com/dr_tensyo/items/88e8ddf0f5ce37040dc8
https://qiita.com/ATORA1992/items/bd824f5097caeee09678
https://qiita.com/misioro_missie/items/175af1f1678e76e59dea
https://qiita.com/Rubyist_SOTA/items/49383aa7f60c42141871