LoginSignup
50
3

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

Last updated at Posted at 2023-12-02

この投稿は、

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

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

はじめに

DMM WEBCAMP でメンターをやらせていただいております。 @tomoaki-kimura です。
Rails7のリリースから随分経ちますが、Rails6の環境もまだまだあります。
今回は、前回の記事で作ったフォームを stimulusaxios で置き換えていきます。

環境

  • Ruby 3.1.2
  • Rails 6.1.4
  • yarn 1.22.18

作るもの

Image from Gyazo

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

準備

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

Stimulesの導入

Gemfile

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

Gemfile
gem 'stimulus-rails'

続いて、

shell
$ bundle install

を実行しましょう。

(略)
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.

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

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

shell
$ rails stimulus:install

を実行して下さい。

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.

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

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

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

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

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

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.debugtrue にしましょう。
( ※このモードは切り替えなくても普通にconsole.log等は使えますのでやる必要はありません。)

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 に読み込みます。

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()

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

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のコンパイル作業がうまくいくか確認する為です。

一旦起動しましょう。

shell
$ rails s

Image from Gyazo

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

また、application.debugtrue にした結果が

Image from Gyazo

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

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

StimulusのController

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

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

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 というファイルを作ります。

shell
$ rails generate stimulus select_form
Running via Spring preloader in process 73518
      create  app/javascript/controllers/select_form_controller.js
       rails  stimulus:manifest:update

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

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

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 の記述が増えています。

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の手前まで動作させてみます。
以下のようにコントローラーを書き換えて下さい。

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 の値をループで1件ずつ取得し、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 を編集しましょう。

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

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

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 のコントローラーと接続する事ができます。

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

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

    地域区分:
    <%= 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 としてコントローラー側の

static targets = []

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

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

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

change->select-form#changeArea
//イベント種類->コントローラー名#関数名

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

  changeArea(e) {
    e.currentTarget
  }

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

パーシャルの削除

次にAjax用に切り出していた _select_city.html.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への変更が出来ました。

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

shell
$ rails s

で起動して、

Image from Gyazo

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

Axiosの導入

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

axios を使いますので、

shell
$ yarn add axios

を実行して下さい。

(略)
[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して使いますので、コントローラーを以下に置き換えて下さい。

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 部分。

import axios from "axios"

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

  //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
}

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

次に、イベントの続き

  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_citiesjs モードで投げられているため、何もしなければjs.erbのファイルを読みに行こうとしますが、DOM操作は必要ないため、data に値を渡す工程に変更が必要です。

Railsのコントローラー

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

現状 posts_controller.rb は以下のようですが、

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を渡すように変更していきましょう。
ついでに、コントローラー側のコードも重複してくるので、同様な処理をメソッドに切り出します。

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操作が書いてありますが、
不要になりますので、放置でも構いませんが、ファイルごと削除しておきましょう。

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

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

shell
$ rails s

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

Image from Gyazo

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

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

おわりに

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

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

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

引き続き DMM WEBCAMP Advent Calendar 2023 をよろしくお願いいたします。

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

初学者向けdocker基礎 となっています。

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

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

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

50
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
50
3