🤐

入力フォームを動的に追加する機能の作成方法

2024/10/05に公開

背景

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モデルのインスタンスを関連付けて作成できるようにします。

今回の入力フォームを動的に追加する機能を作成する手順は下記の通りです。

  1. 動的に増やすことができる入力フォームを作成
  2. 入力されたデータをもとに関連元と関連先のデータをそれぞれ作成
  3. 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オブジェクトを使ってItemProductionDateを同時に保存しています。

ポイントは、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 モジュール以外のモジュールなどを使用し、いろんな機能を実装していきたいです。

参考

https://qiita.com/koki_73/items/22247b5d49eab56ec158

https://railsguides.jp/form_helpers.html#fields-forヘルパー

https://railsguides.jp/active_model_basics.html#activemodel-modelモジュール

https://applis.io/posts/rails-design-pattern-form-objects#formオブジェクトの必要性

Discussion