タイトル通り、とある一方のセレクトボックスの選択によってもう一方の選択肢が変わる動的セレクトボックス(インクリメンタルサーチ付き)を作っていきます。
インクリメンタルサーチとは、キーワード検索を行う際に、利用者が文字を入力するたびに検索を実行する方式。検索語全体を入力する前に検索を開始し、一文字進むごとに検索結果が更新されていく。
完成イメージ
動画のように、メインカテゴリーを選択した際、それに紐付くサブカテゴリーが選択肢として並ぶようにします。
また、各選択肢はテキスト入力で検索できるようになっています。
仕様
- Rails 7
- Bootstrap 5
- Stimulus
- Tom Select
Rails 7、Bootstrap 5、Stimulus の環境構築は各自あらかじめ済ませておいてください。
インクリメンタルサーチ付きのセレクトボックスが簡単に作れるライブラリとしては「Select2」 なども有名ですが、こちらは JQuery が必要な事もあり採用を見送りました。
他に代替案は無いか探してみたところ 「Tom Select」が候補として挙がった次第です。
Tom Select をインストール
$ yarn add tom-select
// 追記
@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 で作成していきます。
gem 'active_hash'
$ bundle install
class MainCategory < ActiveHash::Base
include ActiveHash::Associations
has_many :sub_categories
self.data = [
{ id: 1, name: "ファッション" },
{ id: 2, name: "美容" },
{ id: 3, name: "グルメ" }
]
end
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
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
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
コントローラーを作成
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
ルーティングを作成
Rails.application.routes.draw do
resources :posts, only: %i[index new create]
end
ビューを作成
<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
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);
}
}
// 追記
import PostsController from "./posts_controller"
application.register("posts", PostsController)
Tom Select の使い方については公式ドキュメントを参照してください。
オプションの数も結構あり、色々カスタマイズできそうです。
動作確認
こんな感じになっていれば完成です。