Search code examples
ruby-on-railsruby-on-rails-7hotwire-rails

Rails 7 Dynamic Nested Forms with hotwire/turbo frames


I'm very new to Rails. I've started right from Rails 7 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?


Solution

  • 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
    

    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_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.


    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_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
    

    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_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

    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`.
    

    Custom Form Field

    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 %>
    

    Frame + Stream

    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.


    Stimulus

    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 %>
    

    Deeply Nested Fields

    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.