はじめに
ポートフォリオ制作中です!
書こう書こうとずっと思ってはいたのですが、なかなか手がつけられず…
忘れないうちに書き始めようと決心しました…!
間違えているところ等ございましたら、ご指摘いただけますと幸いです🙇
今回はJavascriptでフォームを何個でも追加できるような実装をアウトプットします✍️
accepts_nested_attributes_for
を使用して子要素を親要素で保存や変更できるようにしています!
(後々調べると、accepts_nested_attributes_for
は使用しない方がいいかもという記事をちらほら見かけますが、ポートフォリオも終盤で今更変えられないのでこのままでいきます!)
完成図
旅行やデートのプランを共有するSNSサイト「Go out Planning」を絶賛作成中です
プラン概要の中に何個でもスケジュールフォームを追加できるようにするため、
今回は動的フォーム追加を実装します!
Gemの利用はしていません!
cocoonというGemがあるようなので、
気になる方は調べてください!(最初の頃はGemの存在を知らんかったんや…!)
ER図
Plan:PlanDetailで1:Nの関係です
Active Record Nested Attributesを使用して、
Planのnew画面でPlanDetailも合わせて保存できるようにしています
Active Record Nested Attributesとは
RailsのActive Recordライブラリにおける機能の一つです
親子関係にあるオブジェクトの属性を一緒に保存、更新するためのもの
フォームから親オブジェクトとその子オブジェクトを同時に、保存変更することができます
事前準備
注意⚠️
こちらのポートフォリオはbootstrapを導入しているため、
jqueryをインストール済みです!
Javascriptでjqueryを使用しているため、もしこの記事を参考にされる方がいらっしゃいましたら、インストールしてから実装をするようにお願いいたします🙇
jqueryについてはこちらの記事がわかりやすかったです!
実装
modelに記述
アソシエーションを組み、ActiveRecordNestedAttributesを使用できるよう記述
親要素(Planモデル)
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モデル)
class PlanDetail < ApplicationRecord
# アソシエーション
belongs_to :plan
end
新規投稿編
controllerに記述
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)作成
レイアウトの部分や関係のない機能は省略しています!
<%= 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
// 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();
詳しく解説していきます
// 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に1を代入します
追加するフォームの各入力フィールドを識別するために、一意のインデックスを付与します
$(document).on("click", addButton, function(e) {
e.preventDefault();
x++;
$(document).on("click", addButton, function(e) {
先ほど代入したaddButton
をクリックしたときの動作を指定しています
e.preventDefault();
デフォルトの動作を防ぐ
addButton
をクリックした時に、フォームを送信したりページをリロードしたりすることを防いでいます
x++;
x + 1と同じ意味です
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
に追加するフォームの記述を代入しています
記述方法はコメントで教えていただいたテンプレートリテラルで記述しました!
バッククォート(`)で囲むことで複数行の文字列をそのまま記述することが可能になります!
こちらの記事を参照しました↓
$(wrapper).append(formHtml);
最初に代入したwrapper
の箇所にformHtml
を追加(append)します
// plans/showでplan_detailsの入力フォームを削除するための記述
$(document).on("click", ".remove_field", function(e) {
e.preventDefault();
追加フォームにのみある削除ボタンをクリックすると、追加したフォームを削除することができます
e.preventDefault();
先ほどと同じように、remove_field
をクリックした時に、フォームを送信したりページをリロードしたりすることを防いでいます
以上で、新規登録編は終了です。
続いて更新編に移ります!
投稿更新編
controllerに記述
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)作成
レイアウトの部分や関係のない機能は省略しています!
<%= 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に教えてあげるためにあります
こちらの記事を参考にさせていただきました!
Javascript
// 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.');
}
});
});
詳しく解説していきます
// plans/editでplan_detailsの入力フォームを削除するための記述
if(confirm('この詳細を削除してもよろしいですか?')) {
var removeButton = $(e.target);
var destroyField = removeButton.closest('.nested-fields').find('.destroy-field');
if(confirm('この詳細を削除してもよろしいですか?')) {
削除ボタンをクリックした後、上記の確認ダイアログを表示し、OKをクリックした場合のみ{}の中の処理が実行
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
に代入
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
削除が成功したことをコンソールに出力します↓
} 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
削除が成功したことをコンソールに出力します
} else {
console.log('Status: cancelled - The item was not deleted.');
}
});
});
ユーザーが削除の確認ダイアログで「キャンセル」をクリックした場合、
削除は実行されず、コンソールに削除がキャンセルされたことを示すメッセージを出力します
投稿削除編
controllerに記述
class Public::PlansController < ApplicationController
def destroy
@plan = Plan.find(params[:id])
@plan.destroy
redirect_to mypage_path
end
end
親要素が削除されれば、子要素は一緒に削除されます!
さいごに
コメントにてご指摘いただき、2件修正・追記しています。
ご指摘いただきありがとうございました!
また、誤り等ございましたら教えていただけますと幸いです🙇
参照記事