'Rails 7 Dynamic Nested Forms with hotwire/turbo frames?

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.



Solution 1:[1]

I do have a simple but a bit dirty solution. Seeing as you're new to all this. I think this could help you understand what is going on in the form itself, before you dive into Turbo and all that. Also using accepts_nested_attributes_for is quick and fun; however it'll drive you insane when you don't understand it.

First lets fix the form and loose simple_form for simplicity

<!-- 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| %>
  <% if cocktail.errors.any? %>
    <% cocktail.errors.each do |error| %>
      <li><%= error.full_message %></li>
    <% end %>
  <% end %>
  <div>
    <%= f.label :name, style: "display: block" %>
    <%= f.text_field :name, value: "aa"%>
  </div>
  <div>
    <%= f.label :recipe, style: "display: block" %>
    <%= f.text_area :recipe, value: "nn" %>
  </div>

  <!-- https://api.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html#method-i-fields_for -->
  <%= f.fields_for "cocktail_ingredients" do |ff| %>
    <br>
    <%= ff.select :ingredient_id, Ingredient.all.collect { |p| [ p.name, p.id ] }, include_blank: true %>
    <%= ff.text_field :quantity, value: "1" %>

    <div>
      <%= ff.label 'Delete' %>
      <%= ff.check_box :_destroy %>
    </div>

  <% end %>

  <!-- Yes we're submitting our form, but with a different button -->
  <!-- see CocktailController#create -->
  <div> <%= f.submit "Add ingredient", name: 'add_ingredient' %> </div>

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

collection_check_boxes does not apply in this situation, we want a single ingredient per record as indicated by belongs_to :ingredient. single select is an obvious alternative; collection_radio_buttons also applicable.

fields_for helper will also output a hidden field with an "id" of cocktail_ingredient record 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).

fields_for also appends "_attributes" to the record_name if form's model, in this case Cocktail, has accepts_nested_attributes_for defined for that record_name.

To clarify, 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].

Controller

# GET /cocktails/new
def new
  @cocktail = Cocktail.new
  # build one new object for our new form; otherwise `fields_for` will not render anything
  # with this new form will always have one empty `cocktail_ingredient`
  # NOTE: this is necessary when using `accepts_nested_attributes_for`
  # NOTE: without `accepts_nested_attributes_for`, `fields_for` will render fields; see what a cluster it is.
  @cocktail.cocktail_ingredients.build 
end

# POST /cocktails
def create
  @cocktail = Cocktail.new(cocktail_params)
  respond_to do |format|
    # NOTE: catch when we submit our form with add_ingredient button
    # params will have "add_ingredient"=>"Add ingredient"
    if params[:add_ingredient]
      # build another cocktail_ingredient to be rendered by `fields_for` helper
      @cocktail.cocktail_ingredients.build
      
      # and just render the form again. tada! you're done.
      # NOTE: rails 7 submits as TURBO_STREAM format; hey we're actually using turbo.
      #       it expects a form to redirect when valid; so we have to use some kind of invalid status.
      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

Model

Update Cocktail to allow the use of _destroy form field to delete record when saving.

accepts_nested_attributes_for :cocktail_ingredients, allow_destroy: true

It's a long explanation, but in the end all I added is four lines of code:

if params[:add_ingredient]
  @cocktail.cocktail_ingredients.build
  format.html { render :new, status: :unprocessable_entity }
<%= f.submit "Add ingredient", name: 'add_ingredient' %>

Hopefully this makes sense. Lot's of room for improvement here. I think cocoon gem is still a viable option for simple use cases, if you want to look into that.

UPDATE. Add turbo-frame

Right now when new ingredient is added the entire page is re-rendered. We can add turbo-frame to only update ingredients part of the form:

...

<turbo-frame id="<%= f.field_id(:ingredients) %>" class="contents">

  <%= f.fields_for "cocktail_ingredients" do |ff| %>
    <br>
    <%= ff.select :ingredient_id, Ingredient.all.collect { |p| [ p.name, p.id ] }, include_blank: true %>
    <%= ff.text_field :quantity %> <%# NOTE: remove preset value %>

    <div>
      <%= ff.label 'Delete' %>
      <%= ff.check_box :_destroy %>
    </div>

  <% end %>

</turbo-frame>

...

Change 'Add ingredient' button to be aware of turbo-frame that we want to update.

...

<%= f.submit "Add ingredient", 
  data: { turbo_frame: f.field_id(:ingredients)},
  name: 'add_ingredient' %>

...

Turbo frame id has to match the button 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 form on the server, but now, instead of re-rendering the entire page, only the content inside the turbo-frame is updated. 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
end
...

<%= f.submit 'Add ingredient',
  formmethod: 'post',
  formaction: '/cocktails/add_ingredient',
  data: { turbo_frame: f.field_id(:ingredients)} %>

...

Add add_ingredient action to CocktailsController

def add_ingredient
  @cocktail = Cocktail.new cocktail_params
  @cocktail.cocktail_ingredients.build # add another ingredient
  render :new
end

create action can be reverted back to default now.

I think this is as simple as I can make it. Here is the short version (about 10 extra lines of code to add dynamic fields, and no javascript)

# config/routes.rb
resources :cocktails do
  post :add_ingredient
end

# app/controllers/cocktails_controller.rb 
# the usual scaffold for other actions
def add_ingredient
  @cocktail = Cocktail.new cocktail_params
  @cocktail.cocktail_ingredients.build # add another ingredient
  render :new
end

# app/views/cocktails/new.html.erb
<%= form_with model: @cocktail do |f| %>

  <turbo-frame id="<%= f.field_id(:ingredients) %>" class="contents">
    <%= f.fields_for :cocktail_ingredients do |ff| %>
      <%= ff.select :ingredient_id, Ingredient.all.collect { |p| [ p.name, p.id ] }, include_blank: true %>
    <% end %>
  </turbo-frame>

  <%= f.button "Add ingredient",
    formmethod: 'post',
    formaction: '/cocktails/add_ingredient',
    data: { turbo_frame: f.field_id(:ingredients)},%>

  <%= f.submit %>
<% end %>

https://thoughtbot.com/blog/dynamic-forms-with-turbo

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1