52
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DMM WEBCAMPAdvent Calendar 2023

Day 2

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

Last updated at Posted at 2023-12-01

この投稿は、

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

1日目は @yuki82511988 さんで
【Ruby】+ と<<で配列の要素を追加した時の話でした。
Rubyは詳しければ詳しい程幸せになれますね。

はじめに

DMM WEBCAMP でメンターをやらせていただいております。 @tomoaki-kimura です。
Rails7のリリースから随分経ちますが、Rails6の環境もまだまだあります。
ということで、今年の寄稿はRails6で改めて動的なフォームの処理に触れてみたいと思います。

環境

  • Ruby 3.1.2
  • Rails 6.1.4
  • yarn 1.22.18

作るもの

Image from Gyazo

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

準備

今回は、政令指定都市に関して以下のようなデーターを作って、入力フォームを作成していきます。

地方区分(areas)

id name
1 北海道
2 東北
3 関東
4 東海
5 近畿
6 中国
7 四国
8 九州

都道府県(prefectures)

id area_id name
1 1 北海道
2 2 宮城県
3 3 神奈川県
4 3 埼玉県
5 3 千葉県
6 4 新潟県
7 4 愛知県
8 4 静岡県
9 5 大阪府
10 5 兵庫県
11 5 京都府
12 6 岡山県
13 6 広島県
14 8 福岡県
15 8 熊本県

政令指定都市(city)

id prefecture_id name
1 1 札幌市
2 2 仙台市
3 3 横浜市
4 3 川崎市
5 3 相模原市
6 4 さいたま市
7 5 千葉市
8 6 新潟市
9 7 名古屋市
10 8 静岡市
11 8 浜松市
12 9 大阪市
13 9 堺市
14 10 神戸市
15 11 京都市
16 12 岡山市
17 13 広島市
18 14 北九州市
19 14 福岡市
20 15 熊本市

プロジェクトの作成

まずはプロジェクトを作成します。 DBもSQLiteを使います。

(6.1.4 でバージョン指定していますが、6系であれば表記や動作の違い等の問題は起こらないと思われます。)

下記コマンドを実行して下さい。

shell
$ gem install rails -v 6.1.4
$ rails _6.1.4_ new form_test
長いので略
├─ select-hose@2.0.0
├─ selfsigned@1.10.14
├─ serve-index@1.9.1
├─ serve-static@1.15.0
├─ signal-exit@3.0.7
├─ sockjs-client@1.6.1
├─ sockjs@0.3.24
├─ spdy-transport@3.0.0
├─ spdy@4.0.2
├─ strip-eof@1.0.0
├─ thunky@1.1.0
├─ toidentifier@1.0.1
├─ url-parse@1.5.10
├─ utils-merge@1.0.1
├─ uuid@3.4.0
├─ wbuf@1.7.3
├─ webpack-dev-middleware@3.7.3
├─ webpack-dev-server@3.11.3
├─ websocket-driver@0.7.4
├─ websocket-extensions@0.1.4
└─ ws@6.2.2
✨  Done in 4.00s.
Webpacker successfully installed 🎉 🍰

プロジェクトが作成後

shell
$ cd form_test

でディレクトリを移動しておきましょう。

モデルの作成

では、早速今回必要なモデルを作ってしまいましょう。下記コマンドで作成します。

shell
$ rails g model area name
$ rails g model prefecture area:references name
$ rails g model city prefecture:references name  
 $ rails g model area name
Running via Spring preloader in process 13883
      invoke  active_record
      create    db/migrate/20231129134732_create_areas.rb
      create    app/models/area.rb
      invoke    test_unit
      create      test/models/area_test.rb
      create      test/fixtures/areas.yml
 $ rails g model prefecture area:references name
Running via Spring preloader in process 14050
      invoke  active_record
      create    db/migrate/20231129134738_create_prefectures.rb
      create    app/models/prefecture.rb
      invoke    test_unit
      create      test/models/prefecture_test.rb
      create      test/fixtures/prefectures.yml
 $ rails g model city prefecture:references name
