form_withで投稿フォームを作成する際に、子モデルのカラムも同一フォームで登録・更新するため、fields_forを使ったのですが、バリデーションをつけるのに苦戦したので、記録しておきます。
fields_forとは?
- fields_forは、親モデルと子モデルに対し、一つのフォーム(form_withなど)から編集を加えるときに使用します。Rails Guideによれば「同じフォームで別のモデルオブジェクトも編集できるようにしたい場合などに便利」とのことです。
- この記事では、親モデルがPostRecipe(レシピ)、子モデルがIngredient(材料)とProcedure(手順)の前提で進めていきます。アソシエーションは記事内で記述します。
前提:###
- モデル(PostRecipe, Ingredient, Procedure)やコントローラー(post_recipes)の作成は済んでいる
- post_recipesコントローラーのnew及びcreateアクションでレシピの新規登録を行う
- 親モデルがPostRecipe(レシピ)、子モデルがIngredient(材料)とProcedure(手順)で、それぞれ一対多の関係性
- 以下の複数モデルのカラムを、一つのフォームで登録・更新する:
- PostRecipeモデル:title(レシピ名), recipe_image(レシピ画像), introduction(レシピ説明), serving(何人前)
- Ingredientモデル:name(材料名), amount(分量)
- Procedureモデル:body(作り方)
- 投稿画面のイメージ(材料・作り方の部分。前後に)はこんな感じです↓
fields_forの実装
では実装していきます!
モデル記述
👇accepts_nested_attributes_for
にallow_destroy: true
を渡すと、関連付けられたオブジェクトが削除されます。下記には記載していませんが、reject_if: all_blank
を追加すると、空欄の場合にデータ更新しないようにすることもできます。オプションについては状況に合わせて調べてみると良いですね。
#一対多のアソシエーション
has_many :procedures, dependent: :destroy
has_many :ingredients, dependent: :destroy
#関連付けしたモデルを一緒にデータ保存できるようにする
accepts_nested_attributes_for :procedures, allow_destroy: true
accepts_nested_attributes_for :ingredients, allow_destroy: true
#一対多のアソシエーション
belongs_to :post_recipe
#一対多のアソシエーション
belongs_to :post_recipe
コントローラー記述
👇ストロング・パラメーターに、◯◯_attributes
のように記述して、子モデルのカラムも許可するようにします。モデルの方にallow_destroy: true
を記述している場合は、_destroyキーをストロングパラメーターに追記します。この辺りの挙動については、Rails Guideに記述があるので、確認してみてください。
※必要なところだけ抜き出して書いています
def new
@post_recipe = PostRecipe.new
end
def create
@post_recipe = PostRecipe.new(post_recipe_params)
if @post_recipe.save
redirect_to post_recipe_path(@post_recipe), notice: "レシピを投稿しました!"
else
render :new, alert: "登録できませんでした。お手数ですが、入力内容をご確認のうえ再度お試しください"
end
end
private
def post_recipe_params
params.require(:post_recipe).permit(
:user_id,
:title,
:introduction,
:recipe_image,
:serving,
procedures_attributes: [:body, :_destroy],
ingredients_attributes: [:name, :amount, :_destroy]
)
end
ビュー記述
<div class="container mt-4 align-imtes-center">
<div class='bg-light mx-auto mb-5'>
<%= form_with model: @post_recipe, local:true do |f| %>
(中略)
<!------------ 材料登録欄 -------------->
<div class="row">
<div class="form-inline mb-2">
<h4 class="mb-0">材料</h4>
<%= f.text_field :serving, placeholder: "何人分", size:"10x3", class:'ml-4' %>
</div>
</div>
<div class="row">
<table class="table table-borderless mb-0" id="ingredient-table">
<thead>
<th class="pb-1"><h6 class="mb-0">材料・調味料</h6></th>
<th class="pb-1"><h6 class="mb-0">分量</h6></th>
</thead>
<tbody>
<%= f.fields_for :ingredients do |ingredient| %>
<tr>
<td style="width: 12%" class="py-1"><%= ingredient.text_field :name, placeholder: "例)にんじん" %></td>
<td style="width: 10%" class="py-1"><%= ingredient.text_field :amount, placeholder: "例)一本" %></td>
<td class="align-middle p-0"><input type="button" value="削除" onclick="deleteRow(this)"></td>
</tr>
<% end %>
</tbody>
</table>
<input type="button" value="材料を追加" onclick="addRow('ingredient-table')" class="btn btn-sm btn-color py-0 my-3 ml-2">
</div>
<!------------ 作成手順登録欄 -------------->
<div style='max-width: 680px' class="mt-3 px-5 pt-4 mx-auto border-0">
<div class="row">
<h4 class="text-center mx-auto mb-0">つくり方</h4>
</div>
<div class="row">
<table class="table table-borderless" id="procedure-table">
<%= f.fields_for :procedures do |procedure| %>
<tr>
<td style='width: 70%' class="px-0"><%= procedure.text_area :body, placeholder:"手順を記入", size:'80 x 3', class:'p-2' %> </td>
<td class="align-middle"><input type="button" value="削除" onclick="deleteRow(this)"></td>
</tr>
<% end %>
</table>
<input type="button" value="手順を追加" onclick="addRow('procedure-table')" class="btn btn-sm btn-color py-0 mb-3">
</div>
</div>
<div class="row mt-4 pb-5">
<div class="form-inline mx-auto">
<%= f.submit 'レシピを公開', :name => 'post', style: 'font-size: 20px', class:'mr-5 btn btn-sm btn-warning text-white' %>
<%= f.submit '下書きに保存', :name => 'update', style: 'font-size: 20px', class:'mr-5 btn btn-sm btn-outline-secondary' %>
</div>
</div>
<%= f.hidden_field :user_id, :value => current_user.id %>
<% end %>
</div>
</div>
バリデーションをかける
ここがハマったところだったのですが、色々調べて試行錯誤した結果はとてもシンプルでした👇
モデル記述
👇まず親モデルの方にこのように記述してあげます。
※必要なところだけ抜き出して書いています
with_options presence: true do
validates :recipe_image
validates :serving
validates :title
validates :introduction
validates :ingredients
validates :procedures
end
validates :title, length: { maximum: 14 }
validates :introduction, length: { maximum: 80 }
新規投稿時に、ingredientsテーブルは2つのカラム(nameとamount)をfields_forで更新するので、ingredient.rb
には各カラムに対してバリデーションを指定しておきます👇
belongs_to :post_recipe
with_options presence: true do
validates :name
validates :amount
end
proceduresテーブルは今回、更新対象が1カラム(body)だけなので、もとの記述のままで問題なく動作します👇
#一対多のアソシエーション
belongs_to :post_recipe
以上です!!