Hotwire(Rails 7)で、複数の子レコードの保存が簡単になる
cocoon などの gem を使わなくてよい。JavaScript も jQuery も使わない
ほぼ erb のレンダリング(サーバーサイド)だけでなんとかなります。
has_many、has_one、belogs_to などいわゆる Rails の関連付け(アソシエーション)の概要は把握していることを前提としてます。
検証は、ruby 3.2、Rails 7.1 の環境です
accepsts_nested_attributes_for は嫌われ者?
そもそも accepsts_nested_attributes_for は、関連付けられているモデルをネストさせることで、一度にまとめてレコードの更新ができるようにするメソッドです。1対N(has_many)のデータを html の form で一度にまとめてサーバーに送り、受け取った Rails がよしなに処理してくれます。なかなか便利な仕組みです。
ところが、Rails生みの親のDHH自身が否定的だったりするらしい。
なので、フォームオブジェクトを活用するというやり方もある。→参考【Rails】複数のモデルをフォームオブジェクトを使って保存する
しかしここでは、フォームオブジェクトも使いません。いわば素のメソッドだけ使い、Hotwire の特徴を使い倒します。では、早速
サンプルの作成
サンプルアプリの生成
rails new sample
cd sample
rails g scaffold movie title:string
rails g scaffold character name:string movie:references
rails db:create ; rails db:migrate
テンプレートの変更
以下は定番の変更と本質的でない変更(なので盲目的なコピペでも充分)
has_many と accepts_nested_attributes_for を利用するための定番です。なので解説は割愛
def movie_params
- params.require(:movie).permit(:title)
+ params.require(:movie).permit(:title, characters_attributes: [:id, :name])
end
class Movie < ApplicationRecord
+ has_many :characters, dependent: :destroy
+ accepts_nested_attributes_for :characters, allow_destroy: true
end
Hotwire を使うために turbo-frame を追加する。<%= turbo_frame_tag "movies" do %>
のブロック部分が交換されるようになります
-<div id="movies">
+<%= turbo_frame_tag "movies" do %>
<% @movies.each do |movie| %>
<%= render movie %>
<p>
- <%= link_to "Show this movie", movie %>
+ <%= link_to "Edit this movie", edit_movie_path(movie) %>
</p>
<% end %>
-</div>
+<% end %>
+<%= turbo_frame_tag 'movies' do %>
<h1>New movie</h1>
<%= link_to "Back to movies", movies_path %>
</div>
+<% end %>
+<%= turbo_frame_tag 'movies' do %>
<h1>Editing movie</h1>
<%= link_to "Back to movies", movies_path %>
</div>
+<% end %>
Show 画面を変更するのが面倒なので、保存したらいったん index に戻る
respond_to do |format|
if @movie.save
- format.html { redirect_to movie_url(@movie), notice: "Movie was successfully created." }
+ format.html { redirect_to movies_path }
format.json { render :show, status: :created, location: @movie }
else
def update
respond_to do |format|
if @movie.update(movie_params)
- format.html { redirect_to movie_url(@movie), notice: "Movie was successfully updated." }
+ format.html { redirect_to movies_path }
format.json { render :show, status: :ok, location: @movie }
else
ここからが主題の変更部分(解説は後述)
def new
@character = Character.new
+ @index = Time.now.to_i #(2)
end
def destroy
@character.destroy!
respond_to do |format|
format.html { redirect_to characters_url, notice: "Character was successfully destroyed." }
format.json { head :no_content }
+ format.turbo_stream #(3)
end
end
<%= turbo_frame_tag dom_id(movie) do %>
<%= form_with(model: movie) do |form| %>
<p>
<%= form.label :title, style: "display: block" %>
<%= form.text_field :title %>
</p>
<%= form.fields_for :characters do |subform| %> #(1)
<%= render partial: "characters/form", locals: {form: subform} %>
<% end %>
<%= turbo_frame_tag 'new_characters' %> #(4)
<%= link_to "NEW", new_character_path, data: {turbo: true, turbo_stream: true} %> #(5)
<div>
<%= form.submit %>
</div>
<% end %>
<% end %>
<% index ||= nil %> #(6)
<%= turbo_frame_tag dom_id(form.object, index) do %> #(6)
<p>
<%= form.hidden_field :id %>
<%= form.label :name %>
<%= form.text_field :name %>
<%= link_to "DELETE", character_path(form.object.id||0), data: { turbo_method: :delete, turbo_confirm: "SURE?", turbo: true, turbo_stream: true} %> #(7)
<%= form.label :index %>
<%= form.index %> #(8)
</p>
<% end %>
<%= turbo_stream.remove dom_id(@character) %>
<%= form_with(model: Movie.new, id:"GOMI") do |form| %> #(9)
<%= turbo_stream.append 'new_characters' do %> #(10)
<%= form.fields_for :characters, Character.new, child_index: @index do |subform| %> #(11)
<%= render partial: "characters/form", locals: {form: subform, index: @index} %> #(12)
<% end %>
<% end %>
<% end %>
<%= turbo_stream.remove 'GOMI' %> #(9)
解説
わかりやすいので先に削除(destroy)から
データベースの該当レコードを削除したらブラウザ上の該当レコード表示も消して欲しい、というのが多くの人の要求でしょう。
そこで、characters_controller.rb の destroy メソッドでは、destroy.turbo_stream.erb をレンダリングするように明示しています。#(3)
destroy.turbo_stream.erb は<%= turbo_stream.remove dom_id(@character) %>
これだけ。これは、id="character_〇〇"(〇〇はレコードID)という DOM エレメントを削除します。
#(7)のcharacter_path(form.object.id||0)
の 0 はレンダリングエラーを回避するためだけです。後述の new のときに 0 になります。リンクをクリックすれば当然エラーになりますが、本記事の趣旨から外れるので簡易な対策です。実務では適切に場合分してください。
子レコードの追加(new)
fields_for ヘルパーの理解から
<%= form_with model: @movie do |form| %>
<%= form.label :title %>
<%= form.text_field :title %>
<%= form.fields_for :characters do |subform| %>
<%= subform.label :name %>
<%= subform.text_field :name %>
<% end %>
<% end %>
上記のコードのform.fields_forのブロック部分は次のようにレンダリングされます(子レコードが2つあるとき)
<label for="movie_characters_attributes_0_name">Name</label>
<input type="text" value="〇〇" name="movie[characters_attributes][0][name]" id="movie_characters_attributes_0_name" />
<label for="movie_characters_attributes_1_name">Name</label>
<input type="text" value="〇〇" name="movie[characters_attributes][1][name]" id="movie_characters_attributes_1_name" />
このときの 0 や 1 というインデックスは Rails が自動的に振ります。コントローラーに値が渡されたときにハッシュのキーとして利用されるので数値自体に意味はないけど、重複してはいけません。既存のレコードの変更(update)のときは気にする必要がありませんが、レコード追加のときに他のデータと重複しないように form.fields_for にレンダリングさせなければなりません。
ドキュメントが薄っぺらくて記載がない(!)のだけれども、オプションにchild_index
というのがあります。これは先述したところのインデックスを指定するオプションになります。とはいえ、インデックスを固定するだけです。インデックスのスタート値を指定するものでもありません。
そこで、このchild_index
オプションを利用します。なんだってよいので、#(2)Time.now.to_i
にしてます。
それを踏まえると、#(11)<%= form.fields_for :characters, Character.new, child_index: @index do |subform| %>
は次のような意図になります。
-
:characters
なので、DOMエレメントの name には [charaters_attributes] を入れてね - レコード(モデルのインスタンス)は
Character.new
を使ってね - [charaters_attributes][] のインデックスは
child_index
で指定した@index
をつかってね
サンプルコードでは child_index
を表示してます#(8)
GET /characters/new(.:format) (new_character_path characters#new)
新しい入力欄を#(4)に追加していきたいので turbo_stream.append #(10)を利用します。
link_to ヘルパーは Turbo Frames をリクエストしますが、これはひとつの HTML 要素の置換しかできません。これに対して、Turbo Streams では HTML 要素の追加・更新・削除をすることができます。新規入力欄を複数作成できるように設計します。そのため、GET メソッドを turbo_stream にします#(5)
リクエストヘッダーが次のように変化します
- HTTP_ACCEPT:text/html, application/xhtml+xml
+ HTTP_ACCEPT:text/vnd.turbo-stream.html, text/html, application/xhtml+xml
その結果、次のようなログ (log/development.log) を確認できます。
Processing by CharactersController#new as TURBO_STREAM
dom_id ヘルパー
つぎに form.fields_for でレンダリングするレコードは各々<turbo-frame id="〇〇">でまとめます。(上記の destroy 参照)。dom_id(Character.new)
の場合の id はnew_character
になります。
しかし複数同時に追加したくても、これでは一つの欄しか表示(DOM の変更)されません。turbo_stream.append はただ追加するだけでなく、既存の同一 id の DOMエレメントを削除してしまうからです。( HTML 的には id が重複しないので正しい)
なので、生成される id も重複しないようにします。
ところで、dom_id は、今まで盲目的に使ってましたが、便利です。第2引数に prefix を指定すると 〇〇_character のように生成してくれます。nil だとデフォルトの振る舞いになります。#(6)
重複さえ避けられればよいので、上述の #(2)@index
を流用してます。
ゴミ掃除
turbo_strem.append は該当 id の turbo-frame に追加していきますが、<form>タグ部分も追加すると<form></form>タグが form_with のタグと入れ子状態になるのでよくありません(ブラウザでは無視されますが)。form.fields_for の外側はいりません。
なので、いらない部分を turbo_strem.append の外側に追い出してあります。
他方、追い出された部分は </body> と </html> の間に追加されてしまいます。そのままでも特段悪影響はなさそうですが、ない方がよいです。
そこで、form_with
に id を追加して<%= turbo_stream.remove 'GOMI' %>
で削除してます#(9)
実装
- New movie の画面で親モデルと子モデルを同時に create できてます(確認は edit 画面で)
- Editing Movie の画面で子モデルを複数追加(update)できてます
- Editing Movie の画面で子モデルを削除(destroy)し、内容変更(update)も同時にできてます
補足と雑感
- scaffold で作成されたルーティングも変更してません。慣れてる方はお気付きかもされませんが
resouces :movies do
rsouces :characters
end
も必須ではないです
- 上記のコードと実装動画では何も入力されていないレコードも create されてしまってます。それを回避するには accepts_nested_attributes_for の引数に
reject_if: :all_blank
を追加すればよいです(スクリーンキャプチャしたあとにバグに気づきました) - fields_for のドキュメントにはオプションの説明がなく、閉口しました。
- Movie モデルと Character モデルの取り扱いが、それぞれ分掌されることになり(フォームオブジェクトと比較して)コードの管理がしやすくなったと感じます(Movie モデルは movies_controller.rb と app/views/movies 以下で、Character モデルは characters_controller.rb と app/views/characters 以下で、とういう具合)
- <%= turbo_stream.update ID do %> や <%= turbo_stream.replace ID do %> があるから、Turbo Streams を原則にして Turbo Frames を例外にした方が楽じゃないだろうか?つまり、GETメソッドのデフォルトが Turbo Streams になる。 (show や new のときは、...だから、とか考えるのは面倒くさい)と、密かに思ってます。