23

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?

1 Answer 1

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

14
  • 4
    I've updated the answer to use turbo-frames. This is about as dynamic as it can get it with very little code; @zdebyman no hacks this time.
    – Alex
    Commented Apr 20, 2022 at 6:22
  • 5
    this is a helluva answer. thank you
    – aidan
    Commented Oct 1, 2022 at 15:10
  • 4
    i've been using rails for years and never knew about that helpers.fields trick to get a FormBuilder into a partial like that
    – aidan
    Commented Oct 1, 2022 at 15:18
  • 3
    This has to be one of the most detailed answers I've found on SO — thanks for taking the time! How would you go about it if there was another nested model under Ingredient? How would the controller action for the next model look like? Commented Apr 8, 2023 at 16:21
  • 2
    @DanielFriis see update. i'm not sure if this is the right way to do it, but it works for the first and second level fields. i haven't tried but it should work to any depth.
    – Alex
    Commented Apr 9, 2023 at 8:11

Not the answer you're looking for? Browse other questions tagged or ask your own question.