【Rails】fields_forを使って同一フォームで別のモデルオブジェクトを編集する

form_withで投稿フォームを作成する際に、子モデルのカラムも同一フォームで登録・更新するため、fields_forを使ったのですが、バリデーションをつけるのに苦戦したので、記録しておきます。

### fields_forとは？ ###

- fields_forは、親モデルと子モデルに対し、一つのフォーム（form_withなど）から編集を加えるときに使用します。[Rails Guide](https://railsguides.jp/form_helpers.html)によれば「同じフォームで別のモデルオブジェクトも編集できるようにしたい場合などに便利」とのことです。
- この記事では、親モデルが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（作り方）
- 投稿画面のイメージ（材料・作り方の部分。前後に）はこんな感じです↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1215197/77f4e63a-b542-aad7-f48e-23b57753f5b7.png)

## fields_forの実装
では実装していきます！
### モデル記述 
👇```accepts_nested_attributes_for```に```allow_destroy: true```を渡すと、関連付けられたオブジェクトが削除されます。下記には記載していませんが、```reject_if: all_blank```を追加すると、空欄の場合にデータ更新しないようにすることもできます。オプションについては状況に合わせて調べてみると良いですね。

```ruby:app/models/post_recipe.rb
#一対多のアソシエーション
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
```

```ruby:app/models/ingredient.rb
#一対多のアソシエーション
belongs_to :post_recipe
```

```ruby:app/models/procedure.rb
#一対多のアソシエーション
belongs_to :post_recipe
```


### コントローラー記述 ##

👇ストロング・パラメーターに、```◯◯_attributes```のように記述して、子モデルのカラムも許可するようにします。モデルの方に```allow_destroy: true```を記述している場合は、_destroyキーをストロングパラメーターに追記します。この辺りの挙動については、[Rails Guide](https://railsguides.jp/form_helpers.html)に記述があるので、確認してみてください。

※必要なところだけ抜き出して書いています

```ruby:app/controllers/post_recipes_controller.rb
  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
```

### ビュー記述

```html:app/views/new.html.erb

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

## バリデーションをかける
ここがハマったところだったのですが、色々調べて試行錯誤した結果はとてもシンプルでした👇

### モデル記述 ##
👇まず親モデルの方にこのように記述してあげます。
　※必要なところだけ抜き出して書いています

```ruby:app/post_recipe.rb
  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```には各カラムに対してバリデーションを指定しておきます👇

```ruby:app/ingredient.rb
  belongs_to :post_recipe

  with_options presence: true do
    validates :name
    validates :amount
  end
```

proceduresテーブルは今回、更新対象が１カラム（body）だけなので、もとの記述のままで問題なく動作します👇

```ruby:app/models/procedure.rb
#一対多のアソシエーション
belongs_to :post_recipe
```

以上です！！