ruby-on-railsnested-form-forcollection-select

Rails 7.0.4- form_for - collection_select with multiple options in edit action


I have three tables:

Business case: Staff can work in multiple locations. Association between Staff and Location is done through staff_locations table. While creating Staff entry I am choosing locations that he/she belongs to. This is working fine.

But I have a problem with correct display of collection_select in the edit action. It is displayed as many times as many entries matching staff_id there are in the staff_locations table.

I can't figure out how to fix that and I didn't find any good hint anywhere so far.

models

class Staff < ApplicationRecord
has_many :visits, dependent: :destroy
has_many :work_schedules
has_many :customers, through: :visits

    has_many :staff_locations, dependent: :destroy
    has_many :locations, through: :staff_locations
    
    accepts_nested_attributes_for :staff_locations, allow_destroy: true

def staff_locations_attributes=(staff_locations_attributes)

        staff_locations_attributes.values[0][:location_id].each do |loc_id| 
            if !loc_id.blank?
                staff_location_attribute_hash = {}; 
                staff_location_attribute_hash['location_id'] = loc_id;              
                            
                staff_location = StaffLocation.create(staff_location_attribute_hash)
                self.staff_locations << staff_location
            end
            
        end
    end

end

class StaffLocation < ApplicationRecord
belongs_to :staff
belongs_to :location

validates :staff_id, :location_id, uniqueness: true
end

class Location < ApplicationRecord
has_many :staff_locations
has_many :staffs, through: :staff_locations
end

staffs_controller

class StaffsController < ApplicationController
before_action :set_staff, only: %i [ show edit update destroy ]

def index
@staffs = Staff.all
end

def show
end

def new
@staff = Staff.new
@staff.staff_locations.build
end

def create
@staff = Staff.new(staff_params)

    if @staff.save
      redirect_to @staff
    else
      render :new, status: :unprocessable_entity
    end

end

def edit
end

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

def destroy
end

private
    def staff_params
      params.require(:staff).permit(:first_name, :last_name, :status, :staff_type, staff_locations_attributes: [:location_id => [] ])
      #due to multiple select in the new staff form, staff_locations_attributes needs to contain Array of location_ids.
      #Moreover check Staff model's method: staff_locations_attributes. It converts staff_locations_attributes into hashes.
    end

    def set_staff
      @staff = Staff.find(params[:id])
    end

end

form partial

<%= form_for(@staff) do |form| %>

    <div>
        <% if params["action"] != "edit" %>
            
            <%= form.fields_for :staff_locations do |staff_location_form| %>
                <%= staff_location_form.label :location_id, 'Associated Locations' %><br>
                <%= staff_location_form.collection_select :location_id, Location.all, :id, :loc_name, {include_blank: false}, {:multiple => true } %>
            <% end %>
    
        <% else %>
    
            <%= form.fields_for :staff_locations do |staff_location_form| %>
                <%= staff_location_form.label :location_id, 'Associated Locations' %><br>
                <%= staff_location_form.collection_select :location_id, Location.all, :id, :loc_name, {selected: @staff.locations.map(&:id).compact, include_blank: false}, {:multiple => true} %>
                <% #debugger %>
            <% end %>
    
        <% end %>
    </div>
    
    <div>
        <%= form.submit %>
    </div>

<% end %>

UPDATE

After changes suggested by @Beartech, update method works fine. However new action stopped working. Below I am pasting what I captured while submitting form to create one entry in Staff table and two associated entries in Staff_locations table.

Before saving objetct to the DB, I checked in the console:

After that I did save. I don't understand reason why it ends up with FALSE status.

   14|     #@staff.staff_locations.build
    15|   end
    16| 
    17|   def create
    18|     @staff = Staff.new(staff_params)
=>  19|     debugger
    20| 
    21|     respond_to do |format|
    22|       if @staff.save
    23|         format.html { redirect_to @staff, notice: 'Staff was successfully created.' }
=>#0    StaffsController#create at ~/rails_projects/dentysta/app/controllers/staffs_controller.rb:19
  #1    ActionController::BasicImplicitRender#send_action(method="create", args=[]) at ~/rails_projects/dentysta/vendor/bundle/ruby/3.0.0/gems/actionpack-7.0.4/lib/action_controller/metal/basic_implicit_render.rb:6
  # and 75 frames (use `bt' command for all frames)

(ruby) @staff
#<Staff:0x00007f2400acb2e8 id: nil, first_name: "s", last_name: "dd", status: "Active", staff_type: "Doctor", created_at: nil, updated_at: nil>

(ruby) @staff.location_ids
[4, 5]

(ruby) staff_params
#<ActionController::Parameters {"first_name"=>"s", "last_name"=>"dd", "status"=>"Active", "staff_type"=>"Doctor", "location_ids"=>["", "4", "5"]} permitted: true>

