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

# やりたいこと

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

# 前提

- webpacker を使っている
    - rails-ujs を使っている
    - turbolinks を使っている

```console
$ yarn add rails-ujs turbolinks
```

```javascript: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` ともに入力必須 (バリデーションエラー時の挙動確認で必要なので)

```console
$ ./bin/rails g scaffold User name age:integer
```

```diff:app/models/user.rb
 class User < ApplicationRecord
+  validates :name, :age, presence: true
 end
```

- Rails のバージョンは 5.2.2

```console
$ rails -v
5.2.2
```

# 実施

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

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

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

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

```diff: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` パーシャルファイルを呼び出す側に記述すればことが済むはずです。

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

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

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

```erb:app/views/users/edit.html.erb
<%= render 'form', user: @user %>
<%= link_to 'Other Site', 'http://example.com' %>
```
```erb: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 などのクライアントエラーを返すようにします

```diff: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
```

```diff: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` イベントを拾ったときに自身の内容を書き換える

```javascript: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
この記事での内容を実施したリポジトリの共有です。