1. Controller & Form - set it up as if you have no javascript,
2. Turbo Frame - then wrap it in a frame.
3. TLDR - if you don't need a long explanation.
4. Turbo Stream - you can skip Turbo Frame and do this instead.
Controller & Form
To start, we need a form that can be submitted and then re-rendered without creating a new cocktail.
Using accepts_nested_attributes_for does change the behavior of the form, which is not obvious and it'll drive you insane when you don't understand it.
First, lets fix the form. I'll use the default rails form builder, but it is the same setup with simple_form as well:
<!-- 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| %>
<%= (errors = safe_join(cocktail.errors.map(&:full_message).map(&tag.method(:li))).presence) ? tag.div(tag.ul(errors), class: "prose text-red-500") : "" %>
<%= f.text_field :name, placeholder: "Name" %>
<%= f.text_area :recipe, placeholder: "Recipe" %>
<%= f.fields_for :cocktail_ingredients do |ff| %>
<div class="flex gap-2">
<div class="text-sm text-right"> <%= ff.object.id || "New ingredient" %> </div>
<%= ff.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
<%= ff.text_field :quantity, placeholder: "Qty" %>
<%= ff.check_box :_destroy, title: "Check to delete ingredient" %>
</div>
<% end %>
<!-- NOTE: Form has to be submitted, but with a different button,
that way we can add different functionality in the controller
see `CocktailsController#create` -->
<%= f.submit "Add ingredient", name: :add_ingredient %>
<div class="flex justify-end p-4 border-t bg-gray-50"> <%= f.submit %> </div>
<% end %>
<style type="text/css" media="screen">
input[type], textarea, select { display: block; padding: 0.5rem 0.75rem; margin-bottom: 0.5rem; width: 100%; border: 1px solid rgba(0,0,0,0.15); border-radius: .375rem; box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 3px 0px }
input[type="checkbox"] { width: auto; padding: 0.75rem; }
input[type="submit"] { width: auto; cursor: pointer; color: white; background-color: rgb(37, 99, 235); font-weight: 500; }
</style>
https://api.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html#method-i-fields_for
We need a single ingredient per cocktail_ingredient as indicated by belongs_to :ingredient. Single select is an obvious choice; collection_radio_buttons also applicable.
fields_for helper will output a hidden field with an id of cocktail_ingredient 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).
Because we're using accepts_nested_attributes_for, fields_for appends "_attributes" to the input name. In other words, 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].
(WARN: source code incoming) The reason is because accepts_nested_attributes_for has defined a new method cocktail_ingredients_attributes=(params) in Cocktail model, which does a lot of work for you. This is where nested parameters are handled, CocktailIngredient objects are created and assigned to corresponding cocktail_ingredients association and also marked to be destroyed if _destroy parameter is present and because autosave is set to true, you get automatic validations. This is just an FYI, in case you want to define your own cocktail_ingredients_attributes= method and you can and f.fields_for will pick it up automatically.
In CocktailsController, new and create actions need a tiny update:
# GET /cocktails/new
def new
@cocktail = Cocktail.new
# NOTE: Because we're using `accepts_nested_attributes_for`, nested fields
# are tied to the nested model now, a new object has to be added to
# `cocktail_ingredients` association, otherwise `fields_for` will not
# render anything; (zero nested objects = zero nested fields).
@cocktail.cocktail_ingredients.build
end
# POST /cocktails
def create
@cocktail = Cocktail.new(cocktail_params)
respond_to do |format|
# NOTE: Catch when form is submitted by "add_ingredient" button;
# `params` will have { add_ingredient: "Add ingredient" }.
if params[:add_ingredient]
# NOTE: Build another cocktail_ingredient to be rendered by
# `fields_for` helper.
@cocktail.cocktail_ingredients.build
# NOTE: Rails 7 submits as TURBO_STREAM format. It expects a form to
# redirect when valid, so we have to use some kind of invalid
# status. (this is temporary, for educational purposes only).
# https://stackoverflow.com/a/71762032/207090
# NOTE: Render the form again. TADA! You're done.
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
In Cocktail model allow the use of _destroy form field to delete record when saving:
accepts_nested_attributes_for :cocktail_ingredients, allow_destroy: true
That's it, the form can be submitted to create a cocktail or submitted to add another ingredient. It's a long explanation, but in the end all I added is a few lines of code:
# in the controller
if params[:add_ingredient]
@cocktail.cocktail_ingredients.build
format.html { render :new, status: :unprocessable_entity }
# and in the form
<%= f.submit "Add ingredient", name: 'add_ingredient' %>
Hopefully this makes sense. If you understood all that, turbo-frame part will be easy, because we were just working with one frame until now and later there will be another frame.
Turbo Frame
Right now, when new ingredient is added the entire page is re-rendered by turbo. To make the form a little more dynamic, we can add turbo-frame tag to only update ingredients part of the form:
<!-- doesn't matter how you get the "id" attribute
it just has to be unique and repeatable across page reloads -->
<turbo-frame id="<%= f.field_id(:ingredients) %>" class="contents">
<%= f.fields_for :cocktail_ingredients do |ff| %>
<div class="flex gap-2">
<div class="text-sm text-right"> <%= ff.object&.id || "New ingredient" %> </div>
<%= ff.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
<%= ff.text_field :quantity, placeholder: "Qty" %>
<%= ff.check_box :_destroy, title: "Check to delete ingredient" %>
</div>
<% end %>
</turbo-frame>
Change "Add ingredient" button to let turbo know that we only want the frame part of the submitted page. A regular link, doesn't need this, we would just put that link inside of the frame tag, but an input button needs extra attention.
<!-- same `id` as <turbo-frame>; repeatable, remember. -->
<%= f.submit "Add ingredient",
data: { turbo_frame: f.field_id(:ingredients)},
name: "add_ingredient" %>
Turbo frame id has to match the button's 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 page on the server, but instead of re-rendering the entire page (frame #1), only the content inside the turbo-frame is updated (frame #2). 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, on: :collection
end
<%= f.submit "Add ingredient",
formmethod: "post",
formaction: add_ingredient_cocktails_path(id: f.object),
data: { turbo_frame: f.field_id(:ingredients)} %>
Add add_ingredient action to CocktailsController:
def add_ingredient
@cocktail = Cocktail.new(cocktail_params.merge({id: params[:id]}))
@cocktail.cocktail_ingredients.build # add another ingredient
# NOTE: Even though we are submitting a form, there is no
# need for "status: :unprocessable_entity".
# Turbo is not expecting a full page response that has
# to be compatible with the browser behavior
# (that's why all the status shenanigans; 422, 303)
# it is expecting to find the <turbo-frame> with `id`
# matching `data-turbo-frame` from the button we clicked.
render :new
end
create action can be reverted back to default now.
You could also reuse new action instead of adding add_ingredient:
resources :cocktails do
post :new, on: :new # add POST /cocktails/new
end
Full controller set up:
https://stackoverflow.com/a/72890584/207090
Then adjust the form to post to new instead of add_ingredient.
TLDR - Put it all together
I think this is as simple as I can make it. Here is the short version (about 10ish extra lines of code to add dynamic fields, and no javascript)
# config/routes.rb
resources :cocktails do
post :add_ingredient, on: :collection
end
# app/controllers/cocktails_controller.rb
# the other actions are the usual default scaffold
def add_ingredient
@cocktail = Cocktail.new(cocktail_params.merge({id: params[:id]}))
@cocktail.cocktail_ingredients.build
render :new
end
# app/views/cocktails/new.html.erb
<%= form_with model: cocktail do |f| %>
<%= (errors = safe_join(cocktail.errors.map(&:full_message).map(&tag.method(:li))).presence) ? tag.div(tag.ul(errors), class: "prose text-red-500") : "" %>
<%= f.text_field :name, placeholder: "Name" %>
<%= f.text_area :recipe, placeholder: "Recipe" %>
<turbo-frame id="<%= f.field_id(:ingredients) %>" class="contents">
<%= f.fields_for :cocktail_ingredients do |ff| %>
<div class="flex gap-2">
<div class="text-sm text-right"> <%= ff.object&.id || "New ingredient" %> </div>
<%= ff.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
<%= ff.text_field :quantity, placeholder: "Qty" %>
<%= ff.check_box :_destroy, title: "Check to delete ingredient" %>
</div>
<% end %>
</turbo-frame>
<%= f.button "Add ingredient", formmethod: "post", formaction: add_ingredient_cocktails_path(id: f.object), data: { turbo_frame: f.field_id(:ingredients)} %>
<div class="flex justify-end p-4 border-t bg-gray-50"> <%= f.submit %> </div>
<% end %>
# app/models/*
class Cocktail < ApplicationRecord
has_many :cocktail_ingredients, dependent: :destroy
has_many :ingredients, through: :cocktail_ingredients
accepts_nested_attributes_for :cocktail_ingredients, allow_destroy: true
end
class Ingredient < ApplicationRecord
has_many :cocktail_ingredients
has_many :cocktails, through: :cocktail_ingredients
end
class CocktailIngredient < ApplicationRecord
belongs_to :cocktail
belongs_to :ingredient
end
Turbo Stream
Turbo stream is as dynamic as we can get with this form without touching any javascript. The form has to be changed to let us render a single cocktail ingredient:
<!-- NOTE: remove `f.submit "Add ingredient"` button -->
<!-- and <turbo-frame> with nested fields -->
<!-- NOTE: this `id` will be the target of the turbo stream -->
<div id="cocktail_ingredients">
<% f.object.cocktail_ingredients.each do |i| %>
<!-- put nested fields into a partial -->
<%= render "ingredient_fields", f: f, cocktail_ingredient: i %>
<% end %>
</div>
<!-- NOTE: `f.submit` is no longer needed, because there is no need to -->
<!-- submit the form anymore just to add an ingredient. -->
<%= link_to "Add ingredient",
add_ingredient_cocktails_path(id: f.object),
class: "text-blue-500 hover:underline",
data: { turbo_method: :post } %>
<!-- ^ -->
<!-- NOTE: still has to be a POST request -->
<!-- app/views/cocktails/_ingredient_fields.html.erb -->
<div class="flex gap-2">
<!-- NOTE: insted of letting `fields_for` helper loop through `cocktail_ingredients` -->
<!-- we can pass each `cocktail_ingredient` explicitly. -->
<!-- v -->
<%= f.fields_for :cocktail_ingredients, cocktail_ingredient, child_index: Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) do |ff| %>
<!-- ^ ^ `Time.now.to_f` is also just fine -->
<!-- NOTE: one caveat is that we need a unique key when we render this partial -->
<!-- to get just one new ingredient, otherwise it would be always 0. -->
<!-- the rest is the same -->
<div class="text-sm text-right"> <%= ff.object&.id || "New ingredient" %> </div>
<%= ff.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
<%= ff.text_field :quantity, placeholder: "Qty" %>
<%= ff.check_box :_destroy, title: "Check to delete ingredient" %>
<% end %>
</div>
Update add_ingredient action to render a turbo_stream response:
# it should be in your routes, see previous section above.
def add_ingredient
# get a form builder but skip the <form> tag
helpers.fields model: Cocktail.new do |f|
# NOTE: render a turbo stream tag that will append just one
# nested ingredient to `#cocktail_ingredients`
render turbo_stream: turbo_stream.append(
"cocktail_ingredients",
partial: "ingredient_fields",
locals: { f: f, cocktail_ingredient: CocktailIngredient.new }
)
end
end