Javascriptを使用してフォームを何個も追加

# はじめに

ポートフォリオ制作中です！
書こう書こうとずっと思ってはいたのですが、なかなか手がつけられず…
忘れないうちに書き始めようと決心しました…！

間違えているところ等ございましたら、ご指摘いただけますと幸いです🙇

今回はJavascriptでフォームを何個でも追加できるような実装をアウトプットします✍️
`accepts_nested_attributes_for`を使用して子要素を親要素で保存や変更できるようにしています！
（後々調べると、`accepts_nested_attributes_for`は使用しない方がいいかもという記事をちらほら見かけますが、ポートフォリオも終盤で今更変えられないのでこのままでいきます！)

https://zenn.dev/murakamiiii/articles/5ecefb7a58d1ef

https://zenn.dev/ysi831/articles/0faa4c301e7d9f

# 完成図
旅行やデートのプランを共有するSNSサイト「Go out Planning」を絶賛作成中です
プラン概要の中に何個でもスケジュールフォームを追加できるようにするため、
今回は動的フォーム追加を実装します！
Gemの利用はしていません！
cocoonというGemがあるようなので、
気になる方は調べてください！（最初の頃はGemの存在を知らんかったんや…！）

https://qiita.com/okiiiiiina/items/62684d875986352e9664

https://qiita.com/okiiiiiina/items/8868a7fcb6ec0817d064

![スクリーンショット 2024-06-20 0.30.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3782860/2e4681a4-e91e-1cdc-5a82-a232b02f358c.png)

# ER図
Plan:PlanDetailで1：Nの関係です
Active Record Nested Attributesを使用して、
Planのnew画面でPlanDetailも合わせて保存できるようにしています

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3782860/86cca8ec-d190-3fff-1f55-26cd2390fb55.png">

# Active Record Nested Attributesとは
RailsのActive Recordライブラリにおける機能の一つです
親子関係にあるオブジェクトの属性を一緒に保存、更新するためのもの
フォームから親オブジェクトとその子オブジェクトを同時に、保存変更することができます

https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html

# 事前準備
:::note alert
注意⚠️
こちらのポートフォリオはbootstrapを導入しているため、
jqueryをインストール済みです！
Javascriptでjqueryを使用しているため、もしこの記事を参考にされる方がいらっしゃいましたら、インストールしてから実装をするようにお願いいたします🙇
:::

jqueryについてはこちらの記事がわかりやすかったです！

https://qiita.com/oxxxkura/items/59d5d9ad15f3e8be8d76

https://www.kagoya.jp/howto/it-glossary/web/jquery/

# 実装

#### modelに記述
アソシエーションを組み、ActiveRecordNestedAttributesを使用できるよう記述

親要素（Planモデル）
```plan.rb
class Plan < ApplicationRecord

  # アソシエーション
  belongs_to :user
  has_many :plan_details, dependent: :destroy
  :
  
  # 子モデル（plan_details）の属性を受入れ、更新や削除を許可する
  accepts_nested_attributes_for :plan_details, allow_destroy: true
  :
end
```
親要素には`accepts_nested_attributes_for`を記述し、子要素の属性を受け入れます

子要素(PlanDetailモデル)
```plan_detail.rb
class PlanDetail < ApplicationRecord

  # アソシエーション
  belongs_to :plan

end
```
# 新規投稿編

#### controllerに記述

```plans_controller.rb
class Public::PlansController < ApplicationController

  def new
    @plan = Plan.new
    # PlanDetailsモデルのインスタンス作成
    @plan_detail = @plan.plan_details.build
  end
  
  def create
    @plan = current_user.plans.new(plan_params)
    @plan.save
    redirect_to plan_path(@plan)
  end
  
  private
  
  def plan_params
    params.require(:plan).permit(:title, :body, :is_draft, plan_details_attributes: [:id, :title, :body, :_destroy, :address, :latitude, :longitude])
  end
end
```

`@plan.plan_details.build`を記述することで、
ネストされたplan_detailのインスタンス変数を作成することができます

`plan_params`ストロングパラメーターの指定
通常はplanのカラムのみ許可しますが、今回はplan_detailのカラムも一緒に許可します！

