表題通りです。RailsでAjax制御するには今まではjqueryを使ったりしてきました。Rails6になってからrails-ujsというライブラリが推奨され、それによってイベント発火して対応したのですが、このrails-ujsはRails7で非推奨となってしまいました。
代わりに実装された同期制御用のスクリプトturboですが、これはどうも不具合が多く、評判が今ひとつ芳しくない上にRansackとの相性もあまり良くありません。何よりテンプレートやコントローラーで色々書き換えが必要になるのが面倒です。そこで代わりの非同期通信手段を調べていたところ、rails/request.jsというものも提供されたみたいで、これを使ってAjax制御できるみたいです。
Githubの公式マニュアル
-
rails/request.js
Qiita内に触れている記事 - 【Rails7】Ajaxリクエスト時にCSRF tokenを含める(おそらく)公式のやり方
ですが、その具体的な実践方法がほとんど載っていなかった、特にransackと連携させた方法がどこにもなかったので、試行錯誤を重ねて実践してみたのがこの記事です。そして、この方法を使えばイベント用のスクリプトを差し替えるだけで、テンプレートとコントローラーを書き換えることなく、Ajax制御が実現できます。
使用環境
Webサーバ:Red Hat系(CentOS7以上)
言語 :Ruby3.0.6
フレームワーク: RubyonRails7.0.6
なお、今回の作業プロジェクト名はcitiesとしています。このcitiesにはMetroというDBテーブルによって全国の市町村が網羅されており、その中で都道府県のプルダウンを選択したら、該当の市町村がリスト化されるようにします。
create_table :metros do |t|
t.integer :base_no
t.text :city_name
t.text :region
t.integer :pref_no
t.integer :population
t.timestamps
end
Javascriptの呼び出し
まず、RailsでAjaxをする際に引っ掛けのような文言があります。それはフォームタグにremote:trueとすれば非同期通信を実現できるというものですが、これには語弊があり、あくまでpost転送された値がxhrになってくれるというものらしく、転送イベント自体はユーザーが仕掛けないといけません。Rails6だとRails.fireというrails-ujsのイベントトリガーによって制御されていた部分ですが、今回はrails-ujsを使用しないので、Javascriptで制御していきます。
importmapの準備
まずはRailsに対し、Javascriptの使用環境を整えましょう。Rails7はimportmapを推奨しているようなので、今回はそれで準備をしていきます。まずはimportmapをプロジェクトに設定しましょう。
※railsコマンドは通常では動きません。.bash_profileで省力化してます。その場合はbin/railsなどと適宜、対応してください。
#rails importmap:rails
このコマンドを用いれば、configフォルダにimportmap.rbというpin止め用の制御ファイルとapp/javascript直下にapplication.jsという設定ファイルが作成されます。制御ファイルに対し、以下のように追記します。
ポイントとなるのは
pin_all_from "app/javascript/controllers", under: "controllers"
の部分で、contorollersフォルダ直下のスクリプトを呼び出すようにします。
# Pin npm packages by running ./bin/importmap
pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
#以下を追記する
pin_all_from "app/javascript/controllers", under: "controllers"
pin "@rails/request.js", to: "https://ga.jspm.io/npm:@rails/request.js@0.0.8/src/index.js"
※今回は汎用でスクリプト制御しないのでapp/javascript/application.jsには何も追記しません。
ピン止めしたスクリプトを呼び出す
ピン止めしたスクリプトは任意のタイミングでモジュールとして呼び出すことができます。なので、view/layoutフォルダ直下にあるapplication.html.erbに以下のように、モジュールを呼び出すように制御しておきましょう。javascipt_importmap_tagsがimport map呼び出しの準備です。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= content_for?(:title) ? yield(:title) : "Searcher" %></title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<%=yield(:customjs) %>
</head>
そして直下にあるyield(:customjs)に、今回テンプレートフォルダに設置したAjax用スクリプト(ajax-controll.js)が設置されます。
<% content_for :customjs do %>
<%= javascript_import_module_tag "contorollers/ajax-controll" %>
<% end %>
フォーム側の準備
フォームの値を取得するためにransackという便利なライブラリがあるので、それを使用しています。検索結果はqに集められます。そしてremote:trueの記述をしておきます。この部分は以前のRailsでの記述と全く同じなので、Turboと違って汎用できます。
※ransackに関しては説明のわかりやすい記事が色々とあるので、それを参照してください。
<%= search_form_for @q, url: cities_path, html:{method: :post,id:"city-form"}, remote:true do |f| %>
<div class="panel-body" id="ajax-contents">
<!-- 都道府県名の完全一致検索 -->
<div class="col-sm-4">
<div class="form-group">
<div class="form-inline">
<%= f.label :'都道府県選択',{class: 'col-sm-6'} %>
<%= f.select :pref_no_eq, Pref::PREF.to_a,{}, {class:'form-control col-sm-6',id:'pref'} %>
</div><!-- form-inline -->
</div><!-- form-group -->
</div><!-- col-sm-4 -->
</div>
<% end %>
Ajaxイベントを実装する
ではいよいよ本題のAjaxイベントの実装です。今回はrails/request.jsを使用するのですが、どこにも具体的なサンプルがなくかなり苦労しました。ですが、基本は他の非同期通信の方法と同じなので、イベントを実行したときに、どうやってransackへプロパティを受け渡すかが解決の鍵となります。なお、呼び出すスクリプトファイルはapp/javascript/controllers直下に設置してあります。
※イベントによるデータの回収方法は以下のページを参考にしました。
import {post} from "@rails/request.js" //今回使用するライブラリ
document.getElementById("ajax-contents").addEventListener("change",()=>{
document.querySelectorAll("option:checked").forEach(()=>{
getData() //このメソッドを呼び出す
})
})
async function getData () {
const element = document.getElementById("pref")
const name = element.name //選択フォームのnameプロパティ
const prop = name.replace(/^(\S+\[)(\S+)(\])$/g,"$2") //ransack用のプロパティ抽出
const val = element.value //選択された値
const response = await post('cities/search', { body: {q:{[prop]:val}} })
/*以下は後で解説*/
if (response.ok) {
console.log("OK")
let promise = response.text; //jsonを格納
//Promiseから値を呼び出す(※Promiseについては後述で補足)
promise.then(data =>{
const content = JSON.parse(data).container
console.log(content)
document.getElementById("ajax-response-wrapper").innerHTML = content
})
}
}
今回の要となる部分は
{ body: {q:{[prop]:val}} })
です。Rails-ujsならget(0)とするだけで対応してくれたのですが、rails/request.jsではそうは行きません。
bodyプロパティ上にqというプロパティを設定しておき、更にqプロパティ上にransack上のnameプロパティを仕掛けておきます(q[pref_no_eq]ならばプロパティはpref_no_eqにする必要があるので、 [prop] と変数を代入している)。また、qプロパティはransack共通のPOST上でのフォーム受け取り用プロパティなので、これも事前に設定しておく必要があります。
また、今回はコンポーネント上のsearchメソッドで処理を行うのでpostの転送先はcities/searchとなります。
※ここで、モジュールをインポートできないとか、ajax-contorollが未定義であるとかそういうエラーが検出される場合は、importmapがうまく紐づいていません。コンソール画面などできちんとスクリプトを呼び出しているかを確認しておく必要があります。よくやってしまうミスが、rails importmap:installとせず手動でimpoartmap.rbとapplication.jsをファイルを作ってしまう場合です。手動で作った場合、importmapでモジュールの紐づけができなくなってしまいます。
ルーティング設定
ここでpost転送を行ったときに、遷移先をsearchメソッドに指定する必要があるので、routes.rbに追記しておきます。これを忘れるとデフォルトのメソッド(create)に転送されてしまいます。
Rails.application.routes.draw do
resources :cities
root 'cities#index' #ルートページへのアクセス
post 'cities/search' => 'cities#search' #Ajax制御時
end
コントローラー上での処理
postへの転送と値の受け渡しの準備が完了したら、いよいよ処理の部分です。この処理の部分は今までのAjax処理と同じように考えて問題ありません。流れとしては
値の取得 → 取得した値をpartial(部品)化する → partialをjsonにしてリクエストに返す
となります。それをプログラムに落とし込んだのが以下になります。
class CitiesController < ApplicationController
def search
query = "select distinct(region) as region from metros group by region order by pref_no"
unless params[:q].blank?
@q = Metro.ransack(params[:q]) #qには検索条件が受け取られている
@q.sorts = 'population desc' if @q.sorts.empty?
@cities = @q.result.page(params[:page]).per(10)
#取得した値をpertial(部品)化する
p_cities = render_to_string(
partial: 'ajax_container',
locals: { :cities => @cities},
)
if request.xhr?
#partialをjsonにしてリクエストに返す
render json:
{
container: p_cities,
}
end
else
@q = Metro.ransack(params[:q])
@q.sorts = 'population desc' if @q.sorts.empty?
@cities = @q.result.page(params[:page]).per(10)
end
end
end #class
partialファイル
partialファイルはAjaxで値を返すときに、ひとかたまりのDOM要素を部品化して渡すためのファイルで、今回は検索結果をテーブルにして返します。ファイル名のルールとして_hoge.html.erbと先頭にアンダーバーを付与しておきます。partialファイルの場所はviews直下でも問題ないようです。
<% @cities.each do |city| %>
<tr id="t">
<td><%= link_to city.id, city_path(city) %></td>
<td><%= city.base_no %></td>
<td><%= city.city_name %></td>
<td><%= city.pref_no %></td>
<td><%= city.population %></td>
</tr>
<% end %>
レスポンスの処理
partial化されたjsonファイルはrender_to_stringメソッドによって元通り返されているので、今度はそれを展開し、テンプレートに返す処理をします。rails/request.jsはFetchAPIとよく似ておりレスポンスはpromiseオブジェクトとなっているので、それを展開していきます。そして展開された値をinnterHTMLを使って返すだけです。
async function myMethod () {
//中略
if (response.ok) {
let promise = response.text; //jsonを格納
//Promiseから値を呼び出す
promise.then(data =>{
const content = JSON.parse(data).container //jsonを元のオブジェクトに展開する
document.getElementById("ajax-response-wrapper").innerHTML = content //値をテンプレートに返す
})
}
}
テンプレートに返す
スクリプトではajax-response-wrapperというdivタグがあるので、そこに先程のpartialが展開されていきます。
<div class="table">
<table class="table table-striped" id="tbl">
<thead>
<tr>
<th><%= model_class.human_attribute_name(:id) %></th>
<th><%= model_class.human_attribute_name(:base_no) %></th>
<th><%= model_class.human_attribute_name(:city_name) %></th>
<th><%= model_class.human_attribute_name(:pref_no) %></th>
<th><%= model_class.human_attribute_name(:population) %></th>
<th><%=t '.actions', :default => t("helpers.actions") %></th>
</tr>
</thead>
<tbody id="ajax-response-wrapper">
</tbody>
</table>
</div>
ページャーを実装する
ransackはkaminariとの相性もいいので、ページャーもついでにAjaxで制御すると便利です。方法は簡単で、テーブルと同じようにAjaxでページャー用のpartialファイルを作っておくだけです。
<%= paginate @cities, window: 2 %>
これを同じようにjson化し、レスポンスに返したものをテンプレートに展開するだけです。ただ、問題はこのままではリンクが機能していないので、これを制御する必要があります。現状では本来制御させたいアクションに遷移していないので、kaminariの遷移先を以下のようにして書き換えます。
<%= paginate @cities, window: 2,params:{controller: "cities",action: "index"}, remote: true %>
ただ、こっちはあくまでリンクだけなので、結果を返すために以下のように仕込んでおく必要があるようです。
<tbody id="ajax-response-wrapper">
<%= render "ajax_container" %>
</tbody>
</table>
<div id="pager"><%= render "ajax_pager" %></div>
こうすれば、検索条件が変更された場合のみ、xhrでレスポンスされ、ページャーのリンクからはrenderで結果が返されます(検索結果も保持されています)。
あらゆるフォームに対応させる
今までだとransackに対応しているのはプルダウンだけです。ですが、機能を拡張してテキストボックス(部分一致)にも対応させてみました。
<%= search_form_for @q, url: cities_path, html:{id:"city-form"}, remote:true do |f| %>
<div class="panel-body" id="ajax-contents">
<div class="row">
<!-- 都道府県名の完全一致検索 -->
<div class="col-sm-5">
<div class="form-group">
<div class="form-inline">
<%= f.label :'都道府県選択',{class: 'col-sm-6'} %>
<%= f.select :pref_no_eq, Pref::PREF.to_a,{}, {class:"form-control col-sm-6"} %>
</div><!-- form-inline -->
</div><!-- form-group -->
</div><!-- col-sm-4 -->
</div><!-- @row -->
<div class="row">
<!-- 市名の部分一致検索 -->
<div class="col-sm-3">
<div class="form-group">
<%= f.label :"部分一致" %>
<%= f.text_field :city_name_cont, class: "form-control", placeholder: "部分一致" %>
</div>
</div>
<!-- 人口の範囲検索 -->
<div class="form-group col-sm-9">
<div class="form-inline">
<%= f.label :'人口の範囲検索' %>
<%= f.search_field :population_gteq, class: "form-control form-inline", placeholder: "最低値" %>
~
<%= f.search_field :population_lteq, class: "form-control", placeholder: "最高値" %>
</div><!-- form-inline -->
</div>
</div><!-- @row -->
</div><!-- #ajax-contents -->
<% end %>
その場合はスクリプトも従来の書き方では対応できません。もっと汎用に対応する記述が必要になります。今回ransackのプロパティとして対応が必要なのはpref_no_eq(完全一致)だけでなく、city_name_cont(部分一致、contはcontain【含む】の意味)、それと範囲を示すpopulation_gteq(gteqはgreater than and equal つまりは以上)、population_lteq(lteqはless than and equalつまりは以下)も制御対象となります。
なので、以下のようなプロセスでqプロパティの中身を回収していきます。
(1) フォーム制御にかかわるイベントが実行されたらsetPropsメソッドでフォームの値を回収する
(2) setPropsイベントでは値と制御イベントの名前を取得し、propsオブジェクトに代入していく。
(1)でイベントを制御する場合はquerySelectorの方が適切(選択されたイベントのみ制御されるため)のようなので、そちらに書き換えます。
また、(2)の部分でオブジェクトに任意のプロパティを代入していく場合は
let props = {} //このようにオブジェクトとして定義
としてから
props[prop] = value
とすれば
{pref_no_eq:'',city_name_cont:'',population_lteq:'',population_gteq:''}
とダイナミックなプロパティが代入されていきます。
import {post} from "@rails/request.js"
const events = ["change","focusout"]
let props = {}
let ar_checked = []
events.forEach((event)=>{
document.querySelector("#ajax-contents").addEventListener(event,(e)=>{
setProps()
setValue()
e.preventDefault
})
})
function setProps(){
const forms = document.getElementsByClassName("form-control")
let prop
ar_checked = []
for(let form of forms){
const name = form.name //選択フォームのnameプロパティ
prop = name.replace(/^(\S+\[)(\S+)(\])$/g,"$2")
props[prop] = form.value
}
}
async function setValue() {
const response = await post('cities/search', { body: {q:props} })
if (response.ok) {
console.log("OK")
let promise = response?.text; //jsonを格納
//Promiseから値を呼び出す(※Promiseについては後述で補足)
promise.then(data =>{
if(data != ""){
const content = JSON.parse(data).container
const pager = JSON.parse(data).pager
document.getElementById("ajax-response-wrapper").innerHTML = content
document.getElementById("pager").innerHTML = pager
}
return false
})
}
}
結論からいえば、転送ライブラリがどうであれ(axiosとかfetchAPIでも)、紐付ける部分さえ一致させれば、どんな手段でも同期可能のようです。また、今まではRailsのAjaxはsubmit前提で考えられていたのですが、rails/request.jsのような手段を使えば、submitは不要となります。