【Rails 6】片方の選択によってもう片方のオプションの値が変化する動的なセレクトボックスを作る

## 概要

タイトル通りのものを作ります。文字だけだとわかりにくかったら、次の完成イメージを見てください。

## 完成イメージ

![タイトルなし.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/63098019-e563-05c7-934f-2f6811b17010.gif)

## 仕様

- Ruby 3
- Rails 6
- MySQL 5.7
- Bootstrap 5
- Docker

※ Bootstrap はバージョン5から JQuery が不要になり導入方法が少し変わったのでご注意ください。

## 下準備

まず最初に下準備から始めていきます。

### 各種ディレクトリ & ファイルを作成

```
$ mkdir rails-dynamic-selectbox && cd rails-dynamic-selectbox
$ touch Dockerfile docker-compose.yml entrypoint.sh Gemfile Gemfile.lock
```

```dockerfile:./Dockerfile
FROM ruby:3.0

RUN curl https://deb.nodesource.com/setup_14.x | bash

RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
    && echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs yarn

ENV APP_PATH /myapp

RUN mkdir $APP_PATH
WORKDIR $APP_PATH

COPY Gemfile $APP_PATH/Gemfile
COPY Gemfile.lock $APP_PATH/Gemfile.lock
RUN bundle install

COPY . $APP_PATH

COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["rails", "server", "-b", "0.0.0.0"]
```

```yml:./docker-compose.yml
version: "3"
services:
  db:
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: password
    volumes:
      - mysql-data:/var/lib/mysql
      - /tmp/dockerdir:/etc/mysql/conf.d/
    ports:
      - 4306:3306
  web:
    build:
      context: .
      dockerfile: Dockerfile
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/myapp
      - ./vendor/bundle:/myapp/vendor/bundle
    environment:
      TZ: Asia/Tokyo
      RAILS_ENV: development
    ports:
      - "3000:3000"
    depends_on:
      - db
volumes:
  mysql-data:
```


```sh:./entrypoint.sh
#!/bin/bash
set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /myapp/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
```

```ruby:./Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "rails", "~> 6"
```

```ruby:./Gemfile.lock
# 空欄でOK
```

### rails new

おなじみのコマンドでアプリの雛型を作成。

```
$ docker-compose run web rails new . --force --no-deps -d mysql --skip-test
```

### database.ymlを編集

デフォルトの状態だとデータベースとの接続ができないので「database.yml」の一部を書き換えます。

```yml:./config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password
  host: db

development:
  <<: *default
  database: myapp_development

test:
  <<: *default
  database: myapp_test

production:
  <<: *default
  database: <%= ENV["DATABASE_NAME"] %>
  username: <%= ENV["DATABASE_USERNAME"] %>
  password: <%= ENV["DATABASE_PASSWORD"] %>
  host: <%= ENV["DATABASE_HOST"] %>
```

### コンテナを起動 & データベースを作成

```
$ docker-compose build
$ docker-compose up -d
$ docker-compose run web bundle exec rails db:create
```

### 動作確認（localhost:3000 にアクセス）

![スクリーンショット 2022-01-03 21.41.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/6d3c7749-4e15-01c9-046e-9bb02ac964a6.png)