`plan_details_attributes:`の後に、配列でplan_detailのカラムを記載します

`:_destroy`
のちに投稿済みをedit画面で編集する際、子要素を削除して更新ボタンを押した場合に
子要素を削除するために`:_destroy`の記述が必要です

#### view(new)作成
レイアウトの部分や関係のない機能は省略しています！

```new.html.erb
<%= form_with model: plan, url: plans_path do |f| %>

  <h4>プラン概要</h4>
  <%= f.text_field :title %>
  <%= f.text_area :body %>

  <h4>スケジュール</h4>
  <%= f.fields_for :plan_details do |plan_detail| %>
    <%= plan_detail.text_field :title %>
    <%= plan_detail.text_area :body %>
    <%= plan_detail.text_field :address %>
  <% end %>

  <div id="plan_details_wrapper"></div>
    <%= link_to '+', '#', id: 'add_plan_details' %>
  </div>
  
  <%= f.submit '投稿' %>
<% end %>
```

planのform_withの中にplan_detailのf.fields_forが入っています

`<%= link_to '+', '#', id: 'add_plan_details' %>`
この＋ボタンをクリックすることにより`<div id="plan_details_wrapper"></div>`に
新しいplan_detailのフォームが追加されることになります(Javascriptで)
また、Javascriptでフォームを追加するため、リンクをクリックしたときどこにも飛ばないようにするため`'#'`の記述があります

#### Javascript

```application.js
// plans/showでplan_detailsの入力フォームを追加するための記述
$(document).ready(function() {
  var wrapper = '#plan_details_wrapper';
  var addButton = '#add_plan_details';
  var x = 1;

  $(document).on("click", addButton, function(e) {
    e.preventDefault();
    x++;

    var formHtml = `
    <div class="row nested-fields">
      <div class="col-10">
        <input type="text" class="form-control mt-4" name="plan[plan_details_attributes][${x}][title]" placeholder="詳細タイトル">
      </div>
      <div class="col-2 d-flex align-items-center">
        <a href="#" class="remove_field btn btn-danger my-3">
          <i class="fa-solid fa-trash" style="color: #ffffff;"></i>
        </a>
      </div>
      <div class="col-12">
        <textarea class="form-control mt-3" name="plan[plan_details_attributes][${x}][body]" placeholder="詳細説明"></textarea>
        <input type="text" class="form-control my-3" name="plan[plan_details_attributes][${x}][address]" placeholder="住所">
      </div>
      <input type="hidden" name="plan[plan_details_attributes][${x}][_destroy]" class="destroy-field" value="false">
    </div>
    `;

    $(wrapper).append(formHtml);
  });
  
// plans/showでplan_detailsの入力フォームを削除するための記述
  $(document).on("click", ".remove_field", function(e) {
    e.preventDefault();
```

詳しく解説していきます

```application.js
// plans/showでplan_detailsの入力フォームを追加するための記述
$(document).ready(function() {
  var wrapper = '#plan_details_wrapper';
  var addButton = '#add_plan_details';
  var x = 1;
```
`(document).ready(function()`
ドキュメントを読み込んだら始めるよ！という意味です

`var wrapper = '#plan_details_wrapper';`
new.html.erbの`<div id="plan_details_wrapper"></div>`idの部分をwrapperに代入します

`var addButton = '#add_plan_details';`
new.html.erbの`<%= link_to '+', '#', id: 'add_plan_details' %>`idの部分をaddButtonに代入します

`var x = 1;`
xに１を代入します
追加するフォームの各入力フィールドを識別するために、一意のインデックスを付与します

```application.js
  $(document).on("click", addButton, function(e) {
    e.preventDefault();
    x++;
```

`$(document).on("click", addButton, function(e) {`
先ほど代入した`addButton`をクリックしたときの動作を指定しています

`e.preventDefault();`
デフォルトの動作を防ぐ
`addButton`をクリックした時に、フォームを送信したりページをリロードしたりすることを防いでいます

`x++;`
x + 1と同じ意味です

