[Rails]某フリマアプリのカテゴリー機能の実装方法


# なにこれ
某スクールのカリキュラムでフリマアプリのクローンを作成中です。
そこで、フリマアプリ等によくあるカテゴリー機能を
[ancestry](https://github.com/stefankroes/ancestry)というgemを使って実装したので、
そちらの実装方法を説明用と備忘録として書いておきます。

# この記事を読んで分かること
gem ancestryの導入からカテゴリーボックスまでの実装まで
（ブランドと商品サイズは実装してません）

# 完成図
<img width="665" alt="0cd9ba9b63522a6228918b080238fca0.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/629015/3ad5078a-76f7-b827-c0e3-1bf2989b2085.png">

# 開発環境
ruby 2.5.1
Rails 5.2.4.3
Sequel Pro(MySQLデータベース)

# 前提条件
◎商品出品テーブルが作成済みであること。
（今回の場合はpostsテーブル）
◎テーブル名など編集が必要な部分は各自で書き換えて下さい。
◎カリキュラムで学習した内容を元に応用した自己流の実装です。
考え方は合ってると思いますが、もっとこうした方が良いなどがあれば是非教えていただきたいです！

# 実装方法の概要
0．gemインストールとseedとアソシエーションを組む
1．コントローラー、投稿フォームの編集とjsファイルを作成する
2．ルーティングを追加
3．コントローラーのアクションを作成
4．json.jbuilderファイルを作成
5．jsファイルを編集して上記で作成したアクションに送られたデータを受け取る。
6．子カテゴリーが完成。孫カテゴリーを呼び出すために3〜7を繰り返す。
7．おしまい

# 0．gemインストールとseedとアソシエーションを組む

自分より遥かに分かりやすい解説があったので、
以下の記事の「③レコード投入！」まで導入お願いします。
[多階層カテゴリでancestryを使ったら便利すぎた](https://qiita.com/Sotq_17/items/120256209993fb05ebac)




項目①で行う内容は、
1．gem ancestryのインストール
2．categoriesテーブルを作成してアソシエーションを組む
3．rails db:seedでレコードを突っ込みます！
って感じです！


8/4追記
rails db:seedの説明の記載がなかったので、追加します。
本記事はカテゴリー機能の実装方法を解説するのがメインなので、seedについては詳しく解説しません。
一言で表すと、「データの初期値を追加する」ってイメージです。

「seedってなんぞや？」と思った方は次の記事を参考にして下さい。
[【Rails入門】seedの使い方まとめ](https://www.sejuku.net/blog/28395)

次の記事にrails db:seedで追加する全てのデータが載ってるので、参考にして頂ければと思います。
[ancestryの使い方]
(https://qiita.com/KOKEINTER/items/f61323552b1cc793f316)


# 1．コントローラー、投稿フォームの編集とjsファイルを作成する
まず、コントローラーで変数@parentsを定義します！
#
```posts_controller.rb
before_action :set_parents, only: [:new, :create]

  def set_parents
    @parents = Category.where(ancestry: nil)
  end
```
set_parentsメソッドでは、
変数@parentsにCategoryテーブルのancestryがnilの値、
つまり親要素の値を代入します。
子要素（レディースなど）は、
ancestryカラムに1など親要素の階層を示すレコードのidが入ってます！

before_actionでnewとcreateを指定してる理由です。
newは当然ですけど、createに@parentsを定義しないと、投稿失敗時にrenderされます。
その時に@parentsが定義できてないと`render :new`した時に`undefined method 'map' for nil:NilClass`が起きます。
@postはpost_paramsで送られた内容が使えるけど、@parentsは未定義。
アクションで定義された変数しかrender先で使えませんので！
ちなみに、開発時はこの理由が分からなくて詰まりました。

#
```posts/_form.html.haml
#category_field
  = form.collection_select :category_id, @parents, :id, :name, { include_blank: "---", selected:"" }, id: "category_form"
```
投稿フォームは特に解説しません。

#
```category-form.js
  // 親セレクトを変更したらjQueryが発火する
  $("#category_form").change(function () {
    // 選択した親の値を取得する
    let parentValue = $("#category_form").val();
    // 初期値("---")以外を選択したらajaxを開始
    if (parentValue.length != 0) {
      $.ajax({
        url: '/posts/search',
        type: 'GET',
        // postsコントローラーにparamsをparent_idで送る
        data: { parent_id: parentValue },
        dataType: 'json'
      })
        .done(function (data) {
        })
        .fail(function () {
        });
    }
  });
```
<img width="352" alt="c73481867726f3003fb6c98ab59d58aa.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/629015/500c927f-34a0-e7bb-9100-6d78741d41a9.png">

`let parentValue = $("#category_form").val();`
こちらで選択したoptionの内容を取得します。
上記の画像で例えると、メンズというオプションを選択するとparentValueには「1」というvalueのidが代入されます。
`if (parentValue.length != 0) {`
こちらの記述の意味は、端的に言うとoption「---」以外が選択されたらtrueを返す。という意味です。
`url: '/posts/search',`
今回の場合はposts#searchというアクションを発火させます。
`data: { parent_id: parentValue },`
こちらの記述でdataにparent_idを代入してます。
上記の例で言うとdataにid「1」を代入しています。

# 2．ルーティングを追加
ajax用のルーティングを定義します。
これがないと、ajax通信してもどのルーティングに飛ぶか分からないので！
#
```config/routes.rb
  resources :posts do
    collection do
      get :search
    end
  end
```
今回の場合は、
出品商品を管理するpostsテーブルにsearchというアクションを追加します！

# 3．コントローラーのアクションを作成
先程ルーティングに追加したアクションに対応するアクションを作成します！
#
```posts_controller.rb
  def search

    #ajax通信を開始
    respond_to do |format|
      format.html
      format.json do
          #子カテゴリーを探して変数@childrensに代入します！
          @childrens = Category.find(params[:parent_id]).children
      end
    end

  end
```
@childrensには、parent_idのchildrenが代入されています。
例えば、dataに1（メンズのid）が代入されていたら、id1のchildren、つまり親の子（複数）を代入しています。
メンズだとトップス、アウターなどが親の子に当たります。

# 4．json.jbuilderファイルを作成
#
```views/posts/search.json.jbuilder
json.array! @childrens do |child|
  json.id child.id
  json.name child.name
end
```
自分の認識が合ってるか分からないのですが、
@childrensは複数あるので、1件ずつ代入してあげる。
と、いうイメージです。

# 5．jsファイルを編集して上記で作成したアクションに送られたデータを受け取る。
#
```category-form.js
  // 子のselectタグを追加
  function build_childSelect() {
    let child_select = `
              <select name="post[category_id]" class="child_category_id">
                <option value="">---</option>
              </select>
              `
    return child_select;
  }

  // selectタグにoptionタグを追加
  function build_Option(children) {
    let option_html = `
                      <option value=${children.id}>${children.name}</option>
                      `
    return option_html;
  }
        // 一部省略
        .done(function (data) {
          // selectタグを生成してビューにappendする
          let child_select = build_childSelect
          $("#category_field").append(child_select);
          // jbuilderから取得したデータを1件ずつoptionタグにappendする
          data.forEach(function (d) {
            let option_html = build_Option(d)
            $(".child_category_id").append(option_html);
          })
        })
        .fail(function () {
          alert("通信エラーです！");
        });
```
全体の流れは、
1．子専用（ここ大事）のセレクトタグを生成して#category_fieldにappendする
（自動的に#category_fieldの一番下に代入されます。親の後ろにできるってこと。）
2．送られてきたデータをforEachで取り出してオプションタグを生成して、
子専用のセレクトタグ(child_category_id)にappendします。
これで子カテゴリーの選択が可能になります。

1つだけ解説することがあります。
#
```category-form.js
// 子のselectタグを追加
  function build_childSelect() {
    let child_select = `
              <select name="post[category_id]" id="post_category_id" class="child_category_id">
```
selectタグを追加した時に`name="post[category_id]"`という記述をしました。
これの意味は、端的に言うと保存先のカラムを指定してます。
この記述がないとidがパラメーターで送ることができず、
親カテゴリーの情報しか反映されません。（親には最初からnameが記述済み）
なので、「子カテゴリーを選択しても親カテゴリーの情報しか入ってないやんけ！ふざけんな！」
っていう状況になります。
チーム開発中にこれで苦しんでるSさんという人がいたので、一応解説しました。笑

# 6．子カテゴリーが完成。孫カテゴリーを呼び出すために3〜7を繰り返す。
以上で子カテゴリーの生成が完了しました！お疲れ様です！

次は孫カテゴリーの生成をしますが、やることはほとんど同じです。

コントローラーを編集します。
#
```posts_controller.rb
       #一部省略
      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
        end
      end
```
変数@grandChildsに子の子供、つまり孫（複数）を代入します。

if文で条件分岐してる理由について解説します。

子カテゴリーを選択した際はパラメーターが1種類しか送られてこないので問題ないです。
しかし、孫カテゴリーも同じアクション(URL)を呼び出す時は、条件分岐が必要です。
どっちの情報なのか判別できないので！

アクションを分けたりすればif文を使わなくても良いと思いますが、
スマートじゃないので同じアクションにまとめました。

#
```views/posts/search.json.jbuilder
json.array! @grandChilds do |gc|
  json.id gc.id
  json.name gc.name
end
```
先程と流れは同じなので省略します！

#
```category-form.js
// 一部省略

  // 孫のselectタグを追加
  function build_gcSelect() {
    let gc_select = `
              <select name="post[category_id]" class="gc_category_id">
              </select>
              `
    return gc_select;
  }

// 一部省略

  // 子セレクトを変更したらjQueryが発火する
  $(document).on("change", ".child_category_id", function () {
    // 選択した子の値を取得する
    let childValue = $(".child_category_id").val();
    // 初期値("---")以外を選択したらajaxを開始
    if (childValue.length != 0) {
      $.ajax({
        url: '/posts/search',
        type: 'GET',
        // postsコントローラーにparamsをchildren_idで送る
        data: { children_id: childValue },
        dataType: 'json'
      })
        .done(function (gc_data) {
          // selectタグを生成してビューにappendする
          let gc_select = build_gcSelect
          $("#category_field").append(gc_select);
          // jbuilderから取得したデータを1件ずつoptionタグにappendする
          gc_data.forEach(function (gc_d) {
            let option_html = build_Option(gc_d);
            $(".gc_category_id").append(option_html);
          });
        })
        .fail(function () {
          alert("gcで通信エラーです！");
        });
    }
  });
```
本当にやってる内容はほぼ同じです。

# 7．おしまい
以上でカテゴリー機能の実装は終了になります。お疲れ様です！

カテゴリー機能の成功条件としては以下のようになります。

1．親カテゴリーの値を選んだとき子カテゴリーが非同期で出現する。
2．子カテゴリーの値を選んだとき孫カテゴリーが非同期で出現する。
3．親カテゴリーの中身を違う値にすると、子と孫のカテゴリーが消えて子カテゴリーの中身が変わっている。
4．子カテゴリーの中身を違う値にすると、孫のカテゴリーが消えて新しく中身が変わった孫カテゴリーが出現する。

ちなみに現在のソースコードだと、
例えば親カテゴリーを変更した際に子カテゴリーと孫カテゴリーは消えません。
同様に子カテゴリーを変更した際に孫カテゴリーは消えません。
後、商品編集時にもエラーが起きます。
数行だけ記述すれば消えるので、そこは皆さんで考えて下さい！

不明点が質問があればお気軽にどうぞ！