I have three tables:
- Staff
- Staff_locations
- Locations
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:
- @staff
- @staff.location_ids
- staff_params
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)
CodePudding user response:
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.
CodePudding user response:
For those who will be struggling with similar case in the future, I am pasting what works for me right now. @Beartech thanks once again for your help. It saved me a lot of time.
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
end
class StaffLocation < ApplicationRecord
belongs_to :staff
belongs_to :location
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
#debugger
end
def new
@staff = Staff.new
end
def create
@staff = Staff.new(staff_params)
debugger
respond_to do |format|
if @staff.save!
format.html { redirect_to @staff, notice: 'Staff was successfully created.' }
format.json { render :show, status: :ok, staff: @staff }
#redirect_to @staff
else
format.html { render :new, status: :unprocessable_entity, notice: 'Somthing went wrong' }
format.json { render json: @staff.errors, status: :unprocessable_entity }
#render :new, status: :unprocessable_entity
end
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, :location_ids => [] )
end
def set_staff
@staff = Staff.find(params[:id])
end
end
_form partial
<%= form_for(@staff) do |form| %>
<div>
<%= form.label :first_name %><br>
<%= form.text_field :first_name %>
<% @staff.errors.full_messages_for(:first_name).each do |message| %>
<div><%= message %></div>
<% end %>
</div>
<div>
<%= form.label :last_name %><br>
<%= form.text_field :last_name %>
<% @staff.errors.full_messages_for(:last_name).each do |message| %>
<div><%= message %></div>
<% end %>
</div>
<div>
<%= form.label :staff_type %><br>
<%= form.collection_select :staff_type, Staff.valid_types, :to_s, :to_s, {include_blank: false}, {:multiple => false} %>
<% @staff.errors.full_messages_for(:staff_type).each do |message| %>
<div><%= message %></div>
<% end %>
</div>
<div>
<%= form.label :status %><br>
<%= form.collection_select :status, Staff.valid_statuses, :to_s, :to_s, {include_blank: false}, {:multiple => false} %>
<% @staff.errors.full_messages_for(:status).each do |message| %>
<div><%= message %></div>
<% end %>
</div>
<div>
<%= form.label :locations %><br />
<%= form.collection_select :location_ids, Location.all, :id, :loc_name,{selected: @staff.location_ids, include_blank: false}, {:multiple => true, size: Location.all.count } %>
</div>
<div>
<%= form.submit %>
</div>
<% end %>