I'm very new to Rails. I've started right from Rails 7 so there is still very little information regarding my problem.
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 %>
Cocktail ingredients ingredient must exist
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?
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.
5. Custom Form Field - make a reusable form field
6. Frame + Stream - stream from the frame
7. Stimulus - it's much simpler than you think
8. Deeply Nested Fields - it's much harder than you think
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_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| %>
<%= tag.div class: "flex gap-2" do %>
<%= 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 %>
<% 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 %>
<%= f.submit %>
<% 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.
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_tag f.field_id(:ingredients) do %>
<%= f.fields_for :cocktail_ingredients do |ff| %>
<%= tag.div class: "flex gap-2" do %>
<%= 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 %>
<% end %>
<% end %>
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, because submit event is triggered from the <form> element which is outside of the turbo frame.
# same id as <turbo-frame>
<%= 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 # this adds POST /cocktails/new
end
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_tag f.field_id(:ingredients) do %>
<%= f.fields_for :cocktail_ingredients do |ff| %>
<%= tag.div class: "flex gap-2" do %>
<%= 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 %>
<% end %>
<% end %>
<%= f.button "Add ingredient", formmethod: "post", formaction: add_ingredient_cocktails_path(id: f.object), data: {turbo_frame: f.field_id(:ingredients)} %>
<%= f.submit %>
<% 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 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
<%= tag.div id: :cocktail_ingredients do %>
<%= f.fields_for :cocktail_ingredients do |ff| %>
# put nested fields into a partial
<%= render "ingredient_fields", f: ff %>
<% end %>
<% end %>
# 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,
class: "text-blue-500 hover:underline",
data: { turbo_method: :post } %>
# ^
# NOTE: still has to be a POST request.
# UPDATE: set `turbo_stream: true` to make it a GET request.
# app/views/cocktails/_ingredient_fields.html.erb
<%= tag.div class: "flex gap-2" do %>
<%= f.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
<%= f.text_field :quantity, placeholder: "Qty" %>
<%= f.check_box :_destroy, title: "Check to delete ingredient" %>
<% end %>
Update add_ingredient
action to render a turbo_stream
response:
# it should be in your routes, see previous section above.
def add_ingredient
# NOTE: get a form builder but skip the <form> tag, `form_with` would work
# here too. however, we'd have to use `fields` if we were in a template.
helpers.fields model: Cocktail.new do |f|
# NOTE: instead of letting `fields_for` helper loop through `cocktail_ingredients`
# we can pass a new object explicitly.
# v
f.fields_for :cocktail_ingredients, CocktailIngredient.new, child_index: Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) do |ff|
# ^ ^ Time.now.to_f also works
# NOTE: one caveat is that we need a unique key when we render this
# partial otherwise it would always be 0, which would override
# previous inputs. just look at the generated input `name` attribute:
# cocktail[cocktail_ingredients_attributes][0][ingredient_id]
# ^
# we need a different number for each set of fields
render turbo_stream: turbo_stream.append(
"cocktail_ingredients",
partial: "ingredient_fields",
locals: { f: ff }
)
end
end
end
# NOTE: `fields_for` does output an `id` field for persisted records
# which would be outside of the rendered html and turbo_stream.
# not an issue here since we only render new records and there is no `id`.
Making a form field helper will simplify the task down to one line:
# config/routes.rb
# NOTE: I'm not using `:id` for anything, but just in case you need it.
post "/fields/:model(/:id)/build/:association(/:partial)", to: "fields#build", as: :build_fields
# app/controllers/fields_controller.rb
class FieldsController < ApplicationController
# POST /fields/:model(/:id)/build/:association(/:partial)
def build
resource_class = params[:model].classify.constantize # => Cocktail
association_class = resource_class.reflect_on_association(params[:association]).klass # => CocktailIngredient
fields_partial_path = params[:partial] || "#{association_class.model_name.collection}/fields" # => "cocktail_ingredients/fields"
render locals: { resource_class:, association_class:, fields_partial_path: }
end
end
# app/views/fields/build.turbo_stream.erb
<%=
fields model: resource_class.new do |f|
turbo_stream.append f.field_id(params[:association]) do
f.fields_for params[:association], association_class.new, child_index: Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) do |ff|
render fields_partial_path, f: ff
end
end
end
%>
# app/models/dynamic_form_builder.rb
class DynamicFormBuilder < ActionView::Helpers::FormBuilder
def dynamic_fields_for association, name = nil, partial: nil, path: nil
association_class = object.class.reflect_on_association(association).klass
partial ||= "#{association_class.model_name.collection}/fields"
name ||= "Add #{association_class.model_name.human.downcase}"
path ||= @template.build_fields_path(object.model_name.name, association:, partial:)
@template.tag.div id: field_id(association) do
fields_for association do |ff|
@template.render(partial, f: ff)
end
end.concat(
@template.link_to(name, path, class: "text-blue-500 hover:underline", data: { turbo_method: :post })
)
end
end
This new helper requires "#{association_name}/_fields"
partial:
# app/views/cocktail_ingredients/_fields.html.erb
<%= f.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
<%= f.text_field :quantity, placeholder: "Qty" %>
<%= f.check_box :_destroy, title: "Check to delete ingredient" %>
Override the default form builder and now you should have dynamic_fields_for
input:
# app/views/cocktails/_form.html.erb
<%= form_with model: cocktail, builder: DynamicFormBuilder do |f| %>
<%= f.dynamic_fields_for :cocktail_ingredients %>
<%# f.dynamic_fields_for :other_things, "Add a thing", partial: "override/partial/path" %>
# or without dynamic form builder, just using the new controller
<%= tag.div id: f.field_id(:cocktail_ingredients) %>
<%= link_to "Add ingredient", build_fields_path(:cocktail, :cocktail_ingredients), class: "text-blue-500 hover:underline", data: { turbo_method: :post } %>
<% end %>
You can render turbo_stream tag on the current page and it will work. Pretty useless to render something just to move it somewhere else on the same page. But, if we put it inside a turbo_frame, we can move things outside of the frame for safekeeping while getting updates inside the turbo_frame.
# app/controllers/cocktails_controller.rb
# GET /cocktails/new
def new
@cocktail = Cocktail.new
@cocktail.cocktail_ingredients.build
# turbo_frame_request? # => true
# request.headers["Turbo-Frame"] # => "add_ingredient"
# skip `new.html.erb` rendering if you want
render ("_form" if turbo_frame_request?), locals: { cocktail: @cocktail }
end
# app/views/cocktails/_form.html.erb
<%= form_with model: cocktail do |f| %>
<%= tag.div id: :ingredients %>
<%= turbo_frame_tag :add_ingredient do %>
# NOTE: render all ingredients and move them out of the frame.
<%= turbo_stream.append :ingredients do %>
# NOTE: just need to take extra care of that `:child_index` and pass it as a proc, so it would be different for each object
<%= f.fields_for :cocktail_ingredients, child_index: -> { Process.clock_gettime(Process::CLOCK_REALTIME, :microsecond) } do |ff| %>
<%= 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 %>
<% end %>
# NOTE: this link is inside `turbo_frame`, so if we navigate to `new` action
# we get a single set of new ingredient fields and `turbo_stream`
# moves them out again.
<%= link_to "Add ingredient", new_cocktail_path, class: "text-blue-500 hover:underline" %>
<% end %>
<% end %>
No extra actions, controllers, routes, partials or responses. Just a GET request with Html response, and only a single set of fields gets appended.
Avoiding javascript is fun, but it can get a bit complicated. On the other hand, making dynamic fields with Stimulus is just so simple:
bin/rails generate stimulus dynamic_fields
// app/javascript/controllers/dynamic_fields_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["template"];
add(event) {
event.preventDefault();
event.currentTarget.insertAdjacentHTML(
"beforebegin",
this.templateTarget.innerHTML.replace(
/__CHILD_INDEX__/g,
new Date().getTime().toString()
)
);
}
}
That's it for javascript, you don't even need to go past the home page to learn this much https://stimulus.hotwired.dev/.
It updates predefined child index in a template and puts updated html back into the form.
To make this stimulus controller work, we need to have controller
element, a template target
with new fields, and a button with add action
. I've made a quick helper method to do all that:
# app/helpers/application_helper.rb
module ApplicationHelper
def dynamic_fields_for f, association, name = "Add"
# stimulus: controller v
tag.div data: {controller: "dynamic-fields"} do
safe_join([
# render existing fields
f.fields_for(association) do |ff|
yield ff
end,
# render "Add" button that will call `add()` function
# stimulus: `add(event)` v
button_tag(name, data: {action: "dynamic-fields#add"}),
# render "<template>"
# stimulus: `this.templateTarget` v
tag.template(data: {dynamic_fields_target: "template"}) do
f.fields_for association, association.to_s.classify.constantize.new,
child_index: "__CHILD_INDEX__" do |ff|
# ^ make it easy to gsub from javascript
yield ff
end
end
])
end
end
end
Use it inside your form:
# app/views/cocktails/_form.html.erb
<%= form_with model: cocktail do |f| %>
<%= dynamic_fields_for f, :cocktail_ingredients do |ff| %>
# NOTE: this block will be rendered once for the <template> and
# once for every `cocktail_ingredient`
<%= tag.div class: "flex gap-2" do %>
<%= 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 %>
# NOTE: double nested dynamic fields also work
<%# <%= dynamic_fields_for ff, :things do |fff| %>
<%# <%= fff.text_field :name %>
<%# <%= fff.text_field :value %>
<%# <% end %>
<% end %>
<% end %>
Stimulus way is much simpler ^.
bin/rails g model Thing name cocktail_ingredient:references
bin/rails db:migrate
# config/routes.rb
resources :cocktails do
post :add_fields, on: :collection
end
# app/models/*.rb
class Thing < ApplicationRecord
belongs_to :cocktail_ingredient
end
class CocktailIngredient < ApplicationRecord
belongs_to :ingredient
belongs_to :cocktail
has_many :things, dependent: :destroy
accepts_nested_attributes_for :things
end
# app/views/cocktails/_form.html.erb
<%= form_with model: cocktail do |f| %>
<%= tag.div id: f.field_id(:cocktail_ingredients) do %>
<%= f.fields_for :cocktail_ingredients do |ff| %>
<%= render "cocktail_ingredient_fields", f: ff %>
<% end %>
<% end %>
# NOTE: we'll use `params[:name]` to build everything on the server
<%= link_to "Add ingredient",
add_fields_cocktails_path(name: f.field_name(:cocktail_ingredients)),
data: { turbo_method: :post } %>
<%= f.submit %>
<% end %>
# app/views/cocktails/_cocktail_ingredient_fields.html.erb
<%= tag.div class: "flex gap-2" do %>
<%= f.select :ingredient_id, Ingredient.all.map { |i| [i.name, i.id] }, include_blank: "Select ingredient" %>
<%= f.text_field :quantity, placeholder: "Qty" %>
<%= f.check_box :_destroy, title: "Check to delete ingredient" %>
<% end %>
# nested nested fields
<%= tag.div id: f.field_id(:things, index: nil) do %>
<%= f.fields_for :things do |ff| %>
<%= render "thing_fields", f: ff %>
<% end %>
<% end %>
<%= link_to "Add a thing",
add_fields_cocktails_path(name: f.field_name(:things, index: nil)),
data: { turbo_method: :post } %>
# app/views/cocktails/_thing_fields.html.erb
<%= f.text_field :name, placeholder: "Name" %>
# i imagine you could keep nesting
This is the fun part:
# app/controllers/cocktails_controller.rb
def add_fields
form_model, *nested_attributes = params[:name].split(/\[|\]/).compact_blank
helpers.fields form_model.classify.constantize.new do |form|
nested_form_builder_for form, nested_attributes do |f|
# NOTE: this block should run only once for the last association
# cocktail[cocktail_ingredients_attributes]
# this ^^^^^^^^^^^^^^^^^^^^ or this vvvvvv
# cocktail[cocktail_ingredients_attributes][0][things_attributes]
#
# `f` is the last nested form builder, for example:
#
# form_with model: Model.new do |f|
# f.fields_for :one do |ff|
# ff.fields_for :two do |fff|
# yield fff
# # ^^^
# # NOTE: this is what you should get in this block
# end
# end
# end
#
render turbo_stream: turbo_stream.append(
params[:name].parameterize(separator: "_"),
partial: "#{f.object.class.name.underscore}_fields",
locals: {f:}
)
end
end
end
private
def nested_form_builder_for f, *nested_attributes, &block
attribute, index = nested_attributes.flatten!.shift(2)
if attribute.blank?
# NOTE: yield the last form builder instance to render the response
yield f
return
end
association = attribute.chomp("_attributes")
child_index = index || Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
f.fields_for association, association.classify.constantize.new, child_index: do |ff|
nested_form_builder_for(ff, nested_attributes, &block)
end
end
This is the first setup that worked well. I tried using params[:name]
as a prefix and skip rebuilding the entire form stack, but it turned out to be even more of a headache.