Rails has_many through form with additional attrib

2020-06-18 15:17发布

问题:

I am attempting to create a form that allows a user to add/edit/remove locations to a campaign. All the examples I have currently found are either for HABTM forms (that do not allow the editing of additional attributes that exist in a has_many through configuration) or only list out the existing relationships.

Below is an image showing what I am trying to accomplish.

The list would show every available location. Locations that have a relationship via the campaign_locations model will be checked and have their campaign_location specific attributes editable. Locations that are non-checked should be able to be checked, campaign_location specific data entered, and a new relationship created upon submission.

Below is the code I currently have implemented. I have tried making use of collection_check_boxes, which is very close to what I need except it does not allow me to edit the campaign_location attributes.

I have been able to successfully edit/remove existing campaign_locations, but I cannot figure out how to incorporate this to also show all available locations (like the attached image).


Models

campaign.rb

class Campaign < ActiveRecord::Base
  has_many :campaign_locations
  has_many :campaign_products
  has_many :products,  through: :campaign_products
  has_many :locations, through: :campaign_locations

  accepts_nested_attributes_for :campaign_locations, allow_destroy: true
end

campaign_location.rb

class CampaignLocation < ActiveRecord::Base
  belongs_to :campaign
  belongs_to :location
end

location.rb

class Location < ActiveRecord::Base
  has_many :campaign_locations
  has_many :campaigns, through: :campaign_locations
end

View

campaign/_form.html.haml

= form_for @campaign do |campaign_form|

  # this properly shows existing campaign_locations, and properly allows me
  # to edit the campaign_location attributes as well as destroy the relationship
  = campaign_form.fields_for :campaign_locations do |cl_f|
    = cl_f.check_box :_destroy, {:checked => cl_f.object.persisted?}, false, true
    = cl_f.label cl_f.object.location.title
    = cl_f.datetime_field :pickup_time_start
    = cl_f.datetime_field :pickup_time_end
    = cl_f.text_field :pickup_timezone

  # this properly lists all available locations as well as checks the ones
  # which have a current relationship to the campaign via campaign_locations
  = campaign_form.collection_check_boxes :location_ids, Location.all, :id, :title

Portion of Form HTML

 <input name="campaign[campaign_locations_attributes][0][_destroy]" type="hidden" value="true" /><input id="campaign_campaign_locations_attributes_0__destroy" name="campaign[campaign_locations_attributes][0][_destroy]" type="checkbox" value="false" />
 <label for="campaign_campaign_locations_attributes_0_LOCATION 1">Location 1</label>
 <label for="campaign_campaign_locations_attributes_0_pickup_time_start">Pickup time start</label>
 <input id="campaign_campaign_locations_attributes_0_pickup_time_start" name="campaign[campaign_locations_attributes][0][pickup_time_start]" type="datetime" />
 <label for="campaign_campaign_locations_attributes_0_pickup_time_end">Pickup time end</label>
 <input id="campaign_campaign_locations_attributes_0_pickup_time_end" name="campaign[campaign_locations_attributes][0][pickup_time_end]" type="datetime" />
 <input id="campaign_campaign_locations_attributes_0_location_id" name="campaign[campaign_locations_attributes][0][location_id]" type="hidden" value="1" />
 <input id="campaign_campaign_locations_attributes_0_pickup_timezone" name="campaign[campaign_locations_attributes][0][pickup_timezone]" type="hidden" value="EST" />

 <input name="campaign[campaign_locations_attributes][1][_destroy]" type="hidden" value="true" /><input id="campaign_campaign_locations_attributes_1__destroy" name="campaign[campaign_locations_attributes][1][_destroy]" type="checkbox" value="false" />
 <label for="campaign_campaign_locations_attributes_1_LOCATION 2">Location 2</label>
 <label for="campaign_campaign_locations_attributes_1_pickup_time_start">Pickup time start</label>
 <input id="campaign_campaign_locations_attributes_1_pickup_time_start" name="campaign[campaign_locations_attributes][1][pickup_time_start]" type="datetime" />
 <label for="campaign_campaign_locations_attributes_1_pickup_time_end">Pickup time end</label>
 <input id="campaign_campaign_locations_attributes_1_pickup_time_end" name="campaign[campaign_locations_attributes][1][pickup_time_end]" type="datetime" />
 <input id="campaign_campaign_locations_attributes_1_location_id" name="campaign[campaign_locations_attributes][1][location_id]" type="hidden" value="2" />
 <input id="campaign_campaign_locations_attributes_1_pickup_timezone" name="campaign[campaign_locations_attributes][1][pickup_timezone]" type="hidden" value="EST" />

 <input name="campaign[campaign_locations_attributes][2][_destroy]" type="hidden" value="true" /><input id="campaign_campaign_locations_attributes_2__destroy" name="campaign[campaign_locations_attributes][2][_destroy]" type="checkbox" value="false" />
 <label for="campaign_campaign_locations_attributes_2_LOCATION 3">Location 3</label>
 <label for="campaign_campaign_locations_attributes_2_pickup_time_start">Pickup time start</label>
 <input id="campaign_campaign_locations_attributes_2_pickup_time_start" name="campaign[campaign_locations_attributes][2][pickup_time_start]" type="datetime" />
 <label for="campaign_campaign_locations_attributes_2_pickup_time_end">Pickup time end</label>
 <input id="campaign_campaign_locations_attributes_2_pickup_time_end" name="campaign[campaign_locations_attributes][2][pickup_time_end]" type="datetime" />
 <input id="campaign_campaign_locations_attributes_2_location_id" name="campaign[campaign_locations_attributes][2][location_id]" type="hidden" value="3" />
 <input id="campaign_campaign_locations_attributes_2_pickup_timezone" name="campaign[campaign_locations_attributes][2][pickup_timezone]" type="hidden" value="EST" />

