'Rails 7 Dynamic Nested Forms with hotwire/turbo frames?
I'm very new to the rails. I've started right from rails7 so there is still very little information regarding my problem.
Here is what i have:
app/models/cocktail.rb
class Cocktail < ApplicationRecord
has_many :cocktail_ingredients, dependent: :destroy
has_many :ingredients, through: :cocktail_ingredients
accepts_nested_attributes_for :cocktail_ingredients
end
app/models/ingredient.rb
class Ingredient < ApplicationRecord
has_many :cocktail_ingredients
has_many :cocktails, :through => :cocktail_ingredients
end
app/models/cocktail_ingredient.rb
class CocktailIngredient < ApplicationRecord
belongs_to :cocktail
belongs_to :ingredient
end
app/controllers/cocktails_controller.rb
def new
@cocktail = Cocktail.new
@cocktail.cocktail_ingredients.build
@cocktail.ingredients.build
end
def create
@cocktail = Cocktail.new(cocktail_params)
respond_to do |format|
if @cocktail.save
format.html { redirect_to cocktail_url(@cocktail), notice: "Cocktail was successfully created." }
format.json { render :show, status: :created, location: @cocktail }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @cocktail.errors, status: :unprocessable_entity }
end
end
end
def cocktail_params
params.require(:cocktail).permit(:name, :recipe, cocktail_ingredients_attributes: [:quantity, ingredient_id: []])
end
...
db/seeds.rb
Ingredient.create([ {name: "rum"}, {name: "gin"} ,{name: "coke"}])
relevant tables from schema
create_table "cocktail_ingredients", force: :cascade do |t|
t.float "quantity"
t.bigint "ingredient_id", null: false
t.bigint "cocktail_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["cocktail_id"], name: "index_cocktail_ingredients_on_cocktail_id"
t.index ["ingredient_id"], name: "index_cocktail_ingredients_on_ingredient_id"
end
create_table "cocktails", force: :cascade do |t|
t.string "name"
t.text "recipe"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "ingredients", force: :cascade do |t|
t.string "name"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
...
add_foreign_key "cocktail_ingredients", "cocktails"
add_foreign_key "cocktail_ingredients", "ingredients"
app/views/cocktails/_form.html.erb
<%= form_for @cocktail do |form| %>
<% if cocktail.errors.any? %>
<% cocktail.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
<% end %>
<div>
<%= form.label :name, style: "display: block" %>
<%= form.text_field :name, value: "aa"%>
</div>
<div>
<%= form.label :recipe, style: "display: block" %>
<%= form.text_area :recipe, value: "nn" %>
</div>
<%= form.simple_fields_for :cocktail_ingredients do |ci| %>
<%= ci.collection_check_boxes(:ingredient_id, Ingredient.all, :id, :name) %>
<%= ci.text_field :quantity, value: "1"%>
<% end %>
<div>
<%= form.submit %>
</div>
<% end %>
Current error:
Cocktail ingredients ingredient must exist
What I'm trying to achieve:
I want a partial where I can pick one of the 3 ingredients and enter its quantity. There should be added/remove buttons to add/remove ingredients.
What do i use? Turbo Frames? Hotwire? How do i do that?
Im still super confused with everything in rails so would really appreciate in-depth answer.
Solution 1:[1]
I do have a simple but a bit dirty solution. Seeing as you're new to all this. I think this could help you understand what is going on in the form itself, before you dive into Turbo and all that.
Also using accepts_nested_attributes_for is quick and fun; however it'll drive you insane when you don't understand it.
First lets fix the form and loose simple_form for simplicity
<!-- form_for or form_tag https://guides.rubyonrails.org/form_helpers.html#using-form-tag-and-form-for -->
<!-- form_with does it all -->
<%= form_with model: cocktail do |f| %>
<% if cocktail.errors.any? %>
<% cocktail.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
<% end %>
<div>
<%= f.label :name, style: "display: block" %>
<%= f.text_field :name, value: "aa"%>
</div>
<div>
<%= f.label :recipe, style: "display: block" %>
<%= f.text_area :recipe, value: "nn" %>
</div>
<!-- https://api.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html#method-i-fields_for -->
<%= f.fields_for "cocktail_ingredients" do |ff| %>
<br>
<%= ff.select :ingredient_id, Ingredient.all.collect { |p| [ p.name, p.id ] }, include_blank: true %>
<%= ff.text_field :quantity, value: "1" %>
<div>
<%= ff.label 'Delete' %>
<%= ff.check_box :_destroy %>
</div>
<% end %>
<!-- Yes we're submitting our form, but with a different button -->
<!-- see CocktailController#create -->
<div> <%= f.submit "Add ingredient", name: 'add_ingredient' %> </div>
<div> <%= f.submit %> </div>
<% end %>
collection_check_boxes does not apply in this situation, we want a single ingredient per record as indicated by belongs_to :ingredient. single select is an obvious alternative; collection_radio_buttons also applicable.
fields_for helper will also output a hidden field with an "id" of cocktail_ingredient record if that particular record has been persisted in the database. That's how rails knows to update existing records (with id) and create new records (without id).
fields_for also appends "_attributes" to the record_name if form's model, in this case Cocktail, has accepts_nested_attributes_for defined for that record_name.
To clarify, if you have this in your model:
accepts_nested_attributes_for :cocktail_ingredients
that means
f.fields_for "cocktail_ingredients"
will prefix input names with cocktail[cocktail_ingredients_attributes].
Controller
# GET /cocktails/new
def new
@cocktail = Cocktail.new
# build one new object for our new form; otherwise `fields_for` will not render anything
# with this new form will always have one empty `cocktail_ingredient`
# NOTE: this is necessary when using `accepts_nested_attributes_for`
# NOTE: without `accepts_nested_attributes_for`, `fields_for` will render fields; see what a cluster it is.
@cocktail.cocktail_ingredients.build
end
# POST /cocktails
def create
@cocktail = Cocktail.new(cocktail_params)
respond_to do |format|
# NOTE: catch when we submit our form with add_ingredient button
# params will have "add_ingredient"=>"Add ingredient"
if params[:add_ingredient]
# build another cocktail_ingredient to be rendered by `fields_for` helper
@cocktail.cocktail_ingredients.build
# and just render the form again. tada! you're done.
# NOTE: rails 7 submits as TURBO_STREAM format; hey we're actually using turbo.
# it expects a form to redirect when valid; so we have to use some kind of invalid status.
format.html { render :new, status: :unprocessable_entity }
else
if @cocktail.save
format.html { redirect_to cocktail_url(@cocktail), notice: "Cocktail was successfully created." }
else
format.html { render :new, status: :unprocessable_entity }
end
end
end
end
Model
Update Cocktail to allow the use of _destroy form field to delete record when saving.
accepts_nested_attributes_for :cocktail_ingredients, allow_destroy: true
It's a long explanation, but in the end all I added is four lines of code:
if params[:add_ingredient]
@cocktail.cocktail_ingredients.build
format.html { render :new, status: :unprocessable_entity }
<%= f.submit "Add ingredient", name: 'add_ingredient' %>
Hopefully this makes sense. Lot's of room for improvement here. I think cocoon gem is still a viable option for simple use cases, if you want to look into that.
UPDATE. Add turbo-frame
Right now when new ingredient is added the entire page is re-rendered. We can add turbo-frame to only update ingredients part of the form:
...
<turbo-frame id="<%= f.field_id(:ingredients) %>" class="contents">
<%= f.fields_for "cocktail_ingredients" do |ff| %>
<br>
<%= ff.select :ingredient_id, Ingredient.all.collect { |p| [ p.name, p.id ] }, include_blank: true %>
<%= ff.text_field :quantity %> <%# NOTE: remove preset value %>
<div>
<%= ff.label 'Delete' %>
<%= ff.check_box :_destroy %>
</div>
<% end %>
</turbo-frame>
...
Change 'Add ingredient' button to be aware of turbo-frame that we want to update.
...
<%= f.submit "Add ingredient",
data: { turbo_frame: f.field_id(:ingredients)},
name: 'add_ingredient' %>
...
Turbo frame id has to match the button data-turbo-frame attribute.
<turbo-frame id="has_to_match">
<input data-turbo-frame="has_to_match" ...>
Now when clicking "Add ingredient" button it still goes to the same controller, it still renders the entire form on the server, but now, instead of re-rendering the entire page, only the content inside the turbo-frame is updated. Which means, page scroll stays the same, form state outside of turbo frame tag is unchanged. For all intents and purposes this is now a dynamic form.
Possible improvement could be to stop messing with create action and "Add ingredients" through a different controller action, like add_ingredient
# config/routes.rb
resources :cocktails do
post :add_ingredient
end
...
<%= f.submit 'Add ingredient',
formmethod: 'post',
formaction: '/cocktails/add_ingredient',
data: { turbo_frame: f.field_id(:ingredients)} %>
...
Add add_ingredient action to CocktailsController
def add_ingredient
@cocktail = Cocktail.new cocktail_params
@cocktail.cocktail_ingredients.build # add another ingredient
render :new
end
create action can be reverted back to default now.
I think this is as simple as I can make it. Here is the short version (about 10 extra lines of code to add dynamic fields, and no javascript)
# config/routes.rb
resources :cocktails do
post :add_ingredient
end
# app/controllers/cocktails_controller.rb
# the usual scaffold for other actions
def add_ingredient
@cocktail = Cocktail.new cocktail_params
@cocktail.cocktail_ingredients.build # add another ingredient
render :new
end
# app/views/cocktails/new.html.erb
<%= form_with model: @cocktail do |f| %>
<turbo-frame id="<%= f.field_id(:ingredients) %>" class="contents">
<%= f.fields_for :cocktail_ingredients do |ff| %>
<%= ff.select :ingredient_id, Ingredient.all.collect { |p| [ p.name, p.id ] }, include_blank: true %>
<% end %>
</turbo-frame>
<%= f.button "Add ingredient",
formmethod: 'post',
formaction: '/cocktails/add_ingredient',
data: { turbo_frame: f.field_id(:ingredients)},%>
<%= f.submit %>
<% end %>
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|---|
| Solution 1 |