(ruby) @staff.save
  TRANSACTION (0.1ms)  begin transaction
  ↳ (rdbg)//home/mw/rails_projects/dentysta/app/controllers/staffs_controller.rb:1:in `create'
  StaffLocation Exists? (0.1ms)  SELECT 1 AS one FROM "staff_locations" WHERE "staff_locations"."staff_id" IS NULL LIMIT ?  [["LIMIT", 1]]
  ↳ (rdbg)//home/mw/rails_projects/dentysta/app/controllers/staffs_controller.rb:1:in `create'
  StaffLocation Exists? (0.1ms)  SELECT 1 AS one FROM "staff_locations" WHERE "staff_locations"."location_id" = ? LIMIT ?  [["location_id", 4], ["LIMIT", 1]]
  ↳ (rdbg)//home/mw/rails_projects/dentysta/app/controllers/staffs_controller.rb:1:in `create'
  CACHE StaffLocation Exists? (0.0ms)  SELECT 1 AS one FROM "staff_locations" WHERE "staff_locations"."staff_id" IS NULL LIMIT ?  [["LIMIT", 1]]
  ↳ (rdbg)//home/mw/rails_projects/dentysta/app/controllers/staffs_controller.rb:1:in `create'
  StaffLocation Exists? (0.3ms)  SELECT 1 AS one FROM "staff_locations" WHERE "staff_locations"."location_id" = ? LIMIT ?  [["location_id", 5], ["LIMIT", 1]]
  ↳ (rdbg)//home/mw/rails_projects/dentysta/app/controllers/staffs_controller.rb:1:in `create'
  TRANSACTION (0.1ms)  rollback transaction
  ↳ (rdbg)//home/mw/rails_projects/dentysta/app/controllers/staffs_controller.rb:1:in `create'
false

(rdbg) c    # continue command

  TRANSACTION (0.1ms)  begin transaction
  ↳ app/controllers/staffs_controller.rb:22:in `block in create'
  StaffLocation Exists? (0.2ms)  SELECT 1 AS one FROM "staff_locations" WHERE "staff_locations"."staff_id" IS NULL LIMIT ?  [["LIMIT", 1]]
  ↳ app/controllers/staffs_controller.rb:22:in `block in create'
  StaffLocation Exists? (0.1ms)  SELECT 1 AS one FROM "staff_locations" WHERE "staff_locations"."location_id" = ? LIMIT ?  [["location_id", 4], ["LIMIT", 1]]
  ↳ app/controllers/staffs_controller.rb:22:in `block in create'
  CACHE StaffLocation Exists? (0.0ms)  SELECT 1 AS one FROM "staff_locations" WHERE "staff_locations"."staff_id" IS NULL LIMIT ?  [["LIMIT", 1]]
  ↳ app/controllers/staffs_controller.rb:22:in `block in create'
  StaffLocation Exists? (0.2ms)  SELECT 1 AS one FROM "staff_locations" WHERE "staff_locations"."location_id" = ? LIMIT ?  [["location_id", 5], ["LIMIT", 1]]
  ↳ app/controllers/staffs_controller.rb:22:in `block in create'
  TRANSACTION (0.1ms)  rollback transaction
  ↳ app/controllers/staffs_controller.rb:22:in `block in create'
  Rendering layout layouts/application.html.erb
  Rendering staffs/new.html.erb within layouts/application
  Location Count (0.1ms)  SELECT COUNT(*) FROM "locations"
  ↳ app/views/staffs/_form.html.erb:36
  Location Load (0.1ms)  SELECT "locations".* FROM "locations"
  ↳ app/views/staffs/_form.html.erb:36
  Rendered staffs/_form.html.erb (Duration: 18.5ms | Allocations: 2989)
  Rendered staffs/new.html.erb within layouts/application (Duration: 21.7ms | Allocations: 3059)
  Rendered layout layouts/application.html.erb (Duration: 24.6ms | Allocations: 4054)
Completed 422 Unprocessable Entity in 2302301ms (Views: 30.1ms | ActiveRecord: 1.8ms | Allocations: 174939)

Solution

  • Edit Important: Using a multi-select may have unintended user interface issues. When you use the code below the multi-select for an existing record will load with the existing associated Locations highlighted as selections. If you don't touch that form element and then save the form, they will remain associated. But the entire multi-select list may not display at once. And if the person can not see all of the selected elements they may click on one and that will unselect all the others, thus deleting those associations when the record saves. I have edited the answer to add size: to the HTML attributes. This will show all of the options so they can see which are selected and what happens when they click on one (the deselecting of all others requiring a shfit/option select to get them reselected). I would consider if this is the correct interface element for what you are doing. You may want to consider collection_check_boxes as the correct UI element for this as they will have to purposely unselect any they want to get rid of and won't have to reselect them every time they add or remove one location.

    Took me a while to remember how to do this. It's because you are focusing on the join table. Normally that is what you would do when you WANT multiple form fields. But you are actually looking to leverage the has_many relationship.

    Remember, your accepts_nested_attributes_for give you a method of location_ids= which lets you set those locations just by passing the IDs. Rails will take care of making the associations using the join model.

    In your console try:

    @staff = Staff.first
    # returns a staff object
    @staff.locations
    #returns an array of location objects due to the has_many
    @staff.location_ids
    # [12, 32]
    @staff.location_ids = [12, 44, 35]
    #this will update the joined locations to those locations by id. If any current locations are not in that array, they get deleted from the join table.
    
    

    change your strong params from:

      params.require(:staff).permit(:first_name, :last_name, :status,
      :staff_type, staff_locations_attributes: [:location_id => [] ])
    

    to:

      params.require(:staff).permit(:first_name, :last_name, :status,
     :staff_type, :location_ids => [] )
    

    In your form you just want ONE form element, built using methods on @staff:

    <%= f.label :locations %><br />
    <%= f.collection_select :location_ids, Location.all, :id, :name,{selected: @staff.location_ids, 
    include_blank: false}, {:multiple => true, size: Location.all.count } %>
    

    So this works since .location_ids is a valid method on @staff, Location.all returns a collection of all locations, then the two symbols (:id and :name) are both valid methods for a single location object. Then in the selected... you are just using the same .location_ids to grab the ones that already exist to mark them as selected.

    I'd forgotten how to do this, it's been a while. Once I remembered it was so easy.