如何为食谱应用表单添加最多5个唯一食材输入字段
Hey there! I’ve built similar nested forms for recipe apps before, so let’s walk through a solid implementation that meets your requirements: supporting pre-selected ingredients, custom input, and a max of 5 fields.
Step 1: Model Setup (Critical for Nested Attributes)
First, make sure your models are set up correctly to handle nested ingredients. Add these associations and nested attribute support:
# app/models/recipe.rb class Recipe < ApplicationRecord has_many :ingredients, dependent: :destroy accepts_nested_attributes_for :ingredients, reject_if: ->(attrs) { attrs[:name].blank? }, allow_destroy: true end # app/models/ingredient.rb class Ingredient < ApplicationRecord belongs_to :recipe validates :name, presence: true end
The reject_if option ensures empty ingredient fields don’t get saved, and allow_destroy lets us remove fields if needed.
Step 2: Controller Setup
In your recipes controller, initialize up to 5 empty ingredient fields so the form loads with the max allowed slots (we’ll also handle re-rendering with errors properly):
# app/controllers/recipes_controller.rb def new @recipe = Recipe.new # Initialize 5 empty ingredient fields (max allowed) 5.times { @recipe.ingredients.build } end def create @recipe = Recipe.new(recipe_params) if @recipe.save redirect_to @recipe, notice: "Recipe created successfully!" else # Re-render form with errors, re-initializing missing fields to keep 5 total (5 - @recipe.ingredients.size).times { @recipe.ingredients.build } render :new end end private def recipe_params params.require(:recipe).permit(:name, :description, ingredients_attributes: [:id, :name, :_destroy]) end
Step 3: ERB Form Implementation
Now, let’s build the form with both a dropdown for existing ingredients and a text field for custom inputs. We’ll also add controls to remove fields while enforcing the 5-field limit:
<%= form_with(model: @recipe) do |f| %> <% if @recipe.errors.any? %> <div id="error_explanation"> <h2><%= pluralize(@recipe.errors.count, "error") %> prohibited this recipe from being saved:</h2> <ul> <% @recipe.errors.each do |error| %> <li><%= error.full_message %></li> <% end %> </ul> </div> <% end %> <div class="field"> <%= f.label :name %> <%= f.text_field :name %> </div> <div class="field"> <%= f.label :description %> <%= f.text_area :description %> </div> <h3>Ingredients (Max 5)</h3> <%= f.fields_for :ingredients do |ingredient_form| %> <div class="ingredient-field-group"> <%= ingredient_form.hidden_field :_destroy %> <%# Dropdown for existing ingredients %> <%= ingredient_form.label :existing_ingredient, "Choose existing or enter custom:" %> <%= select_tag "existing_ingredients[]", options_for_select(["Select an ingredient"] + Ingredient.pluck(:name).uniq), class: "existing-ingredient-select" %> <%# Custom input field %> <%= ingredient_form.label :name, "Custom ingredient name" %> <%= ingredient_form.text_field :name, class: "custom-ingredient-input" %> <%# Remove button (only show if there's more than 1 field) %> <% if @recipe.ingredients.size > 1 %> <%= button_tag type: "button", class: "remove-ingredient-btn" do %> Remove Ingredient <% end %> <% end %> </div> <% end %> <%# Disable add button if we already have 5 ingredients %> <%= button_tag type: "button", id: "add-ingredient-btn", disabled: @recipe.ingredients.size >=5 do %> Add Ingredient <% end %> <div class="actions"> <%= f.submit %> </div> <% end %>
Step 4: JavaScript for Dynamic Behavior
Add this JavaScript to handle auto-filling custom inputs, removing fields, and enforcing the 5-field limit:
document.addEventListener('DOMContentLoaded', function() { // Auto-fill custom input when selecting an existing ingredient document.querySelectorAll('.existing-ingredient-select').forEach(select => { select.addEventListener('change', function() { const customInput = this.closest('.ingredient-field-group').querySelector('.custom-ingredient-input'); customInput.value = this.value !== "Select an ingredient" ? this.value : ""; }); }); // Remove ingredient field (mark for destruction instead of deleting) document.addEventListener('click', function(e) { if (e.target.classList.contains('remove-ingredient-btn')) { const group = e.target.closest('.ingredient-field-group'); const destroyInput = group.querySelector('input[name$="_destroy"]'); destroyInput.value = '1'; group.style.display = 'none'; // Re-enable add button if we now have fewer than 5 visible fields const visibleGroups = document.querySelectorAll('.ingredient-field-group:not([style*="display: none"])').length; document.getElementById('add-ingredient-btn').disabled = visibleGroups >=5; } }); // Add new ingredient field (clone and update nested attribute names) document.getElementById('add-ingredient-btn').addEventListener('click', function() { const lastGroup = document.querySelector('.ingredient-field-group:last-of-type'); const newGroup = lastGroup.cloneNode(true); // Reset values and update field IDs/names for Rails nested attributes newGroup.querySelectorAll('input, select').forEach(input => { input.value = ""; const regex = /(\d+)/; const currentIndex = input.name.match(regex)[1]; const newIndex = parseInt(currentIndex) + 1; input.name = input.name.replace(regex, newIndex); input.id = input.id.replace(regex, newIndex); }); lastGroup.after(newGroup); // Disable add button if we now have 5 visible fields const visibleGroups = document.querySelectorAll('.ingredient-field-group:not([style*="display: none"])').length; this.disabled = visibleGroups >=5; }); });
Key Notes
- The
accepts_nested_attributes_forsetup ensures Rails correctly processes the nested ingredient fields. - The dropdown uses
Ingredient.pluck(:name).uniqto pull unique existing ingredient names—you might want to cache this if you have a large dataset. - The JavaScript handles dynamic field management while respecting the 5-field limit, and ensures nested attribute names are correctly updated when adding new fields.
This implementation should fix the issues you were facing with your initial fields_for setup, and it fully supports both existing and custom ingredients.
内容的提问来源于stack exchange,提问作者Ron

