【Rails】ancestryを使って動的なセレクトボックスを作る方法


[ancestry](https://github.com/stefankroes/ancestry)というGemを使うと階層カテゴリを簡単に作ることができます。今回はancestryを使いつつ、動的なセレクトボックスを作る方法をご紹介します。

完成イメージは以下の通りです。
![dynamic_select.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/348703/ab367545-aada-7340-1de4-7b4fea6fa96d.gif)

今回使用したコードはGithubからも見れるようにしておきました。
[ancestry-sandbox](https://github.com/kenzoukenzou/ancestry-sandbox)

## 前提

- ruby-version: 2.6.3
- rails-version: 6.0.0


## 準備

### ancestryをinstall

まずはGemのinstallから行います。テストデータを作成するための`seed-fu`のinstallもここで行います。

```ruby
# Gemfile
gem 'ancestry'
gem 'seed-fu'
```

```
$ bundle install
```

### migrationを実行

例えば`category`モデルで階層化を行いたい場合は、以下を実行します。

```
$ rails g migration add_ancestry_to_category ancestry:string:index
```

```
$ rake db:migrate
```

```ruby:app/models/cateogry.rb
class Category < ActiveRecord::Base
   has_ancestry
end
```

### テストデータを挿入

動作確認をするためのテストデータを作ります。(ここは冗長なコードになってしまっている気がするので、改善方法ご教授いただきたいです🙇‍♂️)

```ruby:db/fixtures/01_category.rb
parents = %w[ 家電 ファッション 本 ]
electronics_child = %w[ 洗濯機 炊飯器 ]
fashion_child = %w[ アウター トップス ]
book_child = %w[ 自己啓発 技術書 ]

parents.each do |parent|
  Category.seed do |c|
    c.name = parent
  end
end
electronics_child.each do |child|
  Category.find_by!(name: '家電').children.create(name: child)
end
fashion_child.each do |child|
  Category.find_by!(name: 'ファッション').children.create(name: child)
end
book_child.each do |child|
  Category.find_by!(name: '本').children.create(name: child)
end
```

上記で使っている`children`がancestryをinstallしたことで使えるようになったメソッドで、上記の例では家電>洗濯機, ファッション>アウターなどのカテゴリが生成されています。

### jQueryをinstall

Ajaxで動的セレクトボックスを作るときにjQueryを使用するので、セットアップを行います。

```
$ yarn add jquery
```

```ruby:config/webpack/environment.js
const { environment } = require('@rails/webpacker')
const webpack = require('webpack');

environment.plugins.prepend('Provide',
  new webpack.ProvidePlugin({
    $: 'jquery/src/jquery',
    jQuery: 'jquery/src/jquery'
  })
)
module.exports = environment
```

## 動的セレクトボックスを作る

ここからが本題で動的セレクトボックスを作っていきます。親のカテゴリを選択すると、それに紐づく子供のカテゴリを表示するということです。

### セレクトボックスを表示

親カテゴリとデフォルトで表示させる子カテゴリを変数代入し、最初のセレクトボックスで表示させます。

```ruby:app/controllers/products_controller.rb
before_action :set_categories, only: %w[edit new]
private
def set_categories
 @parent_categories = Category.roots
 @default_child_categories = @parent_categories.first.children
end
```

```rhtml:app/views/products/_form.html.erb
<div class="actions">
  <select id="parent_category">
    <% @parent_categories.each do |c| %>
      <option value="<%= c.id %>"><%= c.name %></option>
    <% end %>
  </select>
  <select id="product_category_ids" name="product[category_ids]">
    <% @default_child_categories.each do |c| %>
      <option><%= c.name %></option>
    <% end %>
  </select>
</div>
```

ここまでで一旦セレクトボックスを２つ並べるところまでは完了しました。続いて、親カテゴリが変更されたときにAjaxを利用して子カテゴリが動的に切り替わる実装をしていきます。


### ルーティングの設定
まずはルーティングにAjax用のパスを指定します。

```ruby:routes.rb
get :dynamic_select_category, to: 'products#dynamic_select_category'
```

### Ajax処理を記述
次にAjax処理を追記します。下記で、`dynamic_select_category_path`にセレクトボックスで選択されたvalueをデータとして持たせてAjax送信を行っています。

```rhtml:app/views/products/_form.html.erb
<script type="text/javascript">
$(function() {
  $('#parent_category').change(function() {
    $.ajax({
      url: "<%= dynamic_select_category_path %>",
      type: 'GET',
      data: { category_id: $('#parent_category').has('option:selected').val() }
    });
  });
});
</script>
```

### 子カテゴリを動的に表示

```ruby:app/controllers/products_controller.rb
def dynamic_select_category
 @category = Category.find(params[:category_id])
end
```

```rhtml:app/views/produts/dynamic_select_category.js.erb
$('#product_category_ids').html('<%= j(options_for_select(@category.children.pluck(:name, :id))) %>');
```

コントローラ側では受け取った`category_id`からカテゴリを特定し、js.erbの方では`#product_category_ids`の書き換えを行なっています。

ここでも上述した`children`メソッドで子カテゴリを表示するようにしています。


## 参考

- [Ajaxでセレクトボックスの中身が動的に変わるRailsアプリの作り方](https://qiita.com/jnchito/items/59a5f6bea3d7be84b839)
- [【Rails】セレクトボックスの内容をAjaxを使用して動的に変更する](https://www.task-notes.com/entry/20161222/1482389031)