入力フォームを動的に追加する機能の作成方法
背景
rails で 関連元と関連先が1対多の関係で、関連元のデータを作成するときに、関連先のデータを合わせて複数作成できたら良いなと思いました。
そこで関連先のデータを複数作成するために入力フォームを作成し、その入力フォームを動的に追加し、そのフォームから関連先のデータを複数作成できるようにしました。
これから動的な入力フォームを作成する人の参考になったら良いなと思い、その作成方法やロジックを記事にまとめてみました。
よかったら参考にしていただけると幸いです。
完成イメージ
実装手順
今回、作成する入力フォームは商品データを表すItemモデル(今回は関連元で1対多の1の方)のインスタンスを作成するものとします。
また、その入力フォームの中に製造日のデータを表すProductionDateモデル(今回は関連先で1対多の多の方)のインスタンスを作成するための入力フォームも作成します。
アソシエーションは下記の通りです。
# item.rb
class Item < ApplicationRecord
has_many :production_dates, dependent: :destroy
end
# production_date.rb
class ProductionDate < ApplicationRecord
belongs_to :item
end
その製造日のデータ(ProductionDateモデルのインスタンス)を追加・作成するための入力フォームを今回は動的に追加できるようにし、Itemモデルのインスタンスを1つ作成する際に、複数のProductionDateモデルのインスタンスを関連付けて作成できるようにします。
今回の入力フォームを動的に追加する機能を作成する手順は下記の通りです。
- 動的に増やすことができる入力フォームを作成
- 入力されたデータをもとに関連元と関連先のデータをそれぞれ作成
- Formオブジェクトの作成
動的に増やすことができる入力フォームを作成
まず、動的に増やすことができる入力フォームを作成していきます。
この動的に増やすことができる入力フォームは、あるボタンを押下すると、そのボタンを押下するたびに、入力フォームをどんどん増やすことができるフォームのことです。
下記は、動的に増やすことができる入力フォームのコードになります。
<%= form_with(model: @item, local: true) do |form| %>
<table>
・
・
・
<tr>
<td>
<div class="label-area">
<label for="manufacture_date">Manufacture Date</label>
<button type="button" id="add-production-date-btn">製造日を追加する</button>
</div>
</td>
<td>
<div class="production-date-area">
<%= form.fields_for :production_dates do |pd_form| %>
<div class="production-date-group" data-index="<%= pd_form.index %>">
<%= pd_form.date_field :manufacture_date %>
</div>
<% end %>
</div>
</td>
</tr>
<tr>
<td colspan="2"><%= form.submit "Create Item" %></td>
</tr>
</table>
<% end %>
<script>
// ページのDOMが完全に読み込まれたら処理を開始する
document.addEventListener('DOMContentLoaded', () => {
// 製造日入力フォームを表示するエリアを取得
const productionDateArea = document.querySelector('.production-date-area');
// 製造日追加ボタンを取得
const addProductionDateBtn = document.getElementById('add-production-date-btn');
// 製造日追加ボタンがクリックされたときの処理
addProductionDateBtn.addEventListener('click', () => {
// 既存のフォーム数を基に、新しいフォームのインデックスを決定
const newIndex = productionDateArea.children.length;
// 新しい製造日入力フォームを作成
const newGroup = document.createElement('div');
newGroup.className = 'production-date-group'; // フォームにクラスを追加
newGroup.dataset.index = newIndex; // 新しいインデックスをdata属性に設定
`;
// 新しいフォームをエリアに追加
productionDateArea.appendChild(newGroup);
});
});
</script>
ポイントは、fields_for ヘルパーを使用した同じフォーム内で関連モデルの入力を含む単一フォームを作成したことと、入力フォームを複数作成するロジックを持つ関数をJavascriptで作成したことです。
まず、fields_for ヘルパーについてですが、これは、同じフォーム内で関連するモデルのフィールドを表示するために使われます。
通常、この関連モデルは、メインのフォームモデルとActive Recordの関連付けを通じてつながっています。
今回の場合、Itemモデルに関連付けされたProductionDateモデルのデータをサーバー側へ送信するために使用されています。
次に、入力フォームを複数作成するロジックを持つ関数ですが、これは、「製造日を追加する」というボタンを押下するたびに実行されるようにしました。
入力されたデータをもとに関連元と関連先のデータをそれぞれ作成
次に、入力フォームから入力されたデータをもとに関連元と関連先のデータをそれぞれ作成していきます。
入力されたデータをもとに関連元と関連先のデータをそれぞれ作成するための処理が下記になります。
# items_controller.rb
class ItemsController < ApplicationController
def new
@item = Item.new
@item.production_dates.build
end
def create
form = ItemForm.new(item_params)
if form.save_item_with_dates
redirect_to items_path
else
render :new
end
end
private
def item_params
params.require(:item).permit(
:name, :description, :price, :stock,
production_dates: [:manufacture_date, :_destroy],
production_dates_attributes: [:manufacture_date, :_destroy]
)
end
end
行なっていることとしては、create
アクションで送信されたフォームデータを受け取り、ItemForm
というFormオブジェクトを使ってItem
とProductionDate
を同時に保存しています。
ポイントは、item_params
メソッドの production_dates と production_dates_attributes の部分でProductionDateモデルのmanufacture_dateカラムをストロングパラメータにしたことです。
production_dates はもともとあったProductionDateモデルのフォーム送られたデータとproduction_dates_attributes は 「製造日を追加する」ボタンを押下する度に作成されるフォームから送られたデータをそれぞれストロングパラメータにしています。
Formオブジェクトの作成
最後に、ItemモデルやProductionDateモデルのそれぞれのインスタンスを作成するためにFormオブジェクトを作成していきます。
ItemモデルやProductionDateモデルのそれぞれのインスタンスを作成するためのFormオブジェクトが下記になります。
# itetm_form.rb
class ItemForm
include ActiveModel::Model
attr_accessor :name, :description, :price, :stock, :production_dates, :production_dates_attributes
validates :name, :description, :price, :stock, presence: true
def save_item_with_dates
return false unless valid?
# 新しい Item インスタンスを作成
item = Item.new(
name: name,
description: description,
price: price,
stock: stock
)
# production_dates を処理
if production_dates.present?
# _destroy が "true" ではない場合のみ作成
unless production_dates[:_destroy] == "true"
item.production_dates.build(manufacture_date: production_dates[:manufacture_date])
end
end
# production_dates_attributes を処理
production_dates_attributes.each do |_, date_attributes|
# _destroy が "true" ではない場合のみ作成
next if date_attributes[:_destroy] == "true"
item.production_dates.build(manufacture_date: date_attributes[:manufacture_date])
end
# Item を保存
item.save!
end
end
このコードは、ItemForm
というクラスを使って、Item
(アイテム)とその関連するProductionDate
(製造日)を同時に管理し、保存するためのロジックを実装しています。
このクラスは Formオブジェクトというデザインパターン で、複雑なデータの操作を簡潔にまとめ、Item
モデルとその関連オブジェクト ProductionDate
モデルを扱っています。
ポイントは、include ActiveModel::Model
を行なったことです。
include ActiveModel::Model
を記述し、ActiveModel::Model
をインクルードすることで、通常のモデルのようにバリデーションやフォームのデータ管理ができるようになります。
以上が、入力フォームを動的に追加する機能の作成方法の説明になります。
まとめ
今回、入力フォームを動的に追加する機能の作成方法を紹介しました。
Formオブジェクトを実際に用いてフォームのロジックを作成したことがなかったので、とても良い経験になりました。
また、Active Model も今回使用した ActiveModel**::**Model
モジュール以外にもたくさん種類があると認識することができたのも良い経験になりました。
今後は、今回学習したFormオブジェクト以外のデザインパターンやActiveModel**::**Model
モジュール以外のモジュールなどを使用し、いろんな機能を実装していきたいです。
参考
Discussion