```application.js
  var formHtml = `
    <div class="row nested-fields">
      <div class="col-10">
        <input type="text" class="form-control mt-4" name="plan[plan_details_attributes][${x}][title]" placeholder="詳細タイトル">
      </div>
      <div class="col-2 d-flex align-items-center">
        <a href="#" class="remove_field btn btn-danger my-3">
          <i class="fa-solid fa-trash" style="color: #ffffff;"></i>
        </a>
      </div>
      <div class="col-12">
        <textarea class="form-control mt-3" name="plan[plan_details_attributes][${x}][body]" placeholder="詳細説明"></textarea>
        <input type="text" class="form-control my-3" name="plan[plan_details_attributes][${x}][address]" placeholder="住所">
      </div>
      <input type="hidden" name="plan[plan_details_attributes][${x}][_destroy]" class="destroy-field" value="false">
    </div>
  `;

    $(wrapper).append(formHtml);
  });
```
`var formHtml =以下略`
`formHtml`に追加するフォームの記述を代入しています
記述方法はコメントで教えていただいたテンプレートリテラルで記述しました！
バッククォート（`）で囲むことで複数行の文字列をそのまま記述することが可能になります！
こちらの記事を参照しました↓

https://shotanukumizu-1000.hatenablog.com/entry/20220121

`$(wrapper).append(formHtml);`
最初に代入した`wrapper`の箇所に`formHtml`を追加(append)します

```application.js
// plans/showでplan_detailsの入力フォームを削除するための記述
  $(document).on("click", ".remove_field", function(e) {
    e.preventDefault();
```

追加フォームにのみある削除ボタンをクリックすると、追加したフォームを削除することができます

`e.preventDefault();`
先ほどと同じように、`remove_field`をクリックした時に、フォームを送信したりページをリロードしたりすることを防いでいます

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3782860/aea896d9-2561-7750-e623-63ce4fd66898.png">

以上で、新規登録編は終了です。
続いて更新編に移ります！

# 投稿更新編

#### controllerに記述

```plans_controller.rb
class Public::PlansController < ApplicationController

  def edit
    @plan = Plan.find(params[:id])
  end

  def update
    @plan = Plan.find(params[:id])
    @plan.update(plan_params)
    redirect_to plan_path(@plan)
  end
  
  private

  def plan_params
    params.require(:plan).permit(:title, :body, :is_draft, :description, plan_details_attributes: [:id, :title, :body, :_destroy, :address, :latitude, :longitude])
  end

end
```

ここは至ってシンプルなため説明は割愛させていただきます！

#### view(edit)作成
レイアウトの部分や関係のない機能は省略しています！

```edit.html.erb
<%= form_with model: @plan, url: plan_path(@plan), method: :patch do |f| %>

  <h4>プラン概要</h4>
  <%= f.text_field :title, data: { plan_id: @plan.id } %>
   <%= f.text_area :body %>

  <h4>スケジュール</h4>
  <%= f.fields_for :plan_details do |plan_detail| %>
    <%= plan_detail.text_field :title, data: { plan_detail_id: plan_detail.object.id } %>

    <%= link_to "削除", "#", class: "remove_field" %>

    <%= plan_detail.text_area :body %>
    <%= plan_detail.text_field :address %>

    <%= plan_detail.hidden_field :_destroy, class: 'destroy-field' %>
  <% end %>

  <div id="plan_details_wrapper"></div>
    <%= link_to '+', '#', id: 'add_plan_details' %>

 <%= f.submit "投稿する", class: "btn btn-primary mx-auto" %>
<% end %>
 ```

 新規投稿と違う部分を解説🌱

 `<%= link_to "削除", "#", class: "remove_field" %>`
 すでに投稿している子要素を削除できないと困るので削除ボタンを作っています

 `<%= plan_detail.hidden_field :_destroy, class: 'destroy-field' %>`
 隠しフォームで`_destroy`を送信するフォームを作成しています
 変更前にあったはずの子要素を削除して親要素を変更するとエラーが起きてしまうため、
 子要素を削除した場合、そのエラーは無視していいですよーとrailsに教えてあげるためにあります

 こちらの記事を参考にさせていただきました！

https://qiita.com/yummy888/items/454392cf0816cf078bb1

 #### Javascript
 ```application.js
 // plans/editでplan_detailsの入力フォームを削除するための記述
    if(confirm('この詳細を削除してもよろしいですか？')) {
      var removeButton = $(e.target);
      var destroyField = removeButton.closest('.nested-fields').find('.destroy-field');
      if (destroyField.length > 0) {
        destroyField.val('true');
        removeButton.closest('.nested-fields').hide();
        console.log('Status: success - The item was marked for deletion.');
      } else {
        removeButton.closest('.nested-fields').remove();
        console.log('Status: success - The new item was removed.');
      }
    } else {
      console.log('Status: cancelled - The item was not deleted.');
    }
  });
});
```
詳しく解説していきます

 ```application.js
 // plans/editでplan_detailsの入力フォームを削除するための記述
    if(confirm('この詳細を削除してもよろしいですか？')) {
      var removeButton = $(e.target);
      var destroyField = removeButton.closest('.nested-fields').find('.destroy-field');
```

`if(confirm('この詳細を削除してもよろしいですか？')) {`
削除ボタンをクリックした後、上記の確認ダイアログを表示し、OKをクリックした場合のみ{}の中の処理が実行

![スクリーンショット 2024-06-20 2.14.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3782860/b41051b6-d53a-f61e-41a6-b247b024ce9f.png)

`var removeButton = $(e.target);`
クリックされた削除ボタン自身`(e.target)`を`removeButton`に代入

`var destroyField = removeButton.closest('.nested-fields').find('.destroy-field');`

先ほどedit画面で確認した、``<%= plan_detail.hidden_field :_destroy, class: 'destroy-field' %>``を探してきて`destroyField`に代入

```application.js
if (destroyField.length > 0) {
  destroyField.val('true');
  removeButton.closest('.nested-fields').hide();
  console.log('Status: success - The item was marked for deletion.');
```

`if (destroyField.length > 0) {`
`destroyField`要素が存在するか確認

`destroyField.val('true');`
`destroyField`の値を 'true' に設定

`removeButton.closest('.nested-fields').hide();`
`nested-fields` 要素を非表示にする

`console.log`
削除が成功したことをコンソールに出力します↓

![スクリーンショット 2024-06-20 2.27.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3782860/7a04ada4-31d4-9386-97da-7292500add34.png)

```application.js
     } else {
       removeButton.closest('.nested-fields').remove();
       console.log('Status: success - The new item was removed.');
     }
```
`removeButton.closest('.nested-fields').remove();`
destroyField が存在しない場合、edit画面で新しく追加されたフィールドであるため、
.nested-fields 要素を完全に削除します

`console.log`
削除が成功したことをコンソールに出力します

```application.js
   } else {
     console.log('Status: cancelled - The item was not deleted.');
   }
 });
});
```

ユーザーが削除の確認ダイアログで「キャンセル」をクリックした場合、
削除は実行されず、コンソールに削除がキャンセルされたことを示すメッセージを出力します


# 投稿削除編

#### controllerに記述

```plans_controller.rb
class Public::PlansController < ApplicationController

  def destroy
    @plan = Plan.find(params[:id])
    @plan.destroy
    redirect_to mypage_path
  end
  
end
```

親要素が削除されれば、子要素は一緒に削除されます！

# さいごに
コメントにてご指摘いただき、２件修正・追記しています。
ご指摘いただきありがとうございました！
また、誤り等ございましたら教えていただけますと幸いです🙇

# 参照記事

https://zenn.dev/murakamiiii/articles/5ecefb7a58d1ef

https://zenn.dev/ysi831/articles/0faa4c301e7d9f

https://qiita.com/okiiiiiina/items/62684d875986352e9664

https://qiita.com/okiiiiiina/items/8868a7fcb6ec0817d064

https://qiita.com/yummy888/items/454392cf0816cf078bb1

https://qiita.com/oxxxkura/items/59d5d9ad15f3e8be8d76

https://www.kagoya.jp/howto/it-glossary/web/jquery/