🙆

Rails fields_forについて

2023/06/17に公開

はじめに

ポートフォリオ作成中のプログラミング初学者です🔰
今日は元々作成済だったフィットネス新規登録フォームに
別モデルのデータも登録できるようにしました!

あまり記事が見つからず、苦戦しましたがなんとか動くようになったので
間違いもあると思いますが記録として残しておきます!
JavaScriptの勉強にもなりました💪🏻

完成イメージ

新規登録ページで、

筋トレメニュー追加はこちらのボタンを押すと、

  • 種目・重量・レップ・セット数のフォームが出現します!

このように分けて作った理由は、

  • あくまでもフィットネス登録なので、筋トレ以外の運動がメインで登録できる
  • 筋トレメニューを複数登録したい

以上の理由で、別で選択できるようにしました!

筋トレメニュー追加はこちらのボタンを押すと一個ずつフォームが増えていき
下記のように5個まで登録できるようになっています!

  • ➖ボタンを押すと表示したフォームをまた一個ずつ隠すことができます🙆🏻‍♀️

fields_forとは?

まず、、
fields_forヘルパーとは?

  • 親モデルに紐付いた子モデルのデータを編集するときに使用する!
  • 同じフォームで別のモデルオブジェクトのフィールドをレンダリングしたいときに便利!
  • accepts_nested_attributes_forと組み合わせて使用することで、親オブジェクトのフォーム送信時に関連オブジェクトの属性も同時に保存することができる!
<%= form.fields_for :association_name do |builder| %>
  <!-- フォームフィールドのコード -->
<% end %>
# Parentモデルのフォーム
<%= form_with model: @parent do |f| %>
  <!-- Parentモデルの属性のフォームフィールド -->
  <%= f.label :name %>
  <%= f.text_field :name %>

  <!-- Childモデルのフォームフィールド -->
  <%= f.fields_for :child do |child_form| %>
    <%= child_form.label :age %>
    <%= child_form.number_field :age %>
  <% end %>

  <%= f.submit %>
<% end %>

実装

model

関連付けをしていきます!

フィットネス投稿モデル(post_workout)

app/models/post_workout.rb
class PostWorkout < ApplicationRecord
:
has_many :workout_menus, dependent: :destroy
accepts_nested_attributes_for :workout_menus, allow_destroy: true, reject_if: :all_blank, limit: 5end

~解説~

accepts_nested_attributes_for

  • Active Recordモデルで関連する別のモデルの属性をネストした形式で保存するための機能です。具体的には、親モデルのフォームから子モデルの属性を一緒に送信し、関連を介して子モデルを作成または更新することができます。
    accepts_nested_attributes_forのオプションを使用することで、さまざまな振る舞いをカスタマイズできます。

allow_destroy: true

  • このオプションを使用すると、親モデルのフォームで子モデルの削除を許可します。フォームから削除フラグを設定すると、関連する子モデルが削除されます。

reject_if: :all_blank

  • このオプションを使用すると、空の子モデルを自動的に拒否します。つまり、子モデルの属性がすべて空である場合、その子モデルは作成されません。これにより、フォームで入力されていない空の子モデルを無視することができます。

limit: 5

  • このオプションを使用すると、親モデルが受け入れる子モデルの最大数を制限できます。この場合、親モデルは最大で5つの子モデルを受け入れることができます。

したがって、PostWorkoutモデルがworkout_menusという関連モデルを受け入れ、関連する子モデルの作成と更新を許可します。フォームから送信された子モデルの属性が空である場合は無視され、最大で5つの子モデルを受け入れることができます。また、フォームから送信された削除フラグに基づいて子モデルを削除することもできます。

筋トレメニュー(workout_menu)

app/models/workout_menu.rb
class WorkoutMenu < ApplicationRecord
  belongs_to :post_workout
end

この時、belongs_toにも間違えてdependent: :destroyをつけてしまい削除ができなくなって焦りました笑

controller

フィットネス投稿(post_workout)

app/controllers/public/post_workouts_controller.rb
class Public::PostWorkoutsController < ApplicationController
   def edit
    @post_workout = PostWorkout.find(params[:id])
   end

  def new
    @post_workout = PostWorkout.new
+    5.times { @post_workout.workout_menus.build }
  end
  
  def destroy
    @post_workout = PostWorkout.find(params[:id])
+    @post_workout.workout_menus.destroy_all
    @post_workout.destroy
    flash[:notice] = "削除が完了しました"
    redirect_to post_workouts_path   
  end

  private
  
  def post_workout_params
    params.require(:post_workout).permit(:end_user_id, :image, :start_time, :title, :site, :time, :memo,
+      workout_menus_attributes: [:id, :title, :weight, :reptition_count, :set_count, :_destroy])
  end
end

~解説~

