7

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.

List of locations to add to a campaign

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" />
jamgood96
  • 141
  • 1
  • 7
  • I think [cocoon](https://github.com/nathanvda/cocoon) will save you a lot of time. – mtkcs Feb 24 '15 at 20:06
  • So are you saying that everything looks correct on your form when you go to the web page, but when you click on a new location and add something to the text boxes, and hit save, you get nothing happening? What does your controller look like? Also it helps to see the actual HTML it is generating. I've done some complex forms that update the checkboxes using AJAX based on a dropdown choice. It can get tricking in your `params{}` hash when you want to include fields that are generated on the fly. – Beartech Feb 24 '15 at 22:43

2 Answers2

2

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.

Richard Jones
  • 4,760
  • 3
  • 27
  • 34
  • This seems to be the right path for sure! I've got things almost working, but am running into a few issues. One issue is that on submit a CampaignLocation is trying to be created with no campaign_id. Possibly this has something to do with using nested attributes? The second issue is the piece of code you gave for the create and update methods; this does not seem to work correctly with the nested attributes. I've updated the original post to include the HTML form with the fields that I'm using in hopes that might clear it up some. Thanks! – jamgood96 Feb 27 '15 at 22:11
1

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 :campaign_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.

riley
  • 2,387
  • 1
  • 25
  • 31
  • 1
    This solution works fine. You need to fix `:disease_question_option_id` into `:campaign_id` and add to strong parameters in locations_controller `campaign_locations_attributes: [:id, :campaign_id, :_destroy, :option_included, :other_stuff_you_need]` – Mauro Feb 22 '21 at 16:29