Rails 7 + Stimulus + Tom Select で作るインクリメンタルサーチ付き動的セレクトボックス

タイトル通り、とある一方のセレクトボックスの選択によってもう一方の選択肢が変わる動的セレクトボックス（インクリメンタルサーチ付き）を作っていきます。

> インクリメンタルサーチとは、キーワード検索を行う際に、利用者が文字を入力するたびに検索を実行する方式。検索語全体を入力する前に検索を開始し、一文字進むごとに検索結果が更新されていく。

https://e-words.jp/w/%E3%82%A4%E3%83%B3%E3%82%AF%E3%83%AA%E3%83%A1%E3%83%B3%E3%82%BF%E3%83%AB%E3%82%B5%E3%83%BC%E3%83%81.html#:~:text=%E3%82%A4%E3%83%B3%E3%82%AF%E3%83%AA%E3%83%A1%E3%83%B3%E3%82%BF%E3%83%AB%E3%82%B5%E3%83%BC%E3%83%81%20%E3%80%90incremental%20search%E3%80%91%20%E9%80%90%E6%AC%A1,%E3%81%8C%E6%9B%B4%E6%96%B0%E3%81%95%E3%82%8C%E3%81%A6%E3%81%84%E3%81%8F%E3%80%82

## 完成イメージ

![画面収録-2023-03-18-13.24.27.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/904a02b2-48be-39de-bdf1-31fdfae249d6.gif)

動画のように、メインカテゴリーを選択した際、それに紐付くサブカテゴリーが選択肢として並ぶようにします。

また、各選択肢はテキスト入力で検索できるようになっています。

## 仕様

- Rails 7
- Bootstrap 5
- Stimulus
- Tom Select

Rails 7、Bootstrap 5、Stimulus の環境構築は各自あらかじめ済ませておいてください。

