Dynamic nested forms with Stimulus *and* has_many :through relationship
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 Author
s, 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 Author
s.
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.
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.