Rails 7 Dynamic Nested Forms with hotwire/turbo frames?

Viewed 2273

I'm very new to the rails. I've started right from rails7 so there is still very little information regarding my problem.

Here is what i have:

app/models/cocktail.rb

class Cocktail < ApplicationRecord
  has_many :cocktail_ingredients, dependent: :destroy
  has_many :ingredients, through: :cocktail_ingredients
  accepts_nested_attributes_for :cocktail_ingredients
end

app/models/ingredient.rb

class Ingredient < ApplicationRecord
  has_many :cocktail_ingredients
  has_many :cocktails, :through => :cocktail_ingredients
end

app/models/cocktail_ingredient.rb

class CocktailIngredient < ApplicationRecord
  belongs_to :cocktail
  belongs_to :ingredient
end

app/controllers/cocktails_controller.rb

def new
  @cocktail = Cocktail.new
  @cocktail.cocktail_ingredients.build
  @cocktail.ingredients.build
end


def create
  @cocktail = Cocktail.new(cocktail_params)

  respond_to do |format|
    if @cocktail.save
      format.html { redirect_to cocktail_url(@cocktail), notice: "Cocktail was successfully created." }
      format.json { render :show, status: :created, location: @cocktail }
    else
      format.html { render :new, status: :unprocessable_entity }
      format.json { render json: @cocktail.errors, status: :unprocessable_entity }
    end
  end
end


def cocktail_params
  params.require(:cocktail).permit(:name, :recipe, cocktail_ingredients_attributes: [:quantity, ingredient_id: []])
end

...

db/seeds.rb

Ingredient.create([ {name: "rum"}, {name: "gin"} ,{name: "coke"}])

relevant tables from schema

create_table "cocktail_ingredients", force: :cascade do |t|
    t.float "quantity"
    t.bigint "ingredient_id", null: false
    t.bigint "cocktail_id", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["cocktail_id"], name: "index_cocktail_ingredients_on_cocktail_id"
    t.index ["ingredient_id"], name: "index_cocktail_ingredients_on_ingredient_id"
  end

create_table "cocktails", force: :cascade do |t|
  t.string "name"
  t.text "recipe"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end

create_table "ingredients", force: :cascade do |t|
  t.string "name"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end

...

add_foreign_key "cocktail_ingredients", "cocktails"
add_foreign_key "cocktail_ingredients", "ingredients"

app/views/cocktails/_form.html.erb

<%= form_for @cocktail do |form| %>
  <% if cocktail.errors.any? %>
    <% cocktail.errors.each do |error| %>
      <li><%= error.full_message %></li>
    <% end %>
  <% end %>

  <div>
    <%= form.label :name, style: "display: block" %>
    <%= form.text_field :name, value: "aa"%>
  </div>

  <div>
    <%= form.label :recipe, style: "display: block" %>
    <%= form.text_area :recipe, value: "nn" %>
  </div>

  <%= form.simple_fields_for :cocktail_ingredients do |ci| %>
    <%= ci.collection_check_boxes(:ingredient_id, Ingredient.all, :id, :name) %>
    <%= ci.text_field :quantity, value: "1"%>
  <% end %>

  <div>
    <%= form.submit %>
  </div>
<% end %>

Current error:

Cocktail ingredients ingredient must exist

What I'm trying to achieve:

I want a partial where I can pick one of the 3 ingredients and enter its quantity. There should be added/remove buttons to add/remove ingredients.

What do i use? Turbo Frames? Hotwire? How do i do that?

Im still super confused with everything in rails so would really appreciate in-depth answer.

1 Answers
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
Related