Rails6 で動的なセレクトボックスを作りたい（Stimulus x Axios編）

## この投稿は、

DMM WEBCAMP Advent Calendar 2023
シリーズ2　投稿3日目のエントリーです。

2日目も私 @tomoaki-kimura で
[Rails6 で動的なセレクトボックスを作りたい（JQuery編）](https://qiita.com/tomoaki-kimura/private/dc10a4d6eb367f128e6e)でした。
条件を絞り込みながら入力できる動的なフォームを作りました。

## はじめに
DMM WEBCAMP でメンターをやらせていただいております。 @tomoaki-kimura です。
Rails7のリリースから随分経ちますが、Rails６の環境もまだまだあります。
今回は、前回の記事で作ったフォームを [stimulus](https://github.com/hotwired/stimulus-rails) と [axios](https://github.com/axios/axios) で置き換えていきます。

## 環境
- Ruby 3.1.2
- Rails 6.1.4
- yarn 1.22.18
## 作るもの

[![Image from Gyazo](https://i.gyazo.com/68f2fe8778990ddf75eed13579d57419.png)](https://gyazo.com/68f2fe8778990ddf75eed13579d57419)

- 動的に選択肢の内容が変化するフォーム。
- 地方区分を選ぶと都道府県の選択肢が変わり、都道府県を選択すると政令指定都市の選択肢が変化。
- 地方区分を選択しない場合、都道府県及び、政令指定都市の選択は行えない。
- 都道府県は、 `dataset` を用いてデーターを `data-json` から参照。
- 政令指定都市は、 `axios` を用いてデーターをDBから参照。

## 準備

### 内容的には前回の続きとなりますので、前回の内容が出来ている状態から始めましょう。

https://qiita.com/tomoaki-kimura/private/dc10a4d6eb367f128e6e

## Stimulesの導入

### Gemfile

Gemfileに以下のgemを追加して下さい。

```ruby:Gemfile
gem 'stimulus-rails'
```

続いて、

```bash:shell
$ bundle install
```

を実行しましょう。

```bash
（略）
Using actionmailbox 6.1.7.6
Using sass-rails 6.0.0
Using actiontext 6.1.7.6
Using webpacker 5.4.4
Fetching stimulus-rails 1.3.0
Using rails 6.1.7.6
Installing stimulus-rails 1.3.0
Bundle complete! 18 Gemfile dependencies, 82 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
```

インストール出来ました。

続いて必要ファイルを作成するコマンド

```bash:shell
$ rails stimulus:install
```

を実行して下さい。

```bash
Create controllers directory
      create  app/javascript/controllers
      create  app/javascript/controllers/index.js
      create  app/javascript/controllers/application.js
      create  app/javascript/controllers/hello_controller.js
Couldn't find "app/javascript/application.js".
You must import "./controllers" in your JavaScript entrypoint file
Install Stimulus
         run  yarn add @hotwired/stimulus from "."
yarn add v1.22.18
warning ../package.json: No license field
[1/4] 🔍  Resolving packages...
⠂ @hotwired/stimulus(node:43260) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
warning " > @babel/plugin-proposal-private-methods@7.18.6" has unmet peer dependency "@babel/core@^7.0.0-0".
warning "@babel/plugin-proposal-private-methods > @babel/helper-create-class-features-plugin@7.22.15" has unmet peer dependency "@babel/core@^7.0.0".
warning "@babel/plugin-proposal-private-methods > @babel/helper-create-class-features-plugin > @babel/helper-replace-supers@7.22.9" has unmet peer dependency "@babel/core@^7.0.0".
warning " > @babel/plugin-proposal-private-property-in-object@7.21.11" has unmet peer dependency "@babel/core@^7.0.0-0".
warning "@babel/plugin-proposal-private-property-in-object > @babel/plugin-syntax-private-property-in-object@7.14.5" has unmet peer dependency "@babel/core@^7.0.0-0".
[4/4] 🔨  Building fresh packages...
success Saved lockfile.
success Saved 1 new dependency.
info Direct dependencies
└─ @hotwired/stimulus@3.2.2
info All dependencies
└─ @hotwired/stimulus@3.2.2
✨  Done in 2.90s.

```

インストール出来ました。

そして、流れたログの下記部分

```bash:shell
Create controllers directory
      create  app/javascript/controllers
      create  app/javascript/controllers/index.js
      create  app/javascript/controllers/application.js
      create  app/javascript/controllers/hello_controller.js
Couldn't find "app/javascript/application.js".
You must import "./controllers" in your JavaScript entrypoint file
```

こちらも見ていきます。

### 生成されたファイルの確認

ファイルの確認ですが、

[![Image from Gyazo](https://i.gyazo.com/8e3411a3076a5393d37b9efc78bbf4f6.png)](https://gyazo.com/8e3411a3076a5393d37b9efc78bbf4f6)

この３つのファイルと、外包する `controllers/` が作成されている事を確認しましょう。

そのファイル中で一つ、`javascript/controllers/application.js` を開いて下さい。

```javascript:app/javascript/controllers/application.js
import { Application } from "@hotwired/stimulus"

const application = Application.start()

// Configure Stimulus development experience
application.debug = false
window.Stimulus   = application

export { application }
```

デバッグの表示を切り替えておきます。

`application.debug` を `true` にしましょう。
(　※このモードは切り替えなくても普通にconsole.log等は使えますのでやる必要はありません。)

```javascript:app/javascript/controllers/application.js
import { Application } from "@hotwired/stimulus"

const application = Application.start()

// Configure Stimulus development experience
application.debug = true //ここ変更
window.Stimulus   = application

export { application }
```

次に、これらのファイルを読み込む場所

`app/javascript/application.js`　が見当たらないと言われていますので、今回は、

`app/javascript/packs/application.js` に読み込みます。

```javascript:app/javascript/packs/application.js
// This file is automatically compiled by Webpack, along with any other files
// present in this directory. You're encouraged to place your actual application logic in
// a relevant structure within app/javascript and only use these pack files to reference
// that code so it'll be compiled.

import Rails from "@rails/ujs"
import Turbolinks from "turbolinks"
import * as ActiveStorage from "@rails/activestorage"
import "channels"
import "jquery"
import "./select_form"

Rails.start()
Turbolinks.start()
ActiveStorage.start()
```

これを以下のように編集して下さい。

```javascript:app/javascript/packs/application.js
// This file is automatically compiled by Webpack, along with any other files
// present in this directory. You're encouraged to place your actual application logic in
// a relevant structure within app/javascript and only use these pack files to reference
// that code so it'll be compiled.

import Rails from "@rails/ujs"
import Turbolinks from "turbolinks"
import * as ActiveStorage from "@rails/activestorage"
import "channels"
//import "jquery" //コメントアウト
//import "./select_form" //コメントアウト
import "controllers" //追記
Rails.start()
Turbolinks.start()
ActiveStorage.start()
```

これで新規追加された

`app/jabascript/controllers/` を見に行きます。

同時に、 `jquery` と `./select_form` をコメントアウトした理由は今webpackのコンパイル作業がうまくいくか確認する為です。

一旦起動しましょう。

```bash:shell
$ rails s
```

[![Image from Gyazo](https://i.gyazo.com/e89aaad9a73a2cc3d27a1b856452cc55.gif)](https://gyazo.com/e89aaad9a73a2cc3d27a1b856452cc55)

動かなくなっていると正常です。

また、`application.debug` を `true` にした結果が

[![Image from Gyazo](https://i.gyazo.com/259b42f376ffdf05b2d266eee989001b.png)](https://gyazo.com/259b42f376ffdf05b2d266eee989001b)

こちらのログとなります。（falseだと表示されない。console.logはどちらにせよ使える。）

これで stimulus が動いているのも確認できましたので、実装に入れます。

### StimulusのController

これからコントローラーを作成しますが、先にまだ見ていないファイルを確認します。

`javascript/controllers/index.js` ですが、以降に作成するコントローラーを読み込む場所となります。

```javascript:app/javascript/controllers/index.js
// This file is auto-generated by ./bin/rails stimulus:manifest:update
// Run that command whenever you add a new controller or create them with
// ./bin/rails generate stimulus controllerName

import { application } from "./application"

import HelloController from "./hello_controller"
application.register("hello", HelloController)
```

現状は、`hello_controller` が読まれていますが、こちらは特に使いませんので、放置しておきます。

では、新たにコントローラーを作りましょう。 `select_form` というファイルを作ります。

```bash:shell
$ rails generate stimulus select_form
```

```bash
Running via Spring preloader in process 73518
      create  app/javascript/controllers/select_form_controller.js
       rails  stimulus:manifest:update
```

コントローラーが追加されました。 

作成された `select_form_controller.js` を確認します。

```javascript:app/javascript/controllers/select_form_controller.js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="select-form"
export default class extends Controller {
  connect() {
  }
}
```

また、 `index.js`にもコントローラーの `import` の記述が増えています。

```javascript:app/javascript/controllers/index.js
// This file is auto-generated by ./bin/rails stimulus:manifest:update
// Run that command whenever you add a new controller or create them with
// ./bin/rails generate stimulus controllerName

import { application } from "./application"

import HelloController from "./hello_controller"
application.register("hello", HelloController)

import SelectFormController from "./select_form_controller"
application.register("select-form", SelectFormController)
```

### JQueryからの置き換え

先程のコントローラーを、元々実装しているJQueryコードを参考にAjaxの手前まで動作させてみます。
以下のようにコントローラーを書き換えて下さい。

```javascript:app/javascript/controllers/select_form_controller.js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="select-form"
export default class extends Controller {
  //View内で設定した `select_form_target` の値をここに渡す。
  static targets = [
    "areaSelect", "prefectureSelect", "citySelect"
  ]

  // このコントローラー内で使えるstateをセット出来る
  static values = {
    //都道府県のデーターを管理する。初期値は型を決める為に空配列にした。
    prefectures: []
  }

  //コントローラーがDOMに接続した時に行われる処理。初期値等の設定に使う。
  connect() {
    this.prefecturesData()
  }

  //selectの中身を消す関数
  initSelect(target) {
    //セレクトボックスの中身を空にする
    target.innerHTML = ""
    //非選択時のオプションタグを追加する
    const option = document.createElement('option');
    option.textContent = '-----選択して下さい-----'
    target.appendChild(option);
  }

  //selectの中身を作る関数
  setSelect(target, data) {
    //引数 data の値をループで１件ずつ取得し、o として取り出す
    data.forEach((o) => {
      const option = document.createElement('option');
      option.value = o.id;
      option.textContent = o.name;
      target.appendChild(option);
    })
  }

  //都道府県のデーターをstateに入れる関数
  prefecturesData() {
    //data-jsonからvaluesにparseしてオブジェクトに変換したデーターを登録する
    const prefectures = this.prefectureSelectTarget.dataset.json
    this.prefecturesValue = JSON.parse(prefectures)
  }

  //都道府県のoptionタグを入れ替える関数
  setPrefectureOptions(areaId) {
    //地域区分IDから絞り込みを行い、変数 result に代入
    const result = this.prefecturesValue.filter(o => o.area_id == areaId)
    this.initSelect(this.prefectureSelectTarget)
    this.setSelect(this.prefectureSelectTarget, result)
  }

  //地域区分の値に変化があれば実行するイベント
  changeArea(e) {
    //イベントの戻り地 `e` から選択された値を取得し、変数areaIDに代入
    const areaId = e.currentTarget.value
    this.setPrefectureOptions(areaId)
    this.initSelect(this.citySelectTarget)
  }

  //都道府県の値に変化があれば実行するイベント
  changePrefecture(e) {
    const prefectureId = e.currentTarget.value
    console.log(prefectureId)//都道府県のidをコンソールで確認出来るようにしている
    //ここからがAjax部分ですが、一旦ここまで。
  }
}
```

## Viewの編集

### Viewの変更点

今度はViewsを書き換えます。

`posts/new.html.erb` を編集しましょう。

```erb:app/views/posts/new.html.erb
<h1>Posts#new</h1>

<%= form_with url: root_path, local: true, method: :get do |f| %>
  地域区分：<%= f.collection_select :area_id, @areas, :id, :name, { include_blank: "-----選択して下さい-----", selected: params[:area_id] }, { id: "areaSelect" } %><br>
  都道府県：<%= f.collection_select :prefecture_id, @prefectures, :id, :name, { include_blank: "-----選択して下さい-----", selected: params[:prefecture_id] }, { id: "prefectureSelect", data: { json: @prefectures_json } } %><br>
  指定都市：<span id="cities"><%= render 'posts/select_city', cities: @cities %></span><br>
  <%= f.submit "POST" %>
<% end %>
```

現状このようになっていますので、下記のように書き換えます。

```erb:app/views/posts/new.html.erb
<h1>Posts#new</h1>

<div data-controller="select-form">
  <%= form_with url: root_path, local: true, method: :get do |f| %>
    地域区分：
    <%= f.collection_select :area_id, @areas, :id, :name,
                            { include_blank: "-----選択して下さい-----",
                              selected: params[:area_id] },
                            {
                              data: {
                                select_form_target: 'areaSelect',
                                action: "change->select-form#changeArea",
                              }
                            } %><br>
    都道府県：
    <%= f.collection_select :prefecture_id, @prefectures, :id, :name,
                            { include_blank: "-----選択して下さい-----",
                              selected: params[:prefecture_id] },
                            {
                              data: {
                                select_form_target: 'prefectureSelect',
                                action: "change->select-form#changePrefecture",
                                json: @prefectures_json
                              }
                            } %><br>
    指定都市：
    <%= f.collection_select :city_id, @cities, :id, :name,
                            { include_blank: "-----選択して下さい-----",
                              selected: params[:city_id] },
                            {
                              data: {
                                select_form_target: 'citySelect',
                              }
                            } %><br>
    <%= f.submit "POST" %>
  <% end %>
</div>
```

フォームを以下のタグで囲む事で stimulus のコントローラーと接続する事ができます。

```erb
<div data-controller="select-form"></div>
```

また、フォームに追加されている、 `data-xxxx` の部分ですが、

```erb
    地域区分：
    <%= f.collection_select :area_id, @areas, :id, :name,
                            { include_blank: "-----選択して下さい-----",
                              selected: params[:area_id] },
                            {
                              data: {
                                select_form_target: 'areaSelect',
                                action: "change->select-form#changeArea",
                              }
                            } %><br>
```

`select_form_target（コントローラー名_target）` は `id`等の代わりに取得したいDOMを設定します。なので、元々使っていた タグの`id` は削除しています。
代わりに、`areaSelect` としてコントローラー側の

```javascript
static targets = []
```

にDOMを登録する事ができます。

この`static targets`は、コントローラー側で、 `this.areaSelectTarget（コントローラー名Target）` として呼び出す事ができます。

`action` はView側で決めた `click` や `change` 等のイベントに対して動かしたいコントローラー名を登録しておきます。

```javascript
change->select-form#changeArea
//イベント種類->コントローラー名#関数名
```
の場合ですと、
該当のフォームに変更が加わった時点で、 `select_form` コントローラーの中の `changeArea` 関数を実行する事ができます。
勿論このイベントはJSのイベントハンドラーのように、引数から該当するDOMは取得する事が出来ます。

```javascript
  changeArea(e) {
    e.currentTarget
  }
```

また、 `select_form` のように`_` が付いたコントローラー名称は、構文上 `select-form` のように `-` に変更されています。
ファイル名がキャメルケースであればそのままです。

### パーシャルの削除

次にAjax用に切り出していた `_select_city.html.erb` は不要になります。

```erb:app/views/posts/_select_city.html.erb（削除）
<%= collection_select '', :city_id, cities, :id, :name, { include_blank: "-----選択して下さい-----", selected: params[:city_id] }, { id: "citySelect" } %>
```

こちらはすでに呼び出されていませんので、放置していても構いませんが、削除しておく方が良いでしょう。

ここまでで、JQueryからStimulusへの変更が出来ました。

一旦確認しておきましょう。

```bash:shell
$ rails s
```

で起動して、

[![Image from Gyazo](https://i.gyazo.com/255fb4f5f77779d3c11b78fc7a1fcc90.gif)](https://gyazo.com/255fb4f5f77779d3c11b78fc7a1fcc90)

都道府県までが動くようになっていて、console に id が取得できていればOKです。

## Axiosの導入

ここから `JQuery` を使わない `Ajax` の実装を行います。

`axios` を使いますので、

```bash:shell
$ yarn add axios
```

を実行して下さい。

```bash
（略）
[4/4] 🔨  Building fresh packages...
success Saved 1 new dependency.
info Direct dependencies
└─ axios@1.6.2
info All dependencies
└─ axios@1.6.2
✨  Done in 1.33s.
```

### StumulusのControllerへの追記

インストール出来たら `select_form_controller.js` を開きます。
直接 importして使いますので、コントローラーを以下に置き換えて下さい。

```javascript:app/javascript/controllers/select_form_controller.js
import { Controller } from "@hotwired/stimulus"
import axios from "axios" //ここ追加

// Connects to data-controller="select-form"
export default class extends Controller {
  static targets = [
    "areaSelect", "prefectureSelect", "citySelect"
  ]

  static values = {
    prefectures: []
  }

  connect() {
    this.prefecturesData()
  }

  initSelect(target) {
    target.innerHTML = ""
    const option = document.createElement('option');
    option.textContent = '-----選択して下さい-----'
    target.appendChild(option);
  }

  setSelect(target, data) {
    data.forEach((o) => {
      const option = document.createElement('option');
      option.value = o.id;
      option.textContent = o.name;
      target.appendChild(option);
    })
  }

  prefecturesData() {
    const prefectures = this.prefectureSelectTarget.dataset.json
    this.prefecturesValue = JSON.parse(prefectures)
  }

  setPrefectureOptions(areaId) {
    const result = this.prefecturesValue.filter(o => o.area_id == areaId)
    this.initSelect(this.prefectureSelectTarget)
    this.setSelect(this.prefectureSelectTarget, result)
  }

  changeArea(e) {
    const areaId = e.currentTarget.value
    this.setPrefectureOptions(areaId)
    this.initSelect(this.citySelectTarget)
  }

  changePrefecture(e) {
    const prefectureId = e.currentTarget.value
    const params =  { prefecture_id: prefectureId }
    
    //axiosを使ってリクエストを行う 変数paramsにプロパティに渡したい値を格納する
    this.newAxios().post('posts/search_cities', params)
      //コールバックでレスポンスを受け取る
      .then(response => {
        // dataプロパティにjs.erbで作ったJSONが入っているので取り出してresultに代入
        const result = response.data
        this.initSelect(this.citySelectTarget)
        this.setSelect(this.citySelectTarget, result)
      });
  }
  
  // csrf-tokenの設定が等を関数に切り出している。
  newAxios() {
    const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
    //csrf-token設定。ヘッダーから取り出したトークンをセット
    axios.defaults.headers.common['X-CSRF-Token'] = token
    //Ajaxで送信するようにjsモードで送る。`local:false`に当たる設定。
    axios.defaults.headers.common['Accept'] = 'application/js'
    return axios
  }
}

```

変更点ですが、まずは import 部分。

```javascript
import axios from "axios"
```

次に設定ですが、JQueryと違って、axios は　CSRFトークン等の設定が必要となるため、

```javascript
  //csrf-tokenの設定が等を関数に切り出している。
  newAxios() {
	const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
	//csrf-token設定。ヘッダーから取り出したトークンをセット
	axios.defaults.headers.common['X-CSRF-Token'] = token
	//Ajaxで送信するようにjsモードで送る。`local:false`に当たる設定。
	axios.defaults.headers.common['Accept'] = 'application/js'
	return axios
}
```

このように設定を関数に切り出しておきます。

次に、イベントの続き

```javascript
  changePrefecture(e) {
    const prefectureId = e.currentTarget.value
    const params =  { prefecture_id: prefectureId }
    
    //axiosを使ってリクエストを行う 変数paramsにプロパティに渡したい値を格納する
    this.newAxios().post('posts/search_cities', params)
      //コールバックでレスポンスを受け取る
      .then(response => {
        // dataプロパティにjs.erbで作ったJSONが入っているので取り出してresultに代入
        const result = response.data
        this.initSelect(this.citySelectTarget)
        this.setSelect(this.citySelectTarget, result)
      });
  }
```

DOM操作まで `stimulus` 側でやってしまうので、コールバックの `data` から `setSelect` の関数を実行します。

リクエストは、 `posts/search_cities` に `js` モードで投げられているため、何もしなければ`js.erb`のファイルを読みに行こうとしますが、DOM操作は必要ないため、`data` に値を渡す工程に変更が必要です。

## Railsのコントローラー

今回、コントローラー側の処理は、`ｊｓ．erb`を読みにいかせずに、 Axiosに渡すJSONデーターを作って直接渡すようになります。

現状 `posts_controller.rb` は以下のようですが、
```ruby:app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def new
    @areas = Area.all
    prefectures = Prefecture.all
    @prefectures_json = prefectures.map{|o| o.attributes.symbolize_keys.select{|k,v| k.match(/(id|_id|name)\z/) } }
    @prefectures = prefectures.where(area_id: params[:area_id])
    @cities = City.where(prefecture_id: params[:prefecture_id])
  end

  def search_cities
    @cities = City.where(prefecture_id: params[:prefecture_id])
  end
end
```
これを `render` を使って直接JSONを渡すように変更していきましょう。
ついでに、コントローラー側のコードも重複してくるので、同様な処理をメソッドに切り出します。

```ruby:app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def new
    @areas = Area.all
    prefectures = Prefecture.all
    @prefectures_json = selected_data(prefectures)
    @prefectures = prefectures.where(area_id: params[:area_id])
    @cities = City.where(prefecture_id: params[:prefecture_id])
  end

  def search_cities
    @cities = City.where(prefecture_id: params[:prefecture_id])
    #該当データーがあれば加工した値を返し、なければ空配列を返す
    @cities = selected_data(@cities) if @cities.any?
    #テンプレートは読みにいかず直接jsonを渡す。
    render json: @cities
  end
  
  private

  #同じ処理をまとめたい
  def selected_data(collection)
    collection.map{|o| o.attributes.symbolize_keys.select{|k,v| k.match(/(id|_id|name)\z/) } }
  end
end
```

このように書き換えてしまいましょう。
これで、テンプレートファイルを読まなくなります。

### js.erbの削除

ですので、`js.erb` の方も現状は、パーシャルを差し替えるDOM操作が書いてありますが、
不要になりますので、放置でも構いませんが、ファイルごと削除しておきましょう。

```erb:app/views/posts/search_cities.js.erb（削除）
$('#cities').html('<%=j render 'posts/select_city', cities: @cities %>')
```

では、最終確認してみましょう。

```bash:shell
$ rails s
```

トップページを確認してみましょう。

[![Image from Gyazo](https://i.gyazo.com/0c31a368b9450126e9ec741e7819985e.gif)](https://gyazo.com/0c31a368b9450126e9ec741e7819985e)

JQuery版と同じ動作が出来ました。

これで完成です。お疲れ様でした。

## おわりに

今回はRails6ではあまり馴染みのない `stimulus` を使ってみました。

いかがだったでしょうか？

VueやReactまで必要ないけど、ちょっとJQueryじゃカオス過ぎるよね。という場合にかなり使えます。

引き続き [DMM WEBCAMP Advent Calendar 2023](https://qiita.com/advent-calendar/2023/infratop) をよろしくお願いいたします。

次回 ４日目の投稿は、@Fkinds さんで

[初学者向けdocker基礎](https://qiita.com/Fkinds/items/33dcfff97481509a42bd)　となっています。

Dockerの入りとして、すごく有り難い記事でした。

スレッドの購読やいいねも是非お願いします。

ここまで読んでいただきありがとうございます。