この記事は「ユニークビジョン株式会社 Advent Calendar 2018」の4日目の記事です。
#はじめに
今回、1つの見積もりに複数の明細が存在するという1対多の関係を持つmodel達を1つの画面で登録できるようにする必要がありました。
そのときにnested_formとcocoonという2つのgemを発見しました。
nested_formは最終コミットが2013年、一方cocoonはこの記事を執筆している段階で2月前が最終コミットとなっています。
というわけで、より新しい方を使ってみようくらいの軽い気持ちでcocoonの導入をしてみました。
#導入
##Gemfile
まず、Gemfileにcocoonを追加します。
また、このgemはjQueryに依存するとのことなので別途必要になるかもしれません。
gem 'cocoon'
##application.js
app/assets/javascript/application.js にも以下の記述を追加する必要があります。
//= require cocoon
##Model
モデルは見積もり(estimate)と見積もり明細(estimate_detail)を作成しました。
class Estimate < ApplicationRecord
has_many :estimate_details
accepts_nested_attributes_for :estimate_details
class EstimateDetail < ApplicationRecord
belongs_to :estimate
親である見積もりのformで子である明細をネストできるようにaccepts_nested_attributes_forをestimateに記述しています。
ちなみに本来は動的に削除するためにはaccepts_nested_attributes_forに加えてallow_destroy: trueを指定する必要がありますが、私はDBへ接続してからストアードプロシージャで論理削除を行うためにあえて指定していません。
##Controller
def new
@estimate = Estimate.new
@estimate.estimate_details.build
end
def estimate_params
prms = params.require(:estimate).permit(
:title,
:remark,
estimate_details_attributes: [
:id,
:content,
:price,
:_destroy
]
)
# 明細が存在しなければ空Hash
prms[:estimate_details_attributes] ||= {}
prms
end
まずはnewについて。
ここでestimate_detailsをbuildしていますが、これによって最初から空の明細が1つ表示された状態にすることができます。
そして肝心のパラメーターですが、estimate_details_attributesを設定して、その中に子レコードのパラメーターを指定しています。
ドキュメントによると:idと:_destroyは必須だそうで。
##View
<%= form_for @estimate do |f| %>
<table>
<tr>
<th>タイトル</th>
<td>
<%= f.text_field :title, class:"input-txt" %>
</td>
</tr>
<tr>
<th>備考</th>
<td>
<%= f.text_field :remark, class:"input-txt" %>
</td>
</tr>
</table>
<table class="table-type-b", id="detail-association-insertion-point">
<%= f.fields_for :estimate_details do | detail | %>
<%= render 'estimate_detail_fields', f: detail %>
<% end %>
</table>
<div class="tit-group">
<p class="tit-group_btn">
<%= link_to_add_association '明細を追加', f, :estimate_details,
class: 'btn-type-b',
data: {
association_insertion_node: '#detail-association-insertion-point',
association_insertion_method: 'append'
}
%>
</p>
</div><!-- /tit-group -->
<% end %>
実際にはnew.html.erbとedit.html.erbから共通の_form.html.erbへrenderしていたりするのですが、必要な部分だけを記述しています。
まず上のテーブルですが、見積もりのタイトルと備考があるだけですね、はい。
そして下のテーブル。ここが子レコードの追加場所になります。
fields_forについてですが、Railsドキュメントには「form_for内で異なるモデルを編集できるようになる。」と書かれています。これにhas_manyである:estimate_detailsを渡しています。render先は後ほど記述します。
idは明細追加ボタンにここがformの追加場所であると示すために指定しています。
最後のdivの中身が追加ボタンです。
link_to_add_associationを使うことでネストしたformを動的に追加できます。
さらにassociation_insertion_nodeやassociation_insertion_methodを指定することでどこに子供を追加するかを詳細に指定できるようです。
というわけでassociation_insertion_nodeと先のテーブルのidが対応してくれるようです。
<tr class="nested-fields">
<th width="20%">明細</th>
<td>
<%= f.text_field :content,placeholder: "50文字以内", class:"input-txt size-m" %>
<% if f.index == 'new_estimate_details' %>
<%= f.number_field :price, class:"input-txt size-s", min:"0", value: '0' %>円
<% else %>
<%= f.number_field :price, class:"input-txt size-s", min:"0" %>円
<% end %>
<%= link_to_remove_association '削除', f, class: 'bg-orange btn-type-s' %>
</td>
</tr>
これが複製して追加される部分です。
classにnested-fieldsをつけます。cocoonはこのクラスを認識してその中にある要素を複製して動的に追加しているようです。
ちなみに、動的に追加した要素のindexは'new_estimate_details'となっています。私の場合priceがnumeric型なもので初期値が0.0になってしまうのが微妙にダサく、追加された要素の初期値を0に指定しています。
最後のlink_to_remove_associationを指定しておくことで要素の動的な削除が行えます。
modelで allow_destroy: true を設定していないので、動的に追加したばかりの要素なら単純に削除され、すでにDBに登録されているレコードなら :_destroy がtrue になります。
これを見て私は論理削除を行なっているのですが、cocoonには直接関係のないことなのでここでは記述しません。
通常利用なら allow_destroy: true を設定しておく方が良いでしょう。
#困ったこと
これでほとんど導入は終わったのですが、2つほど困ったことがありました。
1つは、validationに引っかかったりしてDBに登録できなかった時にnewやeditにrenderして登録画面へ戻すと動的に追加したばかりの明細が全て消えてなくなってしまう現象。
おそらくfields_forのあたりでrailsが勝手に呼んでくれる、すでにDBに登録されている子レコードしか出てきていないのだと考え以下のようなメソッドを作成してrender前に呼び出すことで追加した明細をbuildし直して配置することにしました。
def build_details
# 明細が存在すれば見積もりの下にbuildする。
# 全てをbuildするとeditの際にDBに保存されている項目が重複するのでidがないものだけ。
if @estimate.estimate_details_attributes.present?
@estimate.estimate_details_attributes.values.each do | detail |
@estimate.estimate_details.build({
content: detail['content'],
price: detail['price']
}) if detail['id'].blank?
end
end
end
もう1つが明細を登録→編集→追加と複数回に分けて追加していくと順番が逆転してしまう現象。
登録の時には新しい明細を下に追加していっていたのに、編集で再度開くと上に新しい明細がきてしまっていました。
当初はfields_forのあたりに介入してrailsが勝手に呼び出してくれる既存のレコードの並び順をソートしようとしましたが芳しい結果が得られず、結局明細モデルにdefault_scopeを設定して並び順を調整しました。
明細をわざわざ別の並び順で表示する場所もないし、これでいいかなと。
default_scope { order(id: :asc) }
#最後に
初めての記事でしたが、どうでしたでしょうか。
一応、cocoonの導入に際して新人エンジニアが方々駆けずり回って探した情報や実験した内容がここでまとまれば良いなと思って書いたのですが。
どこかの誰かの参考に少しでもなれば幸いです。
#参考サイト
https://qiita.com/Matsushin/items/4829e12da2834d6e386e
https://twinbird-htn.hatenablog.com/entry/2018/05/17/230000
https://rails.densan-labs.net/form/relation_register_form.html
http://hyperneetprogrammer.hatenablog.com/entry/2016/01/14/180000