5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

form_with は remote (Ajax) が既定値なのに scaffold で生成されるコードは local: true オプションが付いていてどうにかしたい

Posted at

やりたいこと

  • Rails の scaffold をジェネレートすると form_withlocal: true のオプションが付いています。
  • local オプションのデフォルトは false なわけで、 Ajax にしていこうという流れの中で scaffold のコードが Ajax じゃないのに違和感を覚えました。
  • というわけで、 scaffold で生成されたコードに対し、現実的なコード改修量で Ajax リクエストによる CRUD を実現してみます。

前提

  • webpacker を使っている
    • rails-ujs を使っている
    • turbolinks を使っている
$ yarn add rails-ujs turbolinks
app/javascript/packs/application.js
import Rails from 'rails-ujs'
Rails.start()

import Turbolinks from 'turbolinks'
Turbolinks.start()
  • 下記のようなユーザモデルを scaffold で作成した直後の状態を想定
    • name および age という属性を持つ。前者は string で後者は integer
    • name および age ともに入力必須 (バリデーションエラー時の挙動確認で必要なので)
$ ./bin/rails g scaffold User name age:integer
app/models/user.rb
 class User < ApplicationRecord
+  validates :name, :age, presence: true
 end
  • Rails のバージョンは 5.2.2
$ rails -v
5.2.2

実施

ざっくり言うと以下の流れに。

  • save 成功時、リダイレクトが Ajax の場合でも動くようにする
  • save 失敗時、 form 要素だけを html で返し、その結果により既存 form を入れ替える

_form パーシャルファイル、 local: true を外す

このポストの内容の前提なので。

app/views/users/_form.html.erb
-<%= form_with(model: user, local: true) do |form| %>
+<%= form_with(model: user) do |form| %>
   <% if user.errors.any? %>
     <div id="error_explanation">
       <h2><%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:</h2>

_form パーシャルファイルが <form> タグだけを描画する、という前提を変えない

通常、 scaffold ジェネレータを実施すると _form パーシャルファイルは <form> タグに該当する html を描画するわけですが、それ以外の要素を _form パーシャルファイル内で描画しないようにします。
たとえば <form> タグの直下に <a> タグでリンクを置きたいからといって _form パーシャルファイルの中にそれを記述してはいけません。同じことを実現するには _form パーシャルファイルを呼び出す側に記述すればことが済むはずです。

例えば以下のようにするのではなく。。。

app/views/users/edit.html.erb
<%= render 'form', user: @user %>
app/views/users/_form.html.erb
<%= form_with(model: user) do |form| %>
  ...省略
<% end %>
<%= link_to 'Other Site', 'http://example.com' %>

_form パーシャルでは <form> だけ描画します。

app/views/users/edit.html.erb
<%= render 'form', user: @user %>
<%= link_to 'Other Site', 'http://example.com' %>
app/views/users/_form.html.erb
<%= form_with(model: user) do |form| %>
  ...省略
<% end %>

gem turbolinks を入れる

単に turbolinks を動かすだけなら JS の npm 管理下にある turbolinks を入れるだけで良いけど、 gem の turbolinks は別の理由で必要です。
gem turbolinks は Rails コントローラの redirect_to の挙動をさしかえ、非Ajax の場合のリダイレクトと同じような動きをするようにします。
参考: https://github.com/turbolinks/turbolinks-classic/blob/master/lib/turbolinks/redirection.rb

create/update アクションのバリデーションエラー時に html 全体でなく _form パーシャルの内容だけ返す

  • render で _form パーシャルだけを描画して返すようにします。このときに locals を指定するのを忘れずに
  • 更に、ステータスコードは 200 でなく 422 Unprocessable Entity などのクライアントエラーを返すようにします
app/controllers/users_controller.rb
   def create
     @user = User.new(user_params)

     if @user.save
       redirect_to @user, notice: 'User was successfully created.'
     else
-      render :new
+      if request.xhr?
+        render partial: 'form', status: :unprocessable_entity, locals: { user: @user }
+      else
+        render :new
+      end
     end
   end
app/controllers/users_controller.rb
   def update
     if @user.update(user_params)
       redirect_to @user, notice: 'User was successfully updated.'
     else
-      render :edit
+      if request.xhr?
+        render partial: 'form', status: :unprocessable_entity, locals: { user: @user }
+      else
+        render :edit
+      end
     end
   end

フォームで ajax:error イベントを拾ったときに自身の内容を書き換える

app/javascript/packs/application.js
import Rails from 'rails-ujs'
Rails.start()

import Turbolinks from 'turbolinks'
Turbolinks.start()

// ここから
document.addEventListener('turbolinks:load', (event) => {
  const forms = document.querySelectorAll('form[data-remote="true"]') // remote フォームについて
  forms.forEach((form) => {
    form.addEventListener('ajax:error', (event) => { // 先のコントローラの処理で 200 を返しているとここは発火しないので注意
      const detail = event.detail
      const xhr = detail[2]
      const contentType = xhr.getResponseHeader('content-type')

      if (contentType === 'text/html; charset=utf-8') { // html が返ってきている場合
        const target = event.currentTarget
        const tmp = document.createElement('div')
        tmp.innerHTML = xhr.responseText
        const element = tmp.firstElementChild
        target.innerHTML = element.innerHTML // <form> タグの innerHTML の中身を入れ替える
      } // TODO: form タグ自体の属性についても厳密に丸々入れ替えるべきかもしれないが、ここではそこまでしていません
    })
  })
})

ここまで実施すると、 scaffold コードをベースにした Ajax CRUD が実現できているはずです。

良し悪し

  • Pros.
    • _form パーシャルに <form> を書くというルールを前提とした場合に非常にシンプル
    • Javascript の記述がほとんど要らない
    • rake app:templates:copy でコピーした controller と view のテンプレートファイルに少し手を入れる程度で、他にほとんど気にすることがない
  • Cons.
    • この実装は、バリデーションエラー時に <form> タグ以外の場所を更新できない
      • 実施する場合、たとえば <form ... data-remote-placeholder="#form-wrapper"> のような属性があれば form でなく document.querySelector(form.dataset.remotePlaceholder) が示す参照先をベースに書き換えする。みたいな拡張は可能。
      • あるいは SJR でやるとか

まとめ

  • scaffold はまだ Ajax 化されていない (今後されるの?)
  • scaffold のコードをベースに Ajax 化してみた
    • gem turbolinks を使うと Ajax/非Ajax を意識せずに redirect_to を使える
    • save 失敗時に form の html 要素を返し、それにより自身の form の中身を差し替える(と決めておくと簡単)

補足

https://github.com/hamajyotan/scaffold_ajaxify
この記事での内容を実施したリポジトリの共有です。

5
2
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
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?