Skip to main content

Dynamic nested forms with Stimulus *and* has_many :through relationship

General • Asked by Nino Rosella

Hi team,

I've been following along with the Dynamic Nested Forms with Stimulus JS lesson, but I've been attempting to adapt it for a has_many :through relationship.

So far, I've managed to get the new and create methods working just fine. However, I'm really having trouble with editing. I've posted my code below.

When I edit a Book, I'm getting duplicate records created.

For instance, if I try to edit a Book that already has two Authors, then it adds these two authors plus any extras that I add during the edit. This then compounds and before I know it I have a Book with many, many Authors.

Anyone got a clue what's happening?

EDIT: Here's a sample app with the code

book.rb

class Book < ApplicationRecord
    has_many :book_authors, inverse_of: :book, dependent: :destroy
    has_many :authors, through: :book_authors

    validates_presence_of :title, :description

    accepts_nested_attributes_for :book_authors, reject_if: :all_blank, allow_destroy: true

    def book_authors_attributes=(book_author_attributes)
    book_author_attributes.values.each do |author_attribute|
      author = Author.find_or_create_by(name:author_attribute["author_attributes"]["name"])
      self.authors << author
    end
  end
end

author.rb

class Author < ApplicationRecord
    has_many :books, through: :book_authors
    has_many :book_authors, dependent: :destroy
end

book_author.rb

class Author < ApplicationRecord
    has_many :books, through: :book_authors
    has_many :book_authors, dependent: :destroy
end

books_controller.rb

class BooksController < ApplicationController
  before_action :set_book, only: [:show, :edit, :update, :destroy]

  def new
    @book = Book.new
    @book.book_authors.build.build_author
  end

  def edit
  end

  def create
    @book = Book.new(book_params)

    respond_to do |format|
      if @book.save
        format.html { redirect_to @book, notice: 'Book was successfully created.' }
        format.json { render :show, status: :created, location: @book }
      else
        format.html { render :new }
        format.json { render json: @book.errors, status: :unprocessable_entity }
      end
    end
  end

  def update
    respond_to do |format|
      if @book.update(book_params)
        format.html { redirect_to @book, notice: 'Book was successfully updated.' }
        format.json { render :show, status: :ok, location: @book }
      else
        format.html { render :edit }
        format.json { render json: @book.errors, status: :unprocessable_entity }
      end
    end
  end


  private
    def set_book
      @book = Book.find(params[:id])
    end

    def book_params
      params.require(:book).permit(:title, :description, book_authors_attributes: [:id, :author_id, :_destroy, author_attributes: [:id, :_destroy, :name]])
    end
end

_form.html.erb

<%= form_for @book do |form| %>
  <% if @book.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@book.errors.count, "error") %> prohibited this book from being saved:</h2>

      <ul>
      <% @book.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= form.text_field :title %>
  </div>

  <div class="field">
    <%= form.text_field :description %>
  </div>

  <div data-controller="nested-form">
    <template data-target="nested-form.template">
      <%= form.fields_for :book_authors, child_index: "NEW_RECORD" do |book_author| %>
        <%= render "book_author_fields", form: book_author %>
      <% end %>
    </template>

    <%= form.fields_for :book_authors do |book_author| %>
      <%= render "book_author_fields", form: book_author %>
    <% end %>

    <div data-target="nested-form.links">
      <%= link_to "add author", "#", data: { action: "nested-form#add_association" } %>
    </div>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

_book_author_fields.html.erb

<%= content_tag :div, class: "nested-fields", data: { new_record: form.object.new_record? } do %>
    <div class="nested-field-input d-flex justtify-content-between mb-2">
        <div class="col-11 pl-0">
            <%= form.fields_for(:author) do |author_form| %>
        <%= author_form.text_field :name %>
      <% end %>
        </div>
        <div class="col-1">
            <%= link_to "delete", "#", data: { action: "nested-form#remove_association" } %>
    </div>
    </div>
    <%= form.hidden_field :_destroy, as: :hidden %>
<% end %>

nested_form_controller.js

import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "links", "template" ]

  connect() {

  }

  add_association(event) {
    event.preventDefault()

    var content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, new Date().getTime())
    this.linksTarget.insertAdjacentHTML('beforebegin', content)
  }

  remove_association(event) {
    event.preventDefault()

    let wrapper = event.target.closest(".nested-fields")
    if(wrapper.dataset.newRecord == "true") {
        wrapper.remove()
    } else {
        wrapper.querySelector("input[name*='_destroy']").value = 1
        wrapper.style.display = "none"
    }
  }
}

Hey Nino,

Editing requires the id of the record to be in the form so it knows which record to edit. You've got _destroy, but not id so it wouldn't know how to delete those either.. You want it as a hidden field and permitted params.

    <%= form.hidden_field :id %>

Hi Chris,

I put <%= form.hidden_field :id %> into my form, and at first it didn't change anything. However, I commented-out the following lines in book.rb that find or create an Author, and the edit form works a charm, so thanks for your help there.

def book_authors_attributes=(book_author_attributes)
  book_author_attributes.values.each do |author_attribute|
    author = Author.find_or_create_by(name: author_attribute["author_attributes"]["name"])
    self.authors << author
  end
end

My question now is how might I be able to make the above code work? It works great on the new form, but I get duplicates again when I use it for editing. I think the problem lies with the self.authors << author part.

I'm guessing that this line is reinserting all the authors back into the form after I've deleted them and causing the duplicates once more?


Yeah, you were manually replacing part of the functionality that's built-in to Rails there.

I don't see anything in this code for deleting authors, just adding new ones so that's part of the problem.


Login or Create An Account to join the conversation.

Subscribe to the newsletter

Join 22,346+ developers who get early access to new screencasts, articles, guides, updates, and more.

    By clicking this button, you agree to the GoRails Terms of Service and Privacy Policy.

    More of a social being? We're also on Twitter and YouTube.