この投稿は、
DMM WEBCAMP Advent Calendar 2023
シリーズ2 投稿3日目のエントリーです。
2日目も私 @tomoaki-kimura で
Rails6 で動的なセレクトボックスを作りたい(JQuery編)でした。
条件を絞り込みながら入力できる動的なフォームを作りました。
はじめに
DMM WEBCAMP でメンターをやらせていただいております。 @tomoaki-kimura です。
Rails7のリリースから随分経ちますが、Rails6の環境もまだまだあります。
今回は、前回の記事で作ったフォームを stimulus と axios で置き換えていきます。
環境
- Ruby 3.1.2
- Rails 6.1.4
- yarn 1.22.18
作るもの
- 動的に選択肢の内容が変化するフォーム。
- 地方区分を選ぶと都道府県の選択肢が変わり、都道府県を選択すると政令指定都市の選択肢が変化。
- 地方区分を選択しない場合、都道府県及び、政令指定都市の選択は行えない。
- 都道府県は、
dataset
を用いてデーターをdata-json
から参照。 - 政令指定都市は、
axios
を用いてデーターをDBから参照。
準備
内容的には前回の続きとなりますので、前回の内容が出来ている状態から始めましょう。
Stimulesの導入
Gemfile
Gemfileに以下のgemを追加して下さい。
gem 'stimulus-rails'
続いて、
$ 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.
インストール出来ました。
続いて必要ファイルを作成するコマンド
$ 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.
インストール出来ました。
そして、流れたログの下記部分
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
こちらも見ていきます。
生成されたファイルの確認
ファイルの確認ですが、
この3つのファイルと、外包する controllers/
が作成されている事を確認しましょう。
そのファイル中で一つ、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等は使えますのでやる必要はありません。)
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
に読み込みます。
// 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()
これを以下のように編集して下さい。
// 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のコンパイル作業がうまくいくか確認する為です。
一旦起動しましょう。
$ rails s
動かなくなっていると正常です。
また、application.debug
を true
にした結果が
こちらのログとなります。(falseだと表示されない。console.logはどちらにせよ使える。)
これで stimulus が動いているのも確認できましたので、実装に入れます。
StimulusのController
これからコントローラーを作成しますが、先にまだ見ていないファイルを確認します。
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
というファイルを作ります。
$ 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
を確認します。
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="select-form"
export default class extends Controller {
connect() {
}
}
また、 index.js
にもコントローラーの import
の記述が増えています。
// 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の手前まで動作させてみます。
以下のようにコントローラーを書き換えて下さい。
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
を編集しましょう。
<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 %>
現状このようになっていますので、下記のように書き換えます。
<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側で決めた click
や change
等のイベントに対して動かしたいコントローラー名を登録しておきます。
change->select-form#changeArea
//イベント種類->コントローラー名#関数名
の場合ですと、
該当のフォームに変更が加わった時点で、 select_form
コントローラーの中の changeArea
関数を実行する事ができます。
勿論このイベントはJSのイベントハンドラーのように、引数から該当するDOMは取得する事が出来ます。
changeArea(e) {
e.currentTarget
}
また、 select_form
のように_
が付いたコントローラー名称は、構文上 select-form
のように -
に変更されています。
ファイル名がキャメルケースであればそのままです。
パーシャルの削除
次にAjax用に切り出していた _select_city.html.erb
は不要になります。
<%= collection_select '', :city_id, cities, :id, :name, { include_blank: "-----選択して下さい-----", selected: params[:city_id] }, { id: "citySelect" } %>
こちらはすでに呼び出されていませんので、放置していても構いませんが、削除しておく方が良いでしょう。
ここまでで、JQueryからStimulusへの変更が出来ました。
一旦確認しておきましょう。
$ rails s
で起動して、
都道府県までが動くようになっていて、console に id が取得できていればOKです。
Axiosの導入
ここから JQuery
を使わない Ajax
の実装を行います。
axios
を使いますので、
$ 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して使いますので、コントローラーを以下に置き換えて下さい。
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_cities
に js
モードで投げられているため、何もしなければjs.erb
のファイルを読みに行こうとしますが、DOM操作は必要ないため、data
に値を渡す工程に変更が必要です。
Railsのコントローラー
今回、コントローラー側の処理は、js.erb
を読みにいかせずに、 Axiosに渡すJSONデーターを作って直接渡すようになります。
現状 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を渡すように変更していきましょう。
ついでに、コントローラー側のコードも重複してくるので、同様な処理をメソッドに切り出します。
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操作が書いてありますが、
不要になりますので、放置でも構いませんが、ファイルごと削除しておきましょう。
$('#cities').html('<%=j render 'posts/select_city', cities: @cities %>')
では、最終確認してみましょう。
$ rails s
トップページを確認してみましょう。
JQuery版と同じ動作が出来ました。
これで完成です。お疲れ様でした。
おわりに
今回はRails6ではあまり馴染みのない stimulus
を使ってみました。
いかがだったでしょうか?
VueやReactまで必要ないけど、ちょっとJQueryじゃカオス過ぎるよね。という場合にかなり使えます。
引き続き DMM WEBCAMP Advent Calendar 2023 をよろしくお願いいたします。
次回 4日目の投稿は、@Fkinds さんで
初学者向けdocker基礎 となっています。
Dockerの入りとして、すごく有り難い記事でした。
スレッドの購読やいいねも是非お願いします。
ここまで読んでいただきありがとうございます。