[rails6] 動的なカテゴリー機能の実装　前半（ancestry）

#はじめに
`ancestry`というgemを使用するにあたって、カテゴリー機能を実装するのにとても時間がかかってしまった。備忘録として残しつつ理解を深めていく。

長くなってしまったので前後半で分けた。今回の実装する部分は下記の通り。

- 前半の実装
 - 初期データの投入（カテゴリーデータの作成）
 - カテゴリーに紐づく動的なセレクトボックスの実装

SNS風の投稿アプリがある想定で下記の箇所を実装していく。
投稿の一覧(index)
投稿機能(new)　←今回の実装部分
投稿の詳細(show)
投稿の編集(edit)

#完成図
![スクリーンレコーディング2021-10-08at 10.10.56 AM](https://p8.f1.n0.cdn.getcloudapp.com/items/mXuPd458/13a640f8-4c2e-4ccf-b049-2c3c776cdda2.gif?v=e537bdd4406d39426aaaf0515b3c228d)

##環境
Ruby 2.7.4
Rails 6.1.4
jQuery 4.4.0
ancestry 4.1.0

##テーブル
-|postsテーブル|
-|-------|
FK|category_id|

-|categoriesテーブル|
-|--------|
-|id|
-|name|
-|ancestry|
二つのテーブルは1(posts)対多(categories)の関係。
postは既存のテーブルを想定、後からcategoryを追加する形を取る。
実装にあたり余計なカラムは記述していない。


#カテゴリー登録機能を実装

##JQueryの導入
[Rails6でのjQuery導入方法](https://qiita.com/tatsuhiko-nakayama/items/b2f0c77e794ca8c9bd74)を参照。

ここでjQeryを扱う理由は以下の通り。
>ブラウザの違いを意識せずに済む
>HTMLのDOM操作が簡単にできる
>Ajax処理が簡単に記述できる

参考もと
[jQueryとは？/筆者：寺谷文宏（てらたにふみひろ）](https://techacademy.jp/magazine/9444#ta-toc-5)

##gemの導入
```gemfile
gem 'ancestry'
```
上記を`gemfile`に記述。
ターミナルから`bundle install`を叩いてgemをインストール。

##モデルの準備
```terminal
rails g model category
```
Categoryモデルを作成。
Postモデルも同様にここで作成してもいいがカテゴリー機能を後付けで実装する想定で進めるため、Postモデルは既にある状態とする。

##マイグレーションファイルの編集
```db/migrate/xxxxxxxxxxxxxx_create_categories.rb
  def change
    create_table :categories do |t|
      t.string :name,     null: false
      t.string :ancestry
      t.timestamps
    end
    add_index :categories, :ancestry
  end
```
categoriesテーブルに`name`,`ancestry`のカラムを記述。


```terminal
rails g migration AddCategoryIdToPosts 
```
既存のPostモデルに後付けでカラムを追加するため、新たにマイグレーションファイルを追加して対応。
マイグレーションファイルは一度マイグレーションで反映させてしまうと次回以降、参照対象から外れる。
そのため既存の`xxxxxxxxxxx_create_teble.rb`は書き換えても意味がない。


```db/migrate/xxxxxxxxxxxxx_add_category_id_to_posts.rb
class AddUserIdToPosts < ActiveRecord::Migration[5.2]
  def change
    add_reference :posts, :category, foreign_key: true
  end
end
```
記述が終わったら忘れず`rails db:migrate`でマイグレーションする。


##関連付け
```app/models/category.rb
class Category < ApplicationRecord
  has_many :posts
  has_ancestry
end
```
categoryモデルに関連付けを行う。
`has_many`で、Categoryは複数のpostを所属することを記述。
`has_ancestry`で、ancestryの階層化を有効にするよう記述。

```app/models/posts.rb
class Post < ApplicationRecord
  belongs_to :category
end
```
Postモデルに関連付けを行う。
`belongs_to`で、postがcategoryに従属している事を記述。


##初期データの作成
![スクリーンショット 2021-10-01 14.37.38.png](https://p8.f1.n0.cdn.getcloudapp.com/items/YEuO5gmg/21ad35b2-1a0f-4bb2-aa8e-3b97c6fe9b1f.png?v=8bad491e74b57c3f3dd6912e56fa899d)
googleスプレットシートを使用。
Aの列に`id`、Bの列に`name`、Cの列に`ancestry`を配置する形で情報を記述。

親カテゴリーC列は`nill`として扱うため記述をしなくてOK
子カテゴリーC列では、どの親カテゴリーに属しているかを`親id`で記述。
孫カテゴリーC列では、どの親と子に属しているかを`親id/子id`で記述。

##初期データをCSVファイルに変換・保存
![スクリーンショット 2021-10-01 15.08.57.png](https://p8.f1.n0.cdn.getcloudapp.com/items/xQu6ypmo/083e3be8-81ea-43f5-b52c-58a3d00d53df.png?v=8fbc53df9d6b2ee6c4a6bb57bf81b692)
次にgoogleスプレッドシートの項目[ファイル]→[ダウンロード]→[カンマ区切りの値(.csv、現在のシート)]を選択。
シートに記述したデータを`csv`ファイルに変換してデスクトップに保存。

```terminal
#CSVファイルを移動
mv ~/Downloads/category.csv ~/sample_app/db/category.csv
```
保存したCSVファイルをプロジェクトの`db`に移す。
出力したCSVファイルはgoogleスプレッドシートのタイトル名＋シート名でファイル名が決まるため必要に応じて変換すること。


##初期データの投入
```db/seeds.rb
#CSVファイルの読み込みを有効化
require "csv"

##CSVファイルをforeachでインポート
CSV.foreach('db/category.csv') do |row|
  Category.create(:id => row[0], :name => row[1], :ancestry => row[2])
end 
```
`db/seed.rb`に上記を記述。
`CSV.foreach('csvファイルへのパス')`でcsvファイルを指定して読み込む
`Category.create(:カラム => ブロック変数[n])`でカンマ区切りの値をインデックス番号で振り分けながらカテゴリーを生成する。

[リファレンスマニュアル](https://docs.ruby-lang.org/ja/latest/class/CSV.html)

```terminal
rails db:seed
```
ターミナルから`rails db:seed`を叩いて投入完了。


##ルーティング
```config/routes.rb
  resources :posts do
    collection do
      get "get_category_children", defaults: { format: "json" }
      get "get_category_grandchildren", defaults: { format: "json" }
    end
  end
```
`resources`でpostsに基づく7つの基本的なアクションをルーティング。
`collection`で独自に定義するアクションをpostsの配下でルーティング。
生成されるパスは`GET posts/get_category_children`のような感じ。

`defaults: { format: "json" }`でJSON形式の応答を指定。


##コントローラー
```app/controllers/posts_controller.rb
before_action :set_parents

private

  def set_parents
    @set_parents = Category.where(ancestry: nil)
  end

  def post_params
    params.require(:post).permit(:category_id)
  end
```
`set_parents`は親カテゴリーのみを抽出したインスタンス変数を定義。
`params.require(:キーに紐づくモデル).permint(:取得するカラム)`で意図しないカラムの更新を防ぐためにストロングパラメーターを設定。
`private`は以下に定義したアクションを外部から呼び出されないように記述。
`before_action`でアクションの実行前に特定のアクションが実行されるように記述


##独自アクションを定義
```app/controllers/posts_controller.rb
  def get_category_children
    @category_children = Category.find(params[:parent_id].to_s).children
  end

  def get_category_grandchildren
    @category_grandchildren = Category.find(params[:child_id].to_s).children
  end

private
```
ルーティングで設定したJSON用アクションを定義。
`get_category_children`は子カテゴリーのidをJSON経由で取得。
`get_category_grandchildren`は孫カテゴリーidをJSON経由で取得。
`params[:xxxx_id].to_s`は後ほど記述するjavascriptファイル内のajaxから送られる値を記述。
`children`はancestryの提供するインスタンスメソッド。子のレコードを取得する。


##jbuilderファイルを作成
```terminal
touch app/views/posts/get_category_children.json.jbuilder
touch app/views/posts/get_category_grandchildren.json.jbuilder
```
2つの`json.jbuilder`を作成。
このファイルを介してアクションで定義した値がJSON形式へと変換される。

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

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

下記は変換されたJSONの返り値。
`# [{"id": "idの値１", "name": "nameの値１"}, {"id": "idの値２", "name": "nameの値２"}]`


##javascriptファイルを作成
```terminal
touch app/javascript/packs/category_post.js
```
空のファイルを作って中身を書いていく。
ここの記述によって選択するセレクトボックスがカテゴリーの階層に合わせて動的に変化する。

```app/javascript/packs/category_post.js
$(document).on('turbolinks:load', function() {
    //①セレクトボックスに必要なoptionタグを生成
    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="select_field">
                          <option value="">---</option>
                          //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="select_field">
                                <option value="">---</option>
                                //optionタグを埋め込む
                                ${insertHTML}
                                </select>
                            </div>`;
    //ブラウザに非同期で孫セレクトボックスを表示される
    $('.append__category').append(grandchildSelectHtml);
  }
  //親カテゴリー選択時に子カテゴリーのイベント発火
  $('#post_category_id').on('change',function(){
    //選択したカテゴリーのidを取得
    var parentId = document.getElementById('post_category_id').value;
    if (parentId != ""){
      //取得したidをコントローラへ渡し、子カテゴリーを取得
      $.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 = '';
        //forEachで子カテゴリーを展開
        children.forEach(function(child){
          //①と紐づいて取得した子のid,nameをoptionタグに埋め込む
          insertHTML += appendOption(child);
        });
        appendChildrenBox(insertHTML);
        if (insertHTML == "") {
          $('#children_wrapper').remove();
        }
      })
      //ajax通信の失敗時にアラートを表示
      .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();
    }
  })
});
```
`document.getElementById('post_category_id').value;`では、セレクトボックスで選択されたidを取得。
`$.ajax({~})`の部分からJSON用のルートを通ってコントローラーへと取得したidが非同期で渡り、子・孫カテゴリーのレコードを取得。
`remove`でセレクトボックスが再選択された場合にルートの子・孫を削除。
`forEach`でajaxで取得した子・孫カテゴリーのレコードを展開。
`appendOption`は取得した子・孫レコードからoptionタグにidとnameを埋め込む。
`insertHTML`はappendxxxxxxBoxと紐づいて、`appendOption`の内容を渡す。
`alert`はajax通信で失敗した場合にアラートを表示。

##view
```app/views/posts/new.html.erb
<%= form_with model: @post, local: true do |f| %>

<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, @set_parents, :id, :name,{ include_blank: "選択してください"},class:"select_field", id:"post_category_id" %>
      </div>
    </div>
  </div>
</div>

 <%= f.submit class: "btn btn-primary btn-block" %>
<% end %>
```
`collection_select`でモデルの情報をもとにセレクトボックスを作成。
`@set_parents`で親カテゴリーを取得。
`id:"post_category_id`で選択されたカテゴリーidをjavascriptと共有。


#javascriptの読み込みを有効化
```app/views/layouts/application.html.erb
  <head>
    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>
```
`javascript_pack_tag 'application'`がデフォルト記述されている事を確認。
ここのヘッダー部分からjavascriptの読み込みが行われている。

```app/javascript/packs/application.js
require('./category_post');
```
新たに作成した`category_post.js`は指定してあげないと読み込まれていないので、上記を記述する事で読み込むようにする。




ひとまず、ここまでがカテゴリーの登録機能の実装は完了。
次の記事でカテゴリーに所属するpostを検索・表示する機能を追加する。

#参考もと
[ancestry/公式ドキュメント](https://github.com/stefankroes/ancestry)
[[Rails]カテゴリー機能](https://qiita.com/iwkmsy9618/items/815db7d6f1b4b230644d#3-%E3%82%AB%E3%83%86%E3%82%B4%E3%83%AA%E3%83%BC%E6%A4%9C%E7%B4%A2%E7%B5%90%E6%9E%9C%E8%A1%A8%E7%A4%BA)
[【Rails】 gem ancestry カテゴリー機能実装について](https://qiita.com/k_suke_ja/items/aee192b5174402b6e8ca)
[ancestryによる多階層構造データを用いて、動的カテゴリーセレクトボックスを実現する~Ajax~
](https://qiita.com/ATORA1992/items/bd824f5097caeee09678)