5.times { @post_workout.workout_menus.build }

  • PostWorkoutオブジェクトに関連するWorkoutMenuオブジェクトを5つ生成しています。

@post_workout.workout_menus.build

  • PostWorkoutオブジェクトに関連付けられたWorkoutMenuオブジェクトを生成するメソッドです。このメソッドを5回繰り返すことで、PostWorkoutオブジェクトに関連するWorkoutMenuオブジェクトを5つ生成しています。

これにより、new アクションで新規作成するフォームに初めから5つのWorkoutMenuフォームが表示されるようになります。

最初は一つのフォームだけ生成して、JQueryの方の記述でボタンを押した時にフォームがまた新しく生成されるようにしたかったのですが、複雑になりうまくいかず、最初に5つ作成してしまい非表示にしておき、ボタンを押した時に表示されるようにしたところうまく行きました。

ただ、フォームを入力した後に、ーボタンで非表示にしてもデータは消えるわけではなく残ったままになってしまっています(直せてないので余裕があれば後で追記します😣)

ストロング・パラメーター
◯◯_attributesのように記述して、子モデルのカラムも許可するようにします。
workout_menus_attributes: [:id, :title, :weight, :reptition_count, :set_count, :_destroy]

  • この記述で、親モデルのPostWorkoutのフォームから一緒にWorkoutMenuの各属性(:id, :title, :weight, :reptition_count, :set_count)を更新できるようにしています。

  • :_destroyは、特別な属性で先ほどモデルに記述したaccepts_nested_attributes_forというRailsのメソッドが提供する機能です。関連する子モデルのレコードを削除するために使用されます。これにtrueを設定して送信すると、該当のWorkoutMenuレコードが削除されます。

view

post_workouts配下のformの部分テンプレートに記載しています!
form_withの中にfields_forをネストさせて書きます!
(見やすくするために最大限レイアウトは省きました!)

app/views/public/post_workouts/_form.html.erb
<%= form_with model: post_workout , local: true do |f| %>
:
    # 筋トレメニュー追加用フォーム
    <div class="post-workout-menus">
      <div class="workout-menus-container" id="workout-menus">
        <%= f.fields_for :workout_menus do |menu_form| %>
          <div class="workout-menu" style="display: <%= 'none' if post_workout.new_record? %>;">
            <div class="form-group">
              <label>種目</label>
              <%= menu_form.text_field :title, class: 'form-control' %>
            </div>
            <div class="form-group">
              <label>重量</label>
              <%= menu_form.number_field :weight, class: 'form-control', step: '0.1', min: '0' %>
            </div>
            <div class="form-group">
              <label>レップ</label>
              <%= menu_form.number_field :reptition_count, class: 'form-control', step: '1', min: '0' %>
            </div>
            <div class="form-group">
              <label>セット数</label>
              <%= menu_form.number_field :set_count, class: 'form-control', step: '1', min: '0' %>
            </div>
          </div>
        <% end %>
      </div>
      <div>
        <% if post_workout.new_record? %>
        <button id="add-menu-button" type="button">筋トレメニュー追加はこちらから</button>
        メニューは5つまで追加可能
        <button id="hide-menu-button" type="button" class="btn btn-secondary"><i class="fa-sharp fa-solid fa-minus"></i></button>
        <% end %>
      </div>
    </div><% if post_workout.new_record? %>
      <%= f.submit '新規作成' %>
    <% else %>
      <%= f.submit '更新する' %>
    <% end %>
    </div>
  </div>
<% end %>
  
<script>
  document.getElementById("add-menu-button").addEventListener("click", function() {
    var container = document.getElementById("workout-menus");
    var workoutMenus = container.getElementsByClassName("workout-menu");
    
    // 非表示のフォームを検索し、最初の非表示フォームを表示する
    for (var i = 0; i < workoutMenus.length; i++) {
      if (workoutMenus[i].style.display === "none") {
        workoutMenus[i].style.display = "flex";
        break;
      }
    }
  });
  document.getElementById("hide-menu-button").addEventListener("click", function() {
    var container = document.getElementById("workout-menus");
    var workoutMenus = container.getElementsByClassName("workout-menu");
    
    // 非表示のフォームを検索し、最初の表示されているフォームを非表示にする
    for (var i = 0; i < workoutMenus.length; i++) {
      if (workoutMenus[i].style.display !== "none") {
        workoutMenus[i].style.display = "none";
        break;
      }
    }
  });
</script>
JavaScriptのコード解説

JavaScriptコードは、「筋トレメニュー追加はこちらから」と「削除」ボタンのクリックイベントをハンドリングしています。