Running via Spring preloader in process 14232
      invoke  active_record
      create    db/migrate/20231129134744_create_cities.rb
      create    app/models/city.rb
      invoke    test_unit
      create      test/models/city_test.rb
      create      test/fixtures/cities.yml

モデルが作成出来たら、

shell
$ rails db:migrate

をしておきましょう。

Running via Spring preloader in process 14408
== 20231129134732 CreateAreas: migrating ======================================
-- create_table(:areas)
   -> 0.0010s
== 20231129134732 CreateAreas: migrated (0.0010s) =============================

== 20231129134738 CreatePrefectures: migrating ================================
-- create_table(:prefectures)
   -> 0.0015s
== 20231129134738 CreatePrefectures: migrated (0.0016s) =======================

== 20231129134744 CreateCities: migrating =====================================
-- create_table(:cities)
   -> 0.0012s
== 20231129134744 CreateCities: migrated (0.0012s) ============================

続けて、アソシエーションを書いておきます。

app/models/area.rb
class Area < ApplicationRecord
  has_many :prefectures #追記
end
app/models/prefecture.rb
class Prefecture < ApplicationRecord
  belongs_to :area
  has_many :cities #追記
end

データーの作成

今回は決まったデーターを扱いますので、seedを作成しておきます。
下記のコードを seeds.rb に記述しましょう。

db/seeds.rb
data = { '北海道': { '北海道': %w(札幌市) },
         '東北': { '宮城県': %w(仙台市) } ,
         '関東': { '神奈川県': %w(横浜市 川崎市 相模原市),
                   '埼玉県': %w(さいたま市),
                   '千葉県': %w(千葉市), },
         '東海': { '新潟県': %w(新潟市),
                   '愛知県': %w(名古屋市),
                   '静岡県': %w(静岡市 浜松市) },
         '近畿': { '大阪府': %w(大阪市 堺市),
                   '兵庫県': %w(神戸市),
                   '京都府': %w(京都市) },
         '中国': { '岡山県': %w(岡山市),
                   '広島県': %w(広島市) },
         '四国': {},
         '九州': { '福岡県': %w(福岡市 北九州市),
                   '熊本県': %w(熊本市) } }
data.each do |area_name, v|
  area = Area.create!(name: area_name)
  v.each do |prefecture_name, city_names|
    prefecture = area.prefectures.create!(name: prefecture_name)
    city_names.each do |city_name|
      prefecture.cities.create!(name: city_name)
    end
  end
end

書けたらデーターを流し込みます。

shell
$ rails db:seed

エラーが出なければOKです。

$ rails db:seed              
Running via Spring preloader in process 15839

コントローラー

コントローラーに入ります。下記コマンドを実行します。

shell
$ rails g controller Posts new 

今回特に何か実際に投稿する訳ではないので、一旦 new のみテンプレートを作成ました。

では、次にコントローラーの中身を見てみましょう。

app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def new
  end
end

new アクションのみ存在しますので、search_cities を追加します。

また、必要な処理も書いておきましょう。

app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def new
    @areas = Area.all
    prefectures = Prefecture.all
    #prefecturesの値を、data-jsonに渡すために必要な項目だけを配列要素にする。
    @prefectures_json = prefectures.map{|o| o.attributes.symbolize_keys.select{|k,v| k.match(/(id|_id|name)\z/) } }
    #Prefectureの絞り込み パラメーターがないと空配列にしかならない。post押下後に
値を渡す。
    @prefectures = prefectures.where(area_id: params[:area_id])
    #Cityの絞り込み。パラメーターがないと空配列にしかならない。post押下後に値を渡す。
    @cities = City.where(prefecture_id: params[:prefecture_id])
  end

  def search_cities
    @cities = City.where(prefecture_id: params[:prefecture_id])
  end
end

@prefecture_json でモデルのデーターを加工していますが、下記コードの結果は

Prefecture.all.map{|o| o.attributes.symbolize_keys.select{|k,v| k.match(/(id|_id|name)\z/) } }