回答1:

The problem you're running into is that the blank locations haven't been instantiated, so your view has nothing to build form elements for. To fix this, you need to build the blank locations in your controller's new and edit actions.

class CampaignController < ApplicationController
  def new
    empty_locations = Location.where.not(id: @campaign.locations.pluck(:id))
    empty_locations.each { |l| @campaign.campaign_locations.build(location: l) }
  end

  def edit
    # do same thing as new
  end
end

Then, in your edit and update actions, you need to remove any locations that have been left blank from the params hash when the user submits the form.

class CampaignController < ApplicationController
  def create
    params[:campaign][:campaign_locations].reject! do |cl|
     cl[:pickup_time_start].blank? && cl[:pickup_time_end].blank? && cl[:pickup_timezone].blank?
    end
  end

  def update
    # do same thing as create
  end
end

Also, I think you'll need a hidden field for the location_id.



回答2:

You should add a non-model attribute checkbox to your model and form, signifying whether to save or remove the relation. Add a hidden field with the relation id to the form, and finally override accepts_nested_attributes_for to save or destroy based on the checkbox and call super.

class CampaignLocation < ActiveRecord::Base
  belongs_to :campaign
  belongs_to :location

  # Returns true if a saved record, used by form
  def option_included
    new_record? ? false : true
  end
end


class Campaign < ActiveRecord::Base
  ...

  accepts_nested_attributes_for :campaign_locations, allow_destroy: true

  def campaign_locations_attributes=(attributes)
    attributes.values.each do |attribute|
      attribute[:_destroy] = true if attribute[:option_included] != '1'
      attribute.delete(:option_included)
    end
    super
  end
end

The form:

= form_for @campaign do |campaign_form|
  - locations.each do |location|
    = campaign_form.fields_for, :campaign_locations, @campaign.campaign_locations.find_or_initialize_by(location_id: location.id) do |cf|
      = cf.check_box :option_included
      = location.name
      = cf.hidden_field :disease_question_option_id
      = cf.datetime_field :pickup_time_start
      = cf.datetime_field :pickup_time_end
      = cf.text_field :pickup_timezone

option_included will return true if there is a saved relation, otherwise false.