0

I'm using Rails 5.0. I've set up an app where each profile has many locations. I'm having trouble getting the locations to actually save to the db. Each user should be able to save multiple zip codes to their profile. I've associated profile and locations and zip_codes is a field in the locations table. My code is below.

A little more context - I want each user/profile to be associated to many zip codes. The goal is to allow users to search for profiles based on a zip code.

UPDATED CODE I've updated my code as suggested here - How to save a nested resource in ActiveRecord using a single form (Ruby on Rails 5)

I'm still having the issues after updating the Profile.rb model to include accepts_nested_attributes_for :locations I'm still seeing the Unpermitted paramter: location as shown in the console log below.

schema.rb

   create_table "locations", force: :cascade do |t|
    t.integer "user_id"
    t.string  "zip_codes"
    t.integer "profile_id"
    t.index ["profile_id"], name: "index_locations_on_profile_id"
    t.index ["user_id"], name: "index_locations_on_user_id"
  end

  create_table "profiles", force: :cascade do |t|
    t.integer  "user_id"
    t.string   "first_name"
    t.string   "last_name"
    t.string   "services_offered"
    t.string   "phone_number"
    t.string   "contact_email"
    t.string   "description"
    t.datetime "created_at",          null: false
    t.datetime "updated_at",          null: false
    t.string   "avatar_file_name"
    t.string   "avatar_content_type"
    t.integer  "avatar_file_size"
    t.datetime "avatar_updated_at"
    t.string   "business_name"
    t.string   "short_term"
    t.string   "long_term"
  end

  create_table "users", force: :cascade do |t|
    t.string   "email",                  default: "", null: false
    t.string   "encrypted_password",     default: "", null: false
    t.string   "reset_password_token"
    t.datetime "reset_password_sent_at"
    t.datetime "remember_created_at"
    t.integer  "sign_in_count",          default: 0,  null: false
    t.datetime "current_sign_in_at"
    t.datetime "last_sign_in_at"
    t.string   "current_sign_in_ip"
    t.string   "last_sign_in_ip"
    t.datetime "created_at",                          null: false
    t.datetime "updated_at",                          null: false
    t.integer  "plan_id"
    t.string   "stripe_customer_token"
    t.index ["email"], name: "index_users_on_email", unique: true
    t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
  end

user.rb

class User < ApplicationRecord
  
  belongs_to :plan
  has_one :profile 
  has_many :locations, through: :profile
  accepts_nested_attributes_for :locations

profile.rb

class Profile < ActiveRecord::Base
   belongs_to :user
   has_many :locations, inverse_of: :profile
   accepts_nested_attributes_for :locations
end

location.rb

class Location < ActiveRecord::Base
    belongs_to :user, optional: true
    belongs_to :profile, optional: true
end

profiles_controller.rb

class ProfilesController < ApplicationController

  #POST to /users/:user_id/profile
   def create
       # Ensure we have the user who is filling out the form
      @user = User.find( params[:user_id] ) 
      # Create profile linked to this specific user
      @profile = @user.build_profile( profile_params )
      if @profile.save
          flash[:success] = "Profile saved!"
          redirect_to user_path( params[:user_id] )
      else
          render action: :new
      end
   end

   #PUT to /users/:user_id/profile
   def update
       # Retrieve user from the database
       @user = User.find( params[:user_id] )
       # Retrieve that user's profile
       @profile = @user.profile
       # Mass assign edited profile attributes and save (update)
       if @profile.update_attributes(profile_params)
           flash[:success] = "Profile updated!"
           # Redirect user to profile page
           redirect_to user_path(id: params[:user_id])
       else
           render action: :edit
       end
   end

 private
    def profile_params
    params.require(:profile).permit(:first_name, :last_name, :avatar, :job_title, :phone_number, :contact_email, :description, :services_offered, :long_term, :short_term, locations_attributes: [:id, :zip_codes])
   end
end

And here is the view where I'm trying to insert the zip_codes field and allow users to save many to their profile. The field shows and I can successfully submit, but the zip codes won't show when I go back to edit it. I'm also not sure they're saving.

_form.html.erb