「筋トレメニュー追加はこちらから」ボタンの処理
document.getElementById("add-menu-button").addEventListener("click", function() {
    var container = document.getElementById("workout-menus");
    var workoutMenus = container.getElementsByClassName("workout-menu");
    
    // 非表示のフォームを検索し、最初の非表示フォームを表示する
    for (var i = 0; i < workoutMenus.length; i++) {
      if (workoutMenus[i].style.display === "none") {
        workoutMenus[i].style.display = "flex";
        break;
      }
    }
});
  • "add-menu-button"というIDを持つ要素(ここでは「筋トレメニュー追加はこちらから」ボタン)がクリックされると、このコードが実行されます。このコードは、"workout-menus"というIDのコンテナ内にある、非表示(display: none)の"workout-menu"クラスの要素を検索し、最初に見つかったものを表示(display: flex)に変更します。

一つずつ説明していきます。
document.getElementById("add-menu-button").addEventListener("click", function() {...})

  • getElementByIdはDOM(Document Object Model)から特定のIDを持つHTML要素を取得するメソッドです。ここでは、"add-menu-button"というIDの要素を取得しています。

addEventListener

  • 特定のイベントリスナーをHTML要素に追加するメソッドで、第一引数にはイベントの種類(ここではクリックイベントである"click")を指定し、第二引数にはそのイベントが起こったときに実行される関数(ここでは無名関数)を指定します。つまり、この行のコードは「"add-menu-button"というIDの要素がクリックされたときに、後続の関数を実行する」という意味です。

var container = document.getElementById("workout-menus");

  • getElementByIdを用いて"workout-menus"というIDの要素を取得し、それを変数containerに格納しています。

var workoutMenus = container.getElementsByClassName("workout-menu");

  • getElementsByClassNameは特定のクラスを持つHTML要素の集合を取得するメソッドです。ここでは、先ほど取得したcontainer要素内から"workout-menu"というクラスを持つ全ての要素を取得し、それらを変数workoutMenusに格納しています。

for (var i = 0; i < workoutMenus.length; i++) {...}

  • ここで宣言されているforループは、workoutMenusに含まれる全ての要素を順に処理します。iはループカウンタで、0から始まりworkoutMenus.length(workoutMenusに含まれる要素の数)未満になるまで1ずつ増加します。

if (workoutMenus[i].style.display === "none") {...}

  • if文は指定された条件が真(true)であるときに、ブロック内のコードを実行します。ここでは、workoutMenus[i].style.display(現在処理している要素のdisplayスタイル属性)が"none"(非表示)であるかを確認しています。もし非表示であれば、workoutMenus[i].style.display = "flex";によってその要素を表示状態に変更し、break;によってforループを終了します。
「削除」ボタンの処理
document.getElementById("hide-menu-button").addEventListener("click", function() {
    var container = document.getElementById("workout-menus");
    var workoutMenus = container.getElementsByClassName("workout-menu");
    
    // 非表示のフォームを検索し、最初の表示されているフォームを非表示にする
    for (var i = 0; i < workoutMenus.length; i++) {
      if (workoutMenus[i].style.display !== "none") {
        workoutMenus[i].style.display = "none";
        break;
      }
    }
});
  • 同様に、"hide-menu-button"というIDを持つ要素(ここでは「削除」ボタン)がクリックされると、このコードが実行されます。このコードは、"workout-menus"というIDのコンテナ内にある、表示されている"workout-menu"クラスの要素を検索し、最初に見つかったものを非表示(display: none)に変更します。

このように、この2つのJavaScriptのコードは、HTMLの要素を表示・非表示にすることで、ユーザーが動的にフォームの項目を追加・削除できるようにしています。ただし、これはあくまで表示上の操作であり、サーバー側のデータには影響を与えません。データの追加・削除は、フォームを送信したときにサーバー側で行われます。

showページでも表示できるようにします!

app/views/public/post_workouts/show.html.erb
<h4>筋トレメニュー</h4>
      <% @post_workout.workout_menus.each do |menu| %>
      <div>
        <h2>種目</h2>
        <p><%= menu.title %></p>
        <h2>重量</h2>
        <p><%= menu.weight %>kg</p>
        <h2>回数</h2>
        <p><%= menu.reptition_count %>レップ</p>
        <h2>セット数</h2>
        <p><%= menu.set_count %>セット</p>
        </div>
      <% end %>

参考にさせていただいた記事🌱

https://qiita.com/mi-1109/items/82bde86493072e2dd2a4

https://qiita.com/kouuuki/items/5daf2b5f34273d8457f7

さいごに

記事もあまり見つからなくめちゃくちゃ時間をかけてしまいました🥲
できたと思ったら一つが動かなくなり、、を繰り返して
なんとか動くようになりましたが、まだ改善しなければいけない点もあり不十分です💦
また、抜けている記述や間違いなどあればぜひ教えていただけますと幸いです!

ひとまず時間はかかりましたが、同時にJavaScriptにも触れられてよかったです。
明日はいつもの勉強会の日なのですが、同じスクールの受講生の方と始めてお会いできるので楽しみです!
おやすみなさい〜〜🌙

Discussion