インクリメンタルサーチ付きのセレクトボックスが簡単に作れるライブラリとしては「[Select2](https://select2.org/)」 なども有名ですが、こちらは JQuery が必要な事もあり採用を見送りました。

他に代替案は無いか探してみたところ 「[Tom Select](https://tom-select.js.org/)」が候補として挙がった次第です。

## Tom Select をインストール

```
$ yarn add tom-select
```

```scss:./app/assets/stylesheets/application.bootstrap.scss
// 追記
@import "tom-select/dist/css/tom-select.bootstrap5";
```

## 各種モデルを作成

- MainCategory
- SubCategory
- Post

今回はこの3つを作成していきます。

Post（投稿）は複数のカテゴリ（MainCategory、SubCategory）に属し、メインカテゴリーは子要素としてのサブカテゴリーを持っているイメージです。

### MainCategory と SubCategory

| MainCategory |
| ---- |
| id: Integer |
| name: String |

| SubCategory |
| ---- |
| id: Integer |
| name: String |
| main_category_id: Integer |


MainCategory と SubCategory に関しては [Active_hash](https://github.com/zilkey/active_hash) で作成していきます。

```rb:./Gemfile
gem 'active_hash'
```

```
$ bundle install
```

```rb:./app/models/main_category.rb
class MainCategory < ActiveHash::Base
  include ActiveHash::Associations
  has_many :sub_categories

  self.data = [
    { id: 1, name: "ファッション" }, 
    { id: 2, name: "美容" }, 
    { id: 3, name: "グルメ" }
  ]
end
```

```rb:./app/models/sub_category.rb
class SubCategory < ActiveHash::Base
  include ActiveHash::Associations
  belongs_to :main_category

  self.data = [
    { id: 1, name: "プチプラ", main_category_id: 1 }, 
    { id: 2, name: "ハイブランド", main_category_id: 1 }, 
    { id: 3, name: "コスメ", main_category_id: 2 },
    { id: 4, name: "ヘアアレンジ", main_category_id: 2 },
    { id: 5, name: "ネイル", main_category_id: 2 },
    { id: 6, name: "手料理", main_category_id: 3 },
    { id: 7, name: "カフェ巡り", main_category_id: 3 }
  ]
end
```

### Post

| Post |
| ---- |
| id: Integer |
| title: String |
| body: Text |
| main_category_id: Integer |
| sub_category_id: Integer |

```
$ rails g model Post title:string body:text main_category_id:integer sub_category_id:integer
```

```ruby:./db/migrate/xxxxxxx_create_posts.rb
class CreatePosts < ActiveRecord::Migration[7.0]
  def change
    create_table :posts do |t|
      t.string :title
      t.text :body
      t.integer :main_category_id
      t.integer :sub_category_id

      t.timestamps
    end
  end
end
```

```
$ rails db:migrate
```

```rb:./app/models/post.rb
class Post < ApplicationRecord
  extend ActiveHash::Associations::ActiveRecordExtensions
  
  belongs_to_active_hash :main_category
  belongs_to_active_hash :sub_category

  validates :title, presence: true
  validates :body, presence: true
  validates :main_category_id, presence: true
  validates :sub_category_id, presence: true
end
```

## コントローラーを作成

```rb:./app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    @posts = Post.all
  end

  def new
    @post = Post.new
  end

  def create
    post = Post.new(post_params)

    if post.save!
      redirect_to posts_path, notice: "投稿の作成に成功しました"
    else
      render :new, alert: "投稿の作成に失敗しました"
    end
  end

  private
  
    def post_params
      params.require(:post).permit(
        :title,
        :body,
        :main_category_id,
        :sub_category_id
      )
    end
end
```

## ルーティングを作成

```ruby:./config/routes.rb
Rails.application.routes.draw do
  resources :posts, only: %i[index new create]
end
```

## ビューを作成

```erb:./app/views/posts/new.html.erb
<div class="container m-5" data-controller="posts">
  <h2 class="text-center">投稿作成</h2>
  <div class="d-flex justify-content-center">
    <%= form_with model: @post, local: true do |f| %>
      <table class="table">
        <tr>
          <td>
            <%= f.label "タイトル", class: "form-label" %>
          </td>
          <td>
            <%= f.text_field :title, class: "form-control" %>
          </td>
        </tr>
        <tr>
          <td>
            <%= f.label "本文", class: "form-label" %>
          </td>
          <td>
            <%= f.text_area :body, class: "form-control" %>
          </td>
        </tr>
        <tr>
          <td>
            <%= f.label "メインカテゴリー", class: "form-label" %>
          </td>
          <td>
            <%= f.collection_select :main_category_id,
                                    MainCategory.all, :id, :name,
                                    { include_blank: true },
                                    class: "form-select",
                                    data: { posts_target: "mainCategorySelect", action: "change->posts#filterSubCategoryOptions" } %>
          </td>
        </tr>
        <tr>
          <td>
            <%= f.label "サブカテゴリー", class: "form-label" %>
          </td>
          <td>
            <%= f.select :sub_category_id,
                          SubCategory.all.map { |sub_category| [sub_category.name, sub_category.id, data: { "main-category-id": sub_category.main_category_id }] },
                          { include_blank: true },
                          class: "form-select",
                          data: { posts_target: "subCategorySelect" } %>
          </td>
        </tr>
      </table>
      
      <div class="text-center">
        <%= f.submit "送信", class: "btn btn-primary" %>
      </div>
    <% end %>
  </div>
</div>
```

**ポイント**

- `data-controller="posts"` で後述の `app/javascript/controllers/posts_controller.js` を読み込ませる

- `SubCategory.all.map { |sub_category| [sub_category.name, sub_category.id, data: { "main-category-id": sub_category.main_category_id }] }` でサブカテゴリーの各選択肢に data 属性 main-category-id を持たせる（JavaScript 側で拾ってフィルタリングを行う用）

## JavaScript を記述

```
$ rails g stimulus TomSelectController
```

```javascript:./app/javascript/controller/posts_controller.js
import { Controller } from "@hotwired/stimulus"
import TomSelect from "tom-select"

// Connects to data-controller="posts"
export default class extends Controller {
  static targets = ["mainCategorySelect", "subCategorySelect"]

  connect() {
    const config = {}
    this.mainCategorySelect = this.initTomSelect(this.mainCategorySelectTarget, config);
    this.subCategorySelect = this.initTomSelect(this.subCategorySelectTarget, config);
    this.subCategorySelect.isDisabled = true;
    this.subCategoryOptions = Object.values(this.subCategorySelect.options);
  }

  initTomSelect(target, config) {
    return new TomSelect(target, config);
  }

  filterSubCategoryOptions(e) {
    const mainCategoryId = e.target.value;

    if (mainCategoryId) this.subCategorySelect.isDisabled = false;

    const filteredSubCategoryOptions = this.subCategoryOptions.filter(option => option.mainCategoryId == mainCategoryId);

    this.subCategorySelect.clear();
    this.subCategorySelect.clearOptions();
    this.subCategorySelect.addOptions(filteredSubCategoryOptions);
  }
}
```

```javascript:./app/javascript/controller/index_controller.js
// 追記
import PostsController from "./posts_controller"
application.register("posts", PostsController)
```

Tom Select の使い方については公式ドキュメントを参照してください。

https://tom-select.js.org/docs/

オプションの数も結構あり、色々カスタマイズできそうです。

## 動作確認

![スクリーンショット 2023-03-18 13.56.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/f6eeb430-b1de-3086-678c-151f7378edcf.png)

こんな感じになっていれば完成です。