<%= form_for @profile, url: user_profile_path, :html => { :multipart => true } do |f| %>
<div class="form-group">
    <%= f.label :first_name %>
    <%= f.text_field :first_name, class: 'form-control' %>
  </div>
  <div class="form-group">
    <%= f.label :last_name %>
    <%= f.text_field :last_name, class: 'form-control' %>
  </div>
  </div>
    <div class="form-group">
    <%= f.fields_for :locations do |f| %>
    <%= f.label :zip_codes, "Zip codes served" %></br>
    <%= f.text_field :zip_codes, 'data-role'=>'tagsinput' %>
    <% end %>
  </div>
<% end %>

UPDATE* Here's the console PATCH request. I edited a few other fields, so it's clear that nothing is running to update the zip_codes field.

Started PATCH "/users/1/profile" for 24.9.150.43 at 2020-11-23 23:35:53 +0000
Cannot render console from 24.9.150.43! Allowed networks: 127.0.0.1, ::1, 127.0.0.0/127.255.255.255
Processing by ProfilesController#update as HTML
  Parameters: {"utf8"=>"✓", "authenticity_token"=>"QPHsRWYDY5hxHr4zEL/XW8h2qzDqvhqIPvU/mBlc4exOyqHSxD9A9vSzShNF+afllkDFejqsSP0DiVwGa/9jsQ==", "profile"=>{"first_name"=>"Person", "last_name"=>"Test", "long_term"=>"true", "short_term"=>"true", "location"=>{"zip_codes"=>"80212"}, "phone_number"=>"507-222-2222", "contact_email"=>"kyle@example.com", "description"=>"Test account with test description."}, "commit"=>"Update Profile", "user_id"=>"1"}
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? ORDER BY "users"."id" ASC LIMIT ?  [["id", 1], ["LIMIT", 1]]
  User Load (0.1ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  CACHE (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Profile Load (0.1ms)  SELECT  "profiles".* FROM "profiles" WHERE "profiles"."user_id" = ? LIMIT ?  [["user_id", 1], ["LIMIT", 1]]
Unpermitted parameter: location
   (0.1ms)  begin transaction
  SQL (7.2ms)  UPDATE "profiles" SET "first_name" = ?, "short_term" = ?, "updated_at" = ? WHERE "profiles"."id" = ?  [["first_name", "Person"], ["short_term", "true"], ["updated_at", 2020-11-23 23:35:53 UTC], ["id", 1]]
   (7.8ms)  commit transaction
Redirected to https://a434446a3d264614901296378175dad9.vfs.cloud9.us-west-2.amazonaws.com/users/1
Completed 302 Found in 28ms (ActiveRecord: 15.3ms)
kgrant211
  • 3
  • 3
  • Does this answer your question? [How to save a nested resource in ActiveRecord using a single form (Ruby on Rails 5)](https://stackoverflow.com/questions/39682727/how-to-save-a-nested-resource-in-activerecord-using-a-single-form-ruby-on-rails) – Matthew Nov 23 '20 at 22:33
  • Can you post the webserver console output when you save the profile? – Beartech Nov 23 '20 at 23:05
  • Also what are your `User` relations? It looks like a User has many profiles and the User has many locations through profiles? Something seems off about your relations. And there probably should be some `accepts_nested_attributes_for` in profile. – Beartech Nov 23 '20 at 23:11
  • Thank you. I think that did answer the question, so I've updated the ```profile.rb``` file to be ```has_many :locations, inverse_of: :profile accepts_nested_attributes_for :locations``` and the view has ```
    <%= f.fields_for :locations do |locations_form| %> <%= locations_form.label :zip_codes, "Zip codes served" %> <%= locations_form.text_field :zip_codes, class: 'form-control' %> <% end %>
    ``` The only issue is that the field no longer shows on the frontend.
    – kgrant211 Nov 23 '20 at 23:24
  • I've also updated the permit params in the controller in the format you linked to - ```locations_attributes: [:id, :zip_codes]``` – kgrant211 Nov 23 '20 at 23:26
  • @Beartech - I added the console output in the original post. I also removed the user to location association. Still not seeing any change. – kgrant211 Nov 23 '20 at 23:39
  • You should edit your code in the question in stead of putting it in comments. Also if that console is the latest output you are still being blocked by the permitted params. `Unpermitted parameter: location` – Beartech Nov 23 '20 at 23:42
  • You need to add the location to permitted params like `location: [:zip_codes]` . Also are you storing multiple zip_codes in the :zip_codes string? Rails makes assumptions based on plural vs singular. Normally a column is singular if it is in a has_many relation because if something "has_many" I can then use a plural of that method/column name to get all of them. – Beartech Nov 23 '20 at 23:46
  • Also why does the `locations` table have both `profile_id` and `profiles_id`. Why does location belong to user and profile? Normally a user would have a profile, a profile would have a location, and a user would have locations through profile. If you want to have multiple locations belonging to anything (user or profile) I would have each location store ONE zip code, and then a join table called `profiles_locations`. Try reading through this: https://guides.rubyonrails.org/association_basics.html#the-has-many-through-association – Beartech Nov 23 '20 at 23:53
  • @Beartech I updated the permitted params at the bottom of the post. The ```profile_id``` and the ```profiles_id``` exist because I made a mistake when creating the table the first time around. Is it possible they're causing the issue? – kgrant211 Nov 24 '20 at 00:05
  • I also fogot to add that I am trying to save multiple zip codes per user and profile. – kgrant211 Nov 24 '20 at 00:29
  • I would remove the column `profiles_id` just to be sure. Can you add your User model rather than expect me to know or guess how it is related? – Beartech Nov 24 '20 at 01:29
  • And have you thought about why you are using a model like `Location` just to store zip codes? Will locations also have more attributes not listed here (fine if they do)? You can store a list of zip codes in a string in which case you can just add that column to `Profile`. If you want to eventually do things like `some_user.profile.locations` or `some_user.locations` to get a list of location object, then you need to restructure your data. – Beartech Nov 24 '20 at 01:40
  • I added the user schema and model. I want to store the zip codes in separate table because I want the zip code to be the main method of searching for profiles. It's meant to allow users to search for businesses based on the areas they cover. Thanks again for your help. – kgrant211 Nov 24 '20 at 02:45
  • also add to Profile.rb: `accepts_nested_attributes_for :locations` – Vitalyp Nov 24 '20 at 04:40
  • add dynamic-form gem to your gemfile and never have such troubles – Vitalyp Nov 24 '20 at 05:18
  • @Vitalyp I've added ```accepts_nested_attributes_for :locations``` and updated the code in my original post to reflect it. For some reason I'm still seeing ```Unpermitted parameter: location``` – kgrant211 Nov 24 '20 at 18:01

1 Answers1

0

OK, since you want to be able to search for profiles via zip code you will want to store the zip codes in the location table. But if you have 100 users that list 98057 as their zip code you don't want 100 unique locations. You want a location with zip 98057, and a join table with an entry of profile_id and location_id.

So Profile model would look like:

class Profile < ActiveRecord::Base
  belongs_to: :user
  has_many: :profile_locations inverse_of: :profile
  has_many: locations through: profile_locations

  accepts_nested_attributes_for: :profile_location
end

And Location model:

class Location < ActiveRecord::Base
  has_many: profile_locations
  has_many: profiles through: profile_locations
end

Add a ProfileLocation model:

class ProfileLocation < ActiveRecord::Base
  belongs_to: :profile
  belongs_to: :location
  validates_presence_of :profile
  validates_presence_of :location

  accepts_nested_attributes_for :location

end

The locations table should be:

create_table "locations", force: :cascade do |t|
    t.string  "zip_code"  #note SINGULAR
end

And the profile_locations table would be:

create_table :profile_locations do |t|
  t.belongs_to :location
  t.belongs_to :profile
  t.timestamps
end

That's the easy part. Now you have to get the zip codes in and out of the DB using your form:

<div class="form-group">
  <%= f.label :zip_codes, "Zip codes served" %></br>
  <%= f.text_field :zip_codes, value: @profile.locations.pluck(:zip_code).join(' ,'),'data-role'=>'tagsinput' %>
</div>

We use value: @profile.locations.pluck(:zip_code).join(' ,') to fill in the edit version of the form with the current zip codes in a text field. When it is in the new form it will be nil.

So you will get a param of

profile: {zip_codes: '99019, 98057, 06850'}

You need to convert those into an array so add a method to Profiles controller:

class ProfilesController < ApplicationController
  before_action: :zip_array only:[:create, :update]
   ....

And then make a private method like:

def zip_array
  profile_params[:zip_codes] = profile_params[:zip_codes].scan(/\d{5}/)
  #replaces the string '99019, 98057, 06850' with the array ['99019', '98057', '06850']
end

In your create and update methods you want to build the associated zip code entries. But in update you want to get rid of any that already exist an update in case the person is removing some zip codes and adding others.

So in the Create action:

def create
   # Ensure we have the user who is filling out the form
  @user = User.find( params[:user_id] ) 
  # Create profile linked to this specific user
  @profile = @user.build_profile( profile_params )
  profile_params[:zip_codes].each do |zip|
    @profile.locations.build(zip_code: zip)
  end
  if @profile.save
      flash[:success] = "Profile saved!"
      redirect_to user_path( params[:user_id] )
  else
      render action: :new
  end
end

And in the Update action:

def update
  # Retrieve user from the database
  @user = User.find( params[:user_id] )
  @profile = user.profile
  # we wrap this in a rescue block in case the transaction fails
  begin
    Profile.transaction do 
      @profile.profile_locations.destroy_all  #get rid of all of them
      profile_params[:zip_codes].each do |zip|
       @profile.locations.build(zip_code: zip) #build the new locations
      end
      @profile.update_attributes(profile_params) #saving the profile saves the locations we built
      flash[:success] = "Profile updated!"
      redirect_to user_path(id: params[:user_id])
    end  
  rescue  #if the transaction fails it will roll back the destroy_all
    render action: :edit
  end
end
Beartech
  • 6,173
  • 1
  • 18
  • 41
  • You are using some odd conventions in your app design, so the normal way that nested forms are used doesn't really fit. If you had just one zip code per profile, it would be far simpler. Also if you were nesting a resource that was just a `has_many` and `belongs_to` relationship you'd use `zipcode_ids` in the nested attributes. – Beartech Nov 25 '20 at 08:40
  • Thank you! I appreciate your help and it makes sense to me. I didn't think of having the join table. I made the updates and am seeing a ```undefined method `scan' for nil:NilClass``` error. It seems like the scan method is causing some issues. Currently debugging it. – kgrant211 Nov 30 '20 at 17:43
  • If the zipcode field is blank, then `.scan` gets called on nil. You can add a validation that requires it to not be blank. Or use `profile_params[:zip_codes].try(:scan, (/\d{5}/))` – Beartech Nov 30 '20 at 19:52
  • There are a lot of situations in Rails where you might get a nil value and that's OK, but many methods don't play well with that. So the came up with `.try(:method, arguments)` so you don't have to constantly have `if` or `unless` statements for when the value is nil. – Beartech Nov 30 '20 at 19:54
  • I'm actually sending it with a value and it's still returning that error. When I make the ```.try``` change you suggest I now get ```undefined method `each' for nil:NilClass```. I'm not sure why it's still sending ```nil``` I can see these parameters getting passed in the request ```"locations"=>{"zip_codes"=>"12345"},``` – kgrant211 Nov 30 '20 at 23:55
  • Below where you see the params coming across in the console log, do you see anything as "unpermitted"? Also since this is the profile controller you need to make sure the zipcodes aren't nested in the params. Normall they would be if you were using IDs to pass values, but you are not. – Beartech Dec 01 '20 at 00:33
  • Check out the edit I made to my answer for the ERB to produce the form. You want to remove the nesting of `fields for` as it is not needed here. – Beartech Dec 01 '20 at 00:37
  • You may also have to add `.try` to the :value for that zipcode field in the ERB template since we grab the user's zipcodes and then `.join` them. – Beartech Dec 01 '20 at 00:42
  • Actually `.pluck` should return an empty array which `.join` doesn't have a problem with so that's probably fine the way it is. – Beartech Dec 01 '20 at 00:57