はじめに
レシピ共有サイト作成中です!
Rails 6.1.7.4
devaice導入済み
nemespace使用
post.rb
user.rb
ingrdient.rb(食材名、数量)
recipe_step.rb(作り方) のモデル作成済
コントローラー
posts_controller作成済み
今回はレシピの投稿をする上で材料、分量、作り方のフォームを動的に追加できるようにしていきます。
完成イメージ
一つの投稿がたくさんの材料、作り方を持っているようにするので、別テーブル(3つのテーブル)で作成します。
モデル
class Ingredient < ApplicationRecord
belongs_to :post
# 食材名、数量必須
validates :name, :amount, presence: true
end
validates :name, :amount, presence: true
モデルのnameとamountフィールドが空(null)でないことを確認し空の場合は保存されないようにする。
class RecipeStep < ApplicationRecord
belongs_to :post
# 作り方必須
validates :instructions, presence: true
end
validates :instructions, presence: true
モデルのinstructionフィールドが空(null)でないことを確認し空の場合は保存されないようにする。
has_many :recipe_steps, dependent: :destroy
# accepts_nested_attributes_forで子かラムを一緒に保存できるようになる。
accepts_nested_attributes_for :recipe_steps, reject_if: :all_blank, allow_destroy: true
has_many :ingredients, dependent: :destroy
accepts_nested_attributes_for :ingredients, reject_if: :all_blank, allow_destroy: true
has_many :notifications, dependent: :destroy
accepts_nested_attributes_for :recipe_steps は、親モデルのフォームからRecipeStepモデルの属性も一緒に更新・保存できるように設定します。
reject_if: :all_blankは、不要な空レコードの生成を防ぎます。これがないと、ユーザーが空のフィールドを送信した場合でも新しいIngredientレコードが作成されてしまう可能性があります。
allow_destroy: trueは、関連する子レコードを簡単に削除できるようにします。これがないと、子レコードの削除は手動で行う必要があります。
コントローラー
class Public::PostsController < ApplicationController
def new
@post = Post.new
@post.ingredients.build # 画面で使うための空の食材オブジェクト
@post.recipe_steps.build # 画面で使うための空のレシピステップオブジェクト
end
def create
@post = current_user.posts.new(post_params)
if @post.save
redirect_to post_path(@post), notice: '投稿しました'
else
flash.now[:alert] = '投稿に失敗しました。必須の項目の入力をしてください'
render :new
end
end
private
def post_params
params.require(:post).permit(
:title, :description, :main_vegetable, :season, :is_public, :image, :tag_list, :serving_size,
ingredients_attributes: [:id, :name, :amount, :_destroy],
recipe_steps_attributes: [:id, :instructions, :_destroy]
)
end
end
@post.ingredients.build** と **@post.recipe_steps.build
のコードは、new アクションで用意されている@post オブジェクトに紐づく空のingredients と recipe_steps オブジェクトを生成します。この目的は、ユーザーが新規の Post を作成する際に、それに対応する ingredients と recipe_steps も同時にフォームで入力できるようにするためです。
具体的には、new.html.erb などのビューファイルで、fields_for メソッドなどを使ってこれらの空オブジェクトに対応するフォームフィールドを生成できます。
:title, :description, :main_vegetable, :season, :is_public, :image, :tag_list, :serving_size
これらはPostモデルの属性です。これらのフィールドは、ユーザーがフォームから送信する値をそのまま受け取ります。
ingredients_attributes: [:id, :name, :amount]
ingredientsモデルの属性です。:idはすでに存在するingredientsレコードを特定するため、:nameと:amountは新しいデータを作成するか、既存のデータを更新するために使います。
recipe_steps_attributes: [:id, :instructions]
recipe_stepsモデルの属性です。:idはすでに存在するrecipe_stepsレコードを特定するため、:instructionsは新しいデータを作成するか、既存のデータを更新するために使います。
これらのネストされたパラメーターは、Railsのaccepts_nested_attributes_forメソッドと組み合わせて使用されます。Postモデル内でこのメソッドが呼ばれると、Postオブジェクトと一緒にingredientsとrecipe_stepsのオブジェクトも保存できるようになります。また、_destroy
を設定することでフォームの削除を行えるようにします。
ビューページ
材料、分量フォーム
材料と分量フォームの部分テンプレートを作成します
今回はpublic/posts内に_ingredient_fields.html.erbを作成します。
<div class="ingredient-fields row align-items-center">
<!--編集の際データベースからも削除できるように-->
<%= f.hidden_field :_destroy %>
<div class="col">
<div class="form-group">
<%= f.text_field :name, placeholder: "材料名(必須)", class: 'form-control light-placeholder' %>
</div>
</div>
<div class="col">
<div class="form-group">
<%= f.text_field :amount, placeholder: "分量(必須)", class: "form-control light-placeholder" %>
</div>
</div>
<div class="col-auto">
<button type="button" class="btn btn-danger remove-ingredient mb-3">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<script>
$(document).ready(function(){
// 削除ボタンがクリックされたときの処理
$(document).on('click', '.remove-ingredient', function(){
// クリックされた.削除ボタンに最も近い.ingredient-fieldsを削除する
$(this).closest('.ingredient-fields').find("input[name$='[_destroy]']").val('true');
// そして、そのフィールドを隠す
$(this).closest('.ingredient-fields').hide();
});
});
</script>
<%= f.hidden_field :_destroy %>
この隠しフィールドは、親モデル(post)が子モデル(Ingredient)の属性と一緒に更新されるときに、子モデルのレコードを削除するための指示を持っています。
JavaScript部分の説明(フォームの削除)
postモデルにallow_destroy: trueを指定している必要があります。
$(document).ready(function(){...});:
文書が読み込まれた(DOMが準備された)時に実行されます。
$(document).on('click', '.remove-ingredient', function(){...});: .remove-ingredient
remove-ingredientクラスを持つ要素(削除ボタン)がクリックされたときに実行される関数です。
(this).closest('.ingredientfields').find("input[name$='[_destroy]']").val('true');:
クリックされたボタン(.remove-ingredient)
に最も近い.ingredient-fieldsを探し、その中のname属性が[_destroy]で終わるinputタグのvalueをtrueに設定します。Railsはこの_destroyフィールドを見て、関連するレコードをデータベースから削除するかどうかを判断します。
$(this).closest('.ingredient-fields').hide();:
クリックされたボタンに最も近い.ingredient-fieldsを非表示にします。
このようにして、材料の名前と分量を入力するフィールドが動的に削除できるようになっています。JavaScriptのjQueryを使用して、ユーザーがフィールドを削除できるようにしています。
作り方フォーム
作り方フォームについても材料フォームと同じように部分テンプレートを作成します。
今回は、_recipe_step_fields.html.erbを作成します。
<div class="instruction-field form-group">
<%= f.hidden_field :_destroy %>
<div class="form-group">
<%= f.label :instructions, raw("ステップ" + content_tag(:span, "(必須)", class: "required-label")) %>
<div class="input-group">
<%= f.text_area :instructions, class: "form-control", rows: "2" %>
<div class="input-group-append">
<button type="button" class="btn btn-danger remove-instruction">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
</div>
</div>
<script>
$(document).ready(function(){
// 削除ボタンがクリックされたときの処理
$(document).on('click', '.remove-instruction', function(){
// クリックされた削除ボタンに最も近い.instruction-fieldにある_destroyフィールドをtrueに設定
$(this).closest('.instruction-field').find("input[name$='[_destroy]']").val('true');
// そして、そのフィールドを隠す(removeでは削除、hideでは要素を隠す)
$(this).closest('.instruction-field').hide();
});
});
</script>
ingredient_fieldsと同じようにrecip_step_fieldsも作成します。
フォームの呼び出し部分(投稿ページ)
<!-- 材料、分量フォームの隠し要素テンプレート、display: noneを指定しているため表示はされない -->
<div id="ingredient-fields-template" style="display: none;">
<!-- 新しいIngredientオブジェクトを作成, 新たに追加されるフォームに一意なインデックスを付与 -->
<%= f.fields_for :ingredients, Ingredient.new, child_index: "new_ingredient" do |ingredient_field| %>
<%= render 'ingredient_fields', f: ingredient_field %>
<% end %>
</div>
<!--すでにデータベースに保存されているか、または新しくユーザーが追加した「材料」に関する情報を表示するためのフォーム-->
<%= f.fields_for :ingredients do |ingredient_field| %>
<%= render 'ingredient_fields', f: ingredient_field %>
<% end %>
<div id="ingredients"></div>
<div>
<!--材料、分量フォームを追加できる-->
<button type="button" id="add-ingredient" class="btn mb-4" style="background-color: #FFD5EC;">材料を追加</button>
</div>
<!--作り方フォームの隠し要素テンプレート-->
<div id="recipe_step-fields-template" style="display: none;">
<!--新しいRecipeStepオブジェクトを作成-->
<%= f.fields_for :recipe_steps, RecipeStep.new, child_index: "new_recipe_step" do |recipe_step_field| %>
<%= render 'recipe_step_fields', f: recipe_step_field %>
<% end %>
</div>
<!--すでにデータベースに存在するか、または新しく追加された「作り方」ステップを表示するフォーム-->
<%= f.fields_for :recipe_steps do |recipe_step_field| %>
<%= render 'recipe_step_fields', f: recipe_step_field %>
<% end %>
<div id="recipe_steps"></div>
<div>
<!--作り方フォームを追加できる-->
<button type="button" id="add-recipe_step" class="btn" style="background-color: #FFD5EC;">作り方を追加</button>
</div>
材料フォームでの説明
<div id="ingredient-fields-template" style="display: none;">
の部分が隠し要素テンプレートです。display: none; によってこの部分は非表示になっています。このテンプレートはJavaScriptによってコピーされ、新たな「材料」フォームとして追加されます。
<%= f.fields_for :ingredients, Ingredient.new, child_index: "new_ingredient" do |ingredient_field| %>
の部分で、新しいIngredientオブジェクトを作成しています。
実際の「材料」フォーム: <%= f.fields_for :ingredients do |ingredient_field| %>
の部分が、すでにデータベースに保存されているか、または新しくユーザーが追加した「材料」に関する情報を表示するためのフォームです。
材料を追加ボタンがクリックされると、JavaScriptが上記の隠し要素テンプレートをコピーして新しい「材料」フォームを追加することができます。
このような形で、動的に「材料」の数を増減させることができるフォームが作成できます。
<script>
// DOMContentLoaded文書のロード、DOMが完全に読み込まれた後に以下のコードが実行される
document.addEventListener("DOMContentLoaded", function() {
// "add-ingredient" IDを持つHTML要素(ボタン)を取得、「材料を追加」ボタンにクリックイベントを追加
document.getElementById("add-ingredient").addEventListener("click", function() {
// "ingredient-fields-template"というIDを持つdiv要素の内部HTML(隠しテンプレート)を取得
var content = document.getElementById("ingredient-fields-template").innerHTML;
var uniqueId = new Date().getTime();
// replace: テンプレート内のプレースホルダーを一意なIDで置き換え
content = content.replace(/new_ingredient/g, uniqueId);
// 新しい材料フォームをDOMに追加
document.getElementById("ingredients").insertAdjacentHTML('beforeend', content);
});
});
// 作り方フォーム追加
document.addEventListener('DOMContentLoaded', () => {
document.querySelector('#add-recipe_step').addEventListener('click', () => {
let content = document.getElementById('recipe_step-fields-template').innerHTML;
let uniqueId = new Date().getTime();
content = content.replace(/new_recipe_step/g, uniqueId);
document.getElementById('recipe_steps').insertAdjacentHTML('beforeend', content);
});
});
</script>
材料、数量フォームのの追加の部分について
DOMContentLoade
で文書の読み取りを行い、文書のロードが完了したら、次の処理を実行します。
getElementById("add-ingredient")
add-ingredient" IDを持つHTML要素(ボタン)を取得します。
ddEventListener("click", function() {}):
「材料を追加」ボタンにクリックイベントを追加します。
innerHTML
"ingredient-fields-template"というIDを持つdiv要素の内部HTML(隠しテンプレート)を取得しています。
replace
content.replace() メソッドを使って、取得したHTMLテンプレート内のプレースホルダー(Time.now.to_i)を一意なID(uniqueId)で置き換えています。正規表現を使い、全てのインスタンスを一度に置き換えるようにしています。
この置換処理によって、新しく追加される各材料フォームに一意なIDが割り当てられます。これにより、バックエンドでこれらのフォームを独立して識別・処理することが可能になります。
insertAdjacentHTML
メソッドを使用して、新しく生成したHTMLコンテンツ(content)をDOMに追加します。
beforeend
は、指定された要素(この場合はIDが ingredients のdiv要素)の最後の子要素として新しいHTMLを挿入することを意味します。
補足
document.addEventListener("DOMContentLoaded", function() {
document.getElementById("add-ingredient").addEventListener("click", function() {
document.addEventListener('DOMContentLoaded', () => {
document.querySelector('#add-recipe_step').addEventListener('click', () => {
今回javascriptの部分で function()
と アロー関数() => {}
を両方使用していますがどちらでも使用できました。
簡単に違いをまとめると以下のような感じです。
function() の特徴
関数の名前を持つことができる:これはスタックトレースで役立ち、デバッグが容易になります。
独自のthisを持つ:function()は呼び出されたコンテキストに基づいてthisの値を持ちます。
アロー関数 () => {} の特徴
短縮して書くことができる:よりコンパクトなコードが書けます。
thisをレキシカルにキャプチャする:アロー関数のthisは、それを囲むコードのthisを継承します。これは、特にイベントハンドラやコールバック関数で役立ちます。
どちらを使用すべきか
イベントハンドラやコールバック内で元のコンテキストのthisを使用したい場合:アロー関数を使用します。
新しいコンテキストでのthisが必要な場合:通常のfunction()を使用します。
短い関数やラムダ関数のようなものを書く場合:アロー関数でコードをシンプルにします。
関数の名前が必要な場合やコンストラクタとして関数を使用する場合:function()を使用します。
詳しくは以下のサイトを参考にさせていただきました。