[localhost:3000](http://localhost:3000/) にアクセスしてウェルカムページが表示されればOKです。

### slim を導入

個人的にビューのテンプレートエンジンは erb よりも slim の方が好みなので変更します。

```rb:./Gemfile
gem 'slim-rails'
gem 'html2slim'
```

```
$ docker-compose build
```

既存のビューファイルを slim に書き換え。

```
$ docker-compose run web bundle exec erb2slim app/views app/views
$ docker-compose run web bundle exec erb2slim app/views app/views -d
```


### Bootstrap を導入

見た目を整えるために Bootstrap を使用します。

※ 下記の手順はバージョン5を想定したものになるので、別のバージョンを使いたい場合は他の記事を参考に導入してください。

#### yarn add

必要なライブラリをインストール。

```
$ docker-compose run web yarn add bootstrap @popperjs/core
```

#### app/views/layouts/application.html

```diff_ruby:./app/views/layouts/application.html
- = stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
+ = stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
```

「app/views/layouts/application.html」内の9行目あたりに記述されている「stylesheet_link_tag」を「stylesheet_pack_tag」に書き換えます。

#### app/javascript/stylesheets/application.scss

```
$ mkdir app/javascript/stylesheets
$ touch app/javascript/stylesheets/application.scss
```

「app/javascript/stylesheets/」以下に「application.scss」を作成し、次の１行を追記してください。

```rb:./app/javascript/stylesheets/application.scss
@import "~bootstrap/scss/bootstrap";
```

#### app/javascript/packs/application.js

「app/javascript/packs/application.js」内に次の2行を追記。

```javascript:./app/javascript/packs/application.js
import "bootstrap";
import "../stylesheets/application";
```

これで Bootstrap の設定は完了です。

### Font Awesome を導入

各種アイコンを使うために Font Awesome を導入します。

```
$ docker-compose run web yarn add @fortawesome/fontawesome-free
```

```js:app/javascript/packs/application.js
// 追記
import "@fortawesome/fontawesome-free/js/all";
```

これだけで OK です。

## 本実装

準備ができたので、本格的な実装に入りましょう。

### 各種モデルを作成

- 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'
```

```
$ docker-compose build
```

```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 |

```
$ docker-compose run web rails g model Post title:string body:text main_category_id:integer sub_category_id:integer
```

```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
```

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

```
$ docker-compose run web rails g controller posts
```

```rb:./app/controllers/posts_controller.rb
class PostsController < ApplicationController
  # Bootstrap に対応したフラッシュメッセージを表示するための設定
  add_flash_types :success, :info, :warning, :danger
  before_action :set_post, only: %i[edit update]

  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, success: "投稿の作成に成功しました"
    else
      render :new, danger: "投稿の作成に失敗しました"
    end
  end

  def edit; end

  def update
    if @post.update!(post_params)
      redirect_to posts_path, success: "投稿の更新に成功しました"
    else
      render :new, danger: "投稿の更新に失敗しました"
    end
  end

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

    def set_post
      @post = Post.find(params[:id])
    end
end
```

### ビューを作成

```
$ touch app/views/posts/index.html.slim app/views/posts/new.html.slim app/views/posts/edit.html.slim app/views/posts/_form.html.slim
```

```slim:./app/views/posts/index.html.slim
- flash.each do |type, message|
  div class="alert alert-#{type} alert-dismissible rounded-0 fade show" role="alert" 
    button.btn-close aria-label="Close" data-bs-dismiss="alert" type="button" 
    h5.m-0
      = message

.container.p-5
  .d-flex.mb-2
    .ms-auto
      = link_to new_post_path, class: "btn btn-sm btn-outline-primary me-2" do
        i.fas.fa-circle-plus.me-1
        | 新規作成

  .table-responsive
    table.table
      thead
        tr.table-dark style="white-space: nowrap;"
          th
          th scope="col" ID
          th scope="col" タイトル
          th scope="col" 本文
          th scope="col" メインカテゴリー
          th scope="col" サブカテゴリー
      tbody
        - @posts&.each do |post|
          tr style="white-space: nowrap;"
            td
              = link_to edit_post_path(post), class: "btn btn-sm btn-outline-success me-2" do
                i.fas.fa-edit.me-1
                | 編集
            td = post.id
            td = post.title
            td = post.body
            td = post.main_category.name
            td = post.sub_category.name
```

```slim:./app/views/posts/new.html.slim
.container.p-5
  .row
    .d-flex.align-items-center.justify-content-center
      .col-6
        = render "form", post: @post
```

```slim:./app/views/posts/edit.html.slim
.container.p-5
  .row
    .d-flex.align-items-center.justify-content-center
      .col-6
        = render "form", post: @post
```

```slim:./app/views/posts/_form.html.slim
.table-responsive
  = form_with model: post, local: true do |f|
    table.table.table-borderless
      tr
        td = f.label :title, class: "control-label"
        td = f.text_field :title, class: "form-control"
      
      tr
        td = f.label :body, class: "control-label"
        td = f.text_area :body, rows: 3, class: "form-control"

      tr
        td = f.label :main_category_id, class: "control-label"
        td = f.collection_select :main_category_id, MainCategory.all, :id, :name, { include_blank: true }, class: "form-select", id: "mainCategorySelect"
      
      tr
        td = f.label :sub_category_id, class: "control-label"
        td
          = f.collection_select :sub_category_id, @post.main_category&.sub_categories || [], :id, :name, { include_blank: true }, class: "form-select", id: "subCategorySelect", disabled: @post.main_category&.sub_categories.blank?
          - MainCategory.all.each do |main_category|
            template.div id="mainCategory#{main_category.id}"
              = f.collection_select :sub_category_id, main_category.sub_categories, :id, :name, { include_blank: true, selected: @post.sub_category_id }, class: "form-select"
    
    .d-flex.align-items-center.justify-content-center
      = f.submit class: "btn btn-success"

javascript:
  var mainCategorySelect = document.getElementById("mainCategorySelect");

  // メインカテゴリーの選択をトリガーに実行
  mainCategorySelect.addEventListener("change", function(){
    // 入力された値（メインカテゴリーID）を取得し、それに対応したtemplate要素を決定 
    var selectedMainCategoryValue = mainCategorySelect.value;
    var template = document.getElementById(`mainCategory${selectedMainCategoryValue}`);
    
    var subCategorySelect = document.getElementById("subCategorySelect");

    // メインカテゴリーが選択された場合、サブカテゴリーのセレクトボックスを有効化して各種template要素にすり替え
    if (selectedMainCategoryValue !== "") {
      subCategorySelect.disabled = false;
      subCategorySelect.innerHTML = template.innerHTML;
    } else {
      subCategorySelect.disabled = true;
      subCategorySelect.value = "";
    }
  });
```

ポイントは `template` です。

参照記事: [<template>: コンテンツテンプレート要素](https://developer.mozilla.org/ja/docs/Web/HTML/Element/template)

> `<template>` は HTML の要素で、ページが読み込まれたときにすぐにレンダリングされるのではなく、実行時に JavaScript を使って後からインスタンス化することができる HTML を保持するためのメカニズムです。

この `template` の性質を利用して予めメインカテゴリーに紐付いたサブカテゴリーのみが選択肢となったセレクトボックスを隠し持っておき、メインカテゴリーの選択をトリガーとしてそれぞれ対応したものを表示させるという流れになっています。

![スクリーンショット 2022-02-16 3.49.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/2b351797-3b34-446b-3a8b-10d1a66586e1.png)

### ルーティングを設定

```rb:./config/routes.rb
Rails.application.routes.draw do
  resources :posts, except: %i[show destroy]
end
```

### 日本語化

最後に「config/application.rb」内に以下を内容を記述し、デフォルトのタイムゾーンや言語を日本に変更してください。

```rb:./config/application.rb
module Myapp
  class Application < Rails::Application
    # ...

    # 追記
    config.time_zone = "Tokyo"
    config.active_record.default_timezone = :local
    config.i18n.default_locale = :ja
    config.i18n.load_path += Dir[Rails.root.join("config", "locales", "**", "*.{rb,yml}").to_s]

    # ...
  end
end
```

また、「config/locals/」以下にja.ymlを作成します。

```
$ touch config/locales/ja.yml
```

```yml:./config/locales/ja.yml
ja:
  activerecord:
    models:
      post: 投稿
      main_category: メインカテゴリー
      sub_category: サブカテゴリー
    attributes:
      post:
        id: ID
        title: タイトル
        body: 本文
        main_category_id: メインカテゴリー
        sub_category_id: サブカテゴリー
        created_at: 登録日
        updated_at: 更新日

  helpers:
    submit:
      create: 登録する
      update: 更新する
      submit: 保存する
```

## 動作確認

![スクリーンショット 2022-02-16 3.59.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/257ffbfc-094d-4f3b-9167-9ecd28c7d16f.png)

[localhost:3000/posts/new](http://localhost:3000/posts/new) にアクセスしてこんな感じになっていれば完成です。

ちゃんと動作しているか確認してください。

## あとがき

以上、片方の選択によってもう片方のオプションの値が変化する動的なセレクトボックスを作ってみました。