Rails 6 Joins Tables using has_many relationships and accepts_nested_attributes_for

559 Views Asked by At

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 and Region
  • 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.

imgur image

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
1

There are 1 best solutions below

2
On

Your issue

Your error is telling you that at least one Regionalization record can't be saved because it doesn't have a region_id.

And adding belongs_to :region, optional: true is actually undermining your data integrity. You now have Regionalization records where the Listing is present, but not the Region, which defeats the purpose of the Regionalization join table. Plus, you could totally bloat your database with one-sided records in your join table.

Easy route

You need to reject Regionalizations 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:

# 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: :missing_keys

  private

  def missing_keys(attributes)
    attributes['region_id'].blank? ||
    attributes['listing_id'].blank?
  end
end

v2 update

So for whatever reason the regionalizations.build will only send the first one through the params. If I make it a loop like in the question it will pass them all through.

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:

<%= render 'regionalization_fields', f: regionalizations_form %>

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:

class Dashboard::ListingsController < Dashboard::BaseController      
  def new
    @listing = Listing.new
    4.times { @listing.regionalization.build }
    @regionalizations = @listing.regionalizations
  end
  ...
  def edit
    @listing = Listing.find(params[:id])
    4.times { @listing.regionalization.build }

    # will include ALL existing regionalizations, and 4 new ones
    @regionalizations = @listing.regionalizations
  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>

      <%= render 'regionalization_fields', collection: @regionalizations %> 
                                             
      <%= link_to_add_fields "Add Region", f, :regionalizations %>                      
    </div>
  </article>
  <%= f.submit data: { turbolinks: false }, class: "btn btn-outline-primary" %>
<% end %>

RENAME: _regionalization_fields.html.erb to _regionalizations_form.html.erb for clarity:

<!-- change the form handler's name so it doesn't conflict with the local variable 'regionalizations_form` -->
<%= f.fields_for regionalizations_form do |f_reg| %>
  <p class="nested-fields">
    <%= f_reg.collection_select(:region_id, Region.all, :id, :name, {multiple: true}, {class: 'form-control'}) %>
    <%= f_reg.hidden_field :_destroy %>
    <%= link_to "Remove", '#', class: "remove_fields" %>
  </p>
<% end %>

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 new Regionalization 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 forms

Better 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 a has_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 table listings_regions instead of regionalizations, but you won't ever really need to reference it.

Here's how your models could look with a simple join table:

# app/models/listing.rb
class Listing < ApplicationRecord
  has_and_belongs_to_many :regions
  
  accepts_nested_attributes_for :regions, :dependent_destroy
end

# app/models/region.rb
class Region < ApplicationRecord
  has_and_belongs_to_many :listings
end

# app/models/regionalization.rb can be deleted