以下のような、Hashを要素とする配列となります。 json 型にはしていません。

処理としては、複数要素のモデルオブジェクトを配列化し、

rails console
irb(main):001:0> Prefecture.all.map{|o|o}
   (0.3ms)  SELECT sqlite_version(*)
  Prefecture Load (0.1ms)  SELECT "prefectures".* FROM "prefectures"            
=>     
[#<Prefecture:0x000000010a914f78                                  
  id: 1,                                                          
  area_id: 1,                                                     
  name: "北海道",                                                  
  created_at: Wed, 29 Nov 2023 13:53:33.681664000 UTC +00:00,     
  updated_at: Wed, 29 Nov 2023 13:53:33.681664000 UTC +00:00>,    
 #<Prefecture:0x000000010a946730                                  
  id: 2,                                                          
  area_id: 2,           
  name: "宮城県",   
  (略)

その中身を attributes.symbolize_key でキーをシンボルとするHash変換し、

rails console
irb(main):002:0> Prefecture.all.map{|o|o.attributes.symbolize_keys}
  Prefecture Load (0.2ms)  SELECT "prefectures".* FROM "prefectures"
=>              
[{:id=>1,                                                         
  :area_id=>1,
  :name=>"北海道",
  :created_at=>Wed, 29 Nov 2023 13:53:33.681664000 UTC +00:00,
  :updated_at=>Wed, 29 Nov 2023 13:53:33.681664000 UTC +00:00},     
 {:id=>2,         
  :area_id=>2,
  (略)

さらに、 select で必要なデーターのみ抽出するために、キーの末尾\zid または、_id または name と付いた条件をマッチさせています。

rails console
irb(main):003:0> Prefecture.all.map{|o| o.attributes.symbolize_keys.select{|k,v|
 k.match(/(id|_id|name)\z/) } }
  Prefecture Load (0.2ms)  SELECT "prefectures".* FROM "prefectures"
=> 
[{:id=>1, :area_id=>1, :name=>"北海道"},
 {:id=>2, :area_id=>2, :name=>"宮城県"},
 {:id=>3, :area_id=>3, :name=>"神奈川県"},
 {:id=>4, :area_id=>3, :name=>"埼玉県"},
 {:id=>5, :area_id=>3, :name=>"千葉県"},
 {:id=>6, :area_id=>4, :name=>"新潟県"},
 {:id=>7, :area_id=>4, :name=>"愛知県"},
 {:id=>8, :area_id=>4, :name=>"静岡県"},
 {:id=>9, :area_id=>5, :name=>"大阪府"},
 {:id=>10, :area_id=>5, :name=>"兵庫県"},
 {:id=>11, :area_id=>5, :name=>"京都府"},
 {:id=>12, :area_id=>6, :name=>"岡山県"},
 {:id=>13, :area_id=>6, :name=>"広島県"},
 {:id=>14, :area_id=>8, :name=>"福岡県"},
 {:id=>15, :area_id=>8, :name=>"熊本県"}]

これによって、絞り込みに area_id と、 option タグに必要な、 idname も取り出せました。

ルーティング

続いてルーティングです。 routes.rb を開きましょう。

config/routes.rb
Rails.application.routes.draw do
  get 'posts/new'
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end 

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

config/routes.rb
Rails.application.routes.draw do
  root 'posts#new'
  post 'posts/search_cities'
end 

一応確認しておきます。

shell
$ rails routes -c posts
             Prefix Verb URI Pattern                    Controller#Action
               root GET  /                              posts#new
posts_search_cities POST /posts/search_cities(.:format) posts#search_cities

良さそうです。ここまでが下準備となります。

ビュー

今度はViewsです

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

app/views/posts/new.html.erb
<h1>Posts#new</h1>
<p>Find me in app/views/posts/new.html.erb</p>

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

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

次に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" } %>

ここで、各フォームの違いを解説しておきたいと思います。

最初に選択する Area フォームですが、 collection_select を使って、コントローラーから Area の全件をインスタンス変数 @areas を入れておきます。

<%= f.collection_select :area_id, @areas, :id, :name, {}, { id: "areaSelect" } %>

Area の選択を受けて変更される @prefectures の内容は、 初期値ではコントローラーからモデルを渡されていますが、選択肢の内容はJSで変化させます。 data-json の中に @prefecture_json として全件入れておき、ここからデーターを絞り込みます。

<%= f.collection_select :prefecture_id, @prefectures, :id, :name, {}, { id: "prefectureSelect", data: { json: @prefectures_json } } %>

@prefecture_json は値が変更されるものではないので、コントローラー側で作成していますが、このやりとりのメリットとして、 to_json 等の変換作業を行わなくて良いので、エスケープ文字の処理を考える必要がない点です。
Image from Gyazo

最後の City ですが、こちらはAjaxで内容を変更しますので、 prefecture_id を使って絞り込んだデーターを cities: @cities として渡し、js.erbファイルからレンダリングを行います。

<%= collection_select, "", :city_id, cities, :id, :name, {}, { id: "citySelect" } %>

f.collecton_select を使っていない理由は、Ajax時に f の値を渡すのが面倒な為です。

ここまでで、フォームの準備は完了です。

JQueryの導入

ここからは、JQueryでの実装となります。

以下のコマンドでJQueryをインストールしましょう。

shell
$ yarn add jquery
yarn add v1.22.18
[1/4] 🔍  Resolving packages...
⠂ jquery(node:24943) [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...
[4/4] 🔨  Building fresh packages...
success Saved lockfile.
success Saved 1 new dependency.
info Direct dependencies
└─ jquery@3.7.1
info All dependencies
└─ jquery@3.7.1
✨  Done in 1.03s.

次にライブラリの呼び出しをします。

ProvidePluginの設定

config/webpack/environment.js ファイル内に、

config/webpack/environment.js
const { environment } = require('@rails/webpacker')

module.exports = environment

グローバルに使う変数を追記しておきましょう。

config/webpack/environment.js
const { environment } = require('@rails/webpacker')

const webpack = require('webpack')
environment.plugins.prepend('Provide',
	new webpack.ProvidePlugin({
		$: 'jquery/src/jquery',
		jQuery: 'jquery/src/jquery'
	})
)
module.exports = environment

ライブラリのimport

ファイルのインポートもやっておきましょう。

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"

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"

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

JQueryを使ったAjax

上記で追加した ./select_form のファイルは存在しませんので作っておきます。

以下のファイルを新規作成して下さい。

app/javascript/packs/select_form.js
$(document).on('turbolinks:load', () => {
    //セレクトボックスの中身を空にする関数
    const initSelect = ($target) => {
        //セレクトボックスの中身を空にする
        $target.empty()
        //非選択時のオプションタグを追加する
        $target.append(`<option>-----選択して下さい-----</option>`)
    }
    //セレクトボックスの中身を第二引数 data の要素を持った選択肢にする
    const setSelect = ($target, data) => {
        //引数 data の値をループで1件ずつ取得し、o として取り出す
        $.each(data, (i, o) => {
            //対象となるセレクトボックス($target)に対してオプションタグを追加
            $target.append(`<option value="${o.id}">${o.name}</option>`)
        })
    }

    //都道府県フォームからJSONデーターを取得
    // `data('json')`はjsで書くと`dataset.json`で、DOMの`data-json`の値を取る事ができる
    const prefecturesData = $('#prefectureSelect').data('json')

    //都道府県データーを地域区分IDから絞り込む関数
    const setPrefectureOptions = (areaId) => {
        //地域区分IDから絞り込みを行い、変数 result に代入
        const result = prefecturesData.filter(o => o.area_id == areaId)
        initSelect($('#prefectureSelect'))
        setSelect($('#prefectureSelect'), result)
    }

    //地域区分の値に変化があれば実行するイベント
    $('#areaSelect').on('change', (e) => {
        //イベントの戻り地 `e` から選択された値を取得し、変数areaIDに代入
        const areaId = e.target.value
        setPrefectureOptions(areaId)
        initSelect($('#citySelect'))
    })

    //都道府県の値に変化があれば実行するイベント
    $('#prefectureSelect').on('change', (e) => {
        const prefectureId = e.target.value
        //JQueryのajaxを使ってリクエストを行う dataプロパティに渡したい値を格納する
        $.ajax('posts/search_cities', {
          type: 'post',
          data: { prefecture_id: prefectureId }
        })
    })
})

ここでフォームの制御を行いますが、関数があまり長くならないように、役割で分割していくと分かりやすいです。
概ねの役割をコメントにしているので参考にして下さい。

JQuery を使うメリットとして、こういった単純な処理の記述の短さや、 $.ajax で簡単に非同期の POST が行える点です。

JQueryを使わない環境では、axos 等を使う事になります。

レンダリング

では、最後に $.ajax で投げられたリクエストを捌く posts/search_cities.js.erb を新規作成しておきます。

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

動作確認

では確認していきましょう。

shell
$ rails s

でサーバーを起動して、トップページを確認してみましょう。

もしこのようなエラーとなった場合、一旦サーバーを落として下さい。

Image from Gyazo

ActionView::Template::Error (Webpacker can't find application.js in /Users/kimuratomoaki/Desktop/form_test/public/packs/manifest.json. Possible causes:
1. You want to set webpacker.yml value of compile to true for your environment
   unless you are using the `webpack -w` or the webpack-dev-server.
2. webpack has not yet re-run to reflect updates.
3. You have misconfigured Webpacker's config/webpacker.yml file.
4. Your webpack configuration is not creating a manifest.
Your manifest contains:
{
}
):
     7:     <%= csp_meta_tag %>
     8: 
     9:     <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    10:     <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
    11:   </head>
    12: 
    13:   <body>
  
app/views/layouts/application.html.erb:10

こちらは、 babel.config.js で読まれているライブラリーをインストールする事で解決します。

(babel v7.22.0によるエラーのようです。)

解決には以下のコマンドを実行して下さい。

shell
$ yarn add @babel/plugin-proposal-private-methods @babel/plugin-proposal-private-property-in-object
yarn add v1.22.18
[1/4] 🔍  Resolving packages...
⠂ @babel/plugin-proposal-private-methods(node:25953) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
warning @babel/plugin-proposal-private-methods@7.18.6: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead.
warning @babel/plugin-proposal-private-property-in-object@7.21.11: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-property-in-object instead.
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
warning "@babel/plugin-proposal-private-methods > @babel/helper-create-class-features-plugin@7.23.5" has unmet peer dependency "@babel/core@^7.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".
warning " > @babel/plugin-proposal-private-methods@7.18.6" has unmet peer dependency "@babel/core@^7.0.0-0".
warning " > @babel/plugin-proposal-private-property-in-object@7.21.11" has unmet peer dependency "@babel/core@^7.0.0-0".
[4/4] 🔨  Building fresh packages...
success Saved lockfile.
success Saved 2 new dependencies.
info Direct dependencies
├─ @babel/plugin-proposal-private-methods@7.18.6
└─ @babel/plugin-proposal-private-property-in-object@7.21.11
info All dependencies
├─ @babel/plugin-proposal-private-methods@7.18.6
└─ @babel/plugin-proposal-private-property-in-object@7.21.11
✨  Done in 3.71s.

インストール出来たら確認してみましょう。

shell
$ rails s

再度起動して、トップページを確認してみましょう。

Image from Gyazo

都道府県の選択時にのみAjaxが発動しますので、パラメーターが変化しているのが分かると思います。

これで完成です。

お疲れ様でした。

おわりに

ちょっと駆け足でしたが、 data-xxxx の使い方と、 Ajax の使い方を見ていきました。
いかがだったでしょうか?

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

次回 3日目の投稿も、私 @tomoaki-kimura で、

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

となっています。

当記事の続きモノで、 JQuery部分をStimulusに置き換えた実装に変更していきます。

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

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

52
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
52
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?