I've been around a few circles with this one.
I was having an issue adding more Regions
to the Listing
on update.
Now I cannot even get multiple Regions
added to the Listing
on create. If anyone can help me with the solution that would be great. A look over my code from fresh (experienced) eyes might notice what I'm doing that is stupid.
- Two models:
Listing
andRegion
- Third model for joining:
Regionalization
Models:
# app/models/listing.rb
class Listing < ApplicationRecord
has_many :regionalizations
has_many :regions, through: :regionalizations
accepts_nested_attributes_for :regionalizations, allow_destroy: true, reject_if: :all_blank
end
# app/models/region.rb
class Region < ApplicationRecord
has_many :regionalizations
has_many :listings, through: :regionalizations
end
# app/models/regionalization.rb
class Regionalization < ApplicationRecord
belongs_to :listing
belongs_to :region
end
Models and associations seem sound to me. I think the problem lies in the controller and or the nested form.
Controller Actions [note I'm using dashboard namespace for this controller]
class Dashboard::ListingsController < Dashboard::BaseController
def new
@listing = Listing.new
end
def create
@listing = Listing.new(listing_params)
@listing.user_id = current_user.id
@listing.regionalizations.build
if @listing.save
redirect_to dashboard_path, notice: "Your Listing was created successfuly"
else
render :new
end
end
def update
respond_to do |format|
if @listing.update(listing_params)
format.html { redirect_to edit_dashboard_listing_path(@listing), notice: 'Your Listing was successfully updated.' }
format.json { render :show, status: :ok, location: @listing }
else
format.html { render :edit }
format.json { render json: @listing.errors, status: :unprocessable_entity }
end
end
end
private
def listing_params
params.require(:listing).permit(:id, :name, :excerpt, :description, :email, :website, :phone_number, :user_id, :featured_image, :business_logo, :address, :category_id, :facebook, :instagram, :twitter, :status, :regionalization_id, gallery_images: [], regionalizations_attributes: [:id, :region_id, :listing_id, :_destroy])
end
end
dashboard/listings/_form:
<%= form_with(model: [:dashboard, listing], local: true) do |f| %>
<article class="card mb-3">
<div class="card-body">
<h5 class="card-title mb-4">Delivery Regions</h5>
<%= f.fields_for :regionalizations do |regionalizations_form| %>
<%= render 'regionalization_fields', f: regionalizations_form %>
<% end %>
<%= link_to_add_fields "Add Region", f, :regionalizations %>
</div>
</article>
<%= f.submit data: { turbolinks: false }, class: "btn btn-outline-primary" %>
<% end %>
_regionalization_fields.html.erb:
<p class="nested-fields">
<%= f.collection_select(:region_id, Region.all, :id, :name, {multiple: true}, {class: 'form-control'}) %>
<%= f.hidden_field :_destroy %>
<%= link_to "Remove", '#', class: "remove_fields" %>
</p>
error on validation when creating a new Listing
:
Regionalizations region must exist
If I add this to the Regionalization table I can get the regionaliztion to work.
belongs_to :region, optional: true
Now my parameters only ever show one regionalization atribute unless I tell it to build 3 or 4.
Like so:
4.times do @listing.regionalizations.build end
I have used Steve Polito's guide to try get this working. I've not changed any of the javascript stuff or application_helper stuff.
The add and delete fields work fine on front end. The remove nested field works fine in the dB.
Am I missing something totally stupid here, please?
The only thing I can notice any different to a new nested field and one pulled in from the build method is the "Selected" tag is not on the new nested field added to the form.
Params on submit:
Started POST "/dashboard/listings" for ::1 at 2020-10-30 20:22:15 +0000
Processing by Dashboard::ListingsController#create as HTML
Parameters: {"authenticity_token"=>"shtfCS/cSj/w/I6S1tNey99L8TKf48Xj0GAOMsODU3l44o0pJdjucCteQXca496aosNCEp7sPD85UM4QO4jEnw==", "listing"=>{"name"=>"", "excerpt"=>"", "description"=>"", "category_id"=>"1", "email"=>"", "phone_number"=>"", "website"=>"", "address"=>"", "facebook"=>"#", "instagram"=>"#", "twitter"=>"#", "regionalizations_attributes"=>{"0"=>{"region_id"=>"14", "_destroy"=>"false"}}}, "commit"=>"Create Listing"}
I'm going to add in the applicaton_helper file taken from Steve's tutorial on nested forms. One of the comments makes mention about the dynamic ability of the code. It works (just not for me). I can achieve what I need on the create method by forcing a numbered loop. Just can't get the fields to add dynamically into the db.
# This method creates a link with `data-id` `data-fields` attributes. These attributes are used to create new instances of the nested fields through Javascript.
def link_to_add_fields(name, f, association)
# Takes an object (@person) and creates a new instance of its associated model (:addresses)
# To better understand, run the following in your terminal:
# rails c --sandbox
# @person = Person.new
# new_object = @person.send(:addresses).klass.new
new_object = f.object.send(association).klass.new
# Saves the unique ID of the object into a variable.
# This is needed to ensure the key of the associated array is unique. This is makes parsing the content in the `data-fields` attribute easier through Javascript.
# We could use another method to achive this.
id = new_object.object_id
# https://api.rubyonrails.org/ fields_for(record_name, record_object = nil, fields_options = {}, &block)
# record_name = :addresses
# record_object = new_object
# fields_options = { child_index: id }
# child_index` is used to ensure the key of the associated array is unique, and that it matched the value in the `data-id` attribute.
# `person[addresses_attributes][child_index_value][_destroy]`
fields = f.fields_for(association, new_object, child_index: id) do |builder|
# `association.to_s.singularize + "_fields"` ends up evaluating to `address_fields`
# The render function will then look for `views/people/_address_fields.html.erb`
# The render function also needs to be passed the value of 'builder', because `views/people/_address_fields.html.erb` needs this to render the form tags.
render(association.to_s.singularize + "_fields", f: builder)
end
# This renders a simple link, but passes information into `data` attributes.
# This info can be named anything we want, but in this case we chose `data-id:` and `data-fields:`.
# The `id:` is from `new_object.object_id`.
# The `fields:` are rendered from the `fields` blocks.
# We use `gsub("\n", "")` to remove anywhite space from the rendered partial.
# The `id:` value needs to match the value used in `child_index: id`.
link_to(name, '#', class: "add_fields", data: {id: id, fields: fields.gsub("\n", "")})
end
Your issue
Your error is telling you that at least one
Regionalization
record can't be saved because it doesn't have aregion_id
.And adding
belongs_to :region, optional: true
is actually undermining your data integrity. You now haveRegionalization
records where theListing
is present, but not theRegion
, which defeats the purpose of theRegionalization
join table. Plus, you could totally bloat your database with one-sided records in your join table.Easy route
You need to reject
Regionalization
s that don't have BOTH a:listing_id
and a:region_id
This:
accepts_nested_attributes_for :regionalizations, allow_destroy: true, reject_if: :all_blank
is not doing the job for you. The record isn't "all blank" because it has a
:listing_id
Handling this in the model is doable:
v2 update
Yes,
@listing.regionalizations.build
only builds 1 new record.If you know exactly how many drop-downs you want to appear on the page, you can use your
4.times do @listing.regionalizations.build end
code. This loads 4 new records into memory and Rails will find them as a collection when it runs this:BUT
f.fields_for
is NOT a loop so the fact that this works is a bit mysterious to me.If you are creating 4 child records, you should use
f.fields_for
4 times.You can do this by changing around your partials a bit:
dashboard/listings/_form:
RENAME:
_regionalization_fields.html.erb
to_regionalizations_form.html.erb
for clarity:If you need a variable number of
Regionalizations
, the best way to handle that is with some Rails AJAX and UJS. This would mean creating the form with only 1 newRegionalization
record and having a button that says "Add another". The user clicks it and you add in another select field with everything you need, including the correct conventions to have the results posted to the params. This is a whole other can of worms, but I still recommend (and so does Steve Polito) Ryan Bate's Railscast on nested formsBetter route (still)
You've opted for the "has_many / belongs_to :through" option, but if
Regionalization
is truly just a join table, you could simplify this with ahas_and_belongs_to_many
.If you're unsure if you can go this route, consider this: if
Regionalization
needs no methods or other dB fields beyond the foreign key ID's, then you've added complexity with a model you don't need.If
Regionalization
can be a more standard JoinTable, you can avoid some nesting complexity and allow Rails to do it for you.If you can go this way, see this doc. You'll need to change your migration, but you can use the
create_join_table
to let Rails handle the naming and indexes for you. Rails will call this join tablelistings_regions
instead ofregionalizations
, but you won't ever really need to reference it.Here's how your models could look with a simple join table: