Ask A Question

Notifications

You’re not receiving notifications from this thread.

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

Nino Rosella asked in General

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"
    }
  }
}

Reply

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

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?

Reply

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.

Reply

I wanted to generate a pair of partials. My approach is prerhaps flawed, but I was generating the same "id" for both partials when doing the replace for "NEW_RECORD". The way I managed to get around that was...

(my apologies I don't know how to add code block here...)

content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, Math.floor(new Date().valueOf() * Math.random())).replace(/NEW_SECOND_RECORD/g, Math.floor(new Date().valueOf() * Math.random()))

Perhaps this is helpful to anyone else that comes across this post.

Reply
Join the discussion
Create an account Log in

Want to stay up-to-date with Ruby on Rails?

Join 82,464+ developers who get early access to new tutorials, screencasts, articles, and more.

    We care about the protection of your data. Read our Privacy Policy.