Search code examples
ruby-on-railsassociations

Trying to allow creation of a related object in Ruby on Rails form


I have a form containing a select and a text field (you can pick a MediaType or create one):

<div>
  <%= form.label :media_type_id, style: "display: block" %>
  <%= form.collection_select :media_type_id, MediaType.all, :id, :name, :prompt => "Select a Media Type" %>
  or create one:
  <%= form.text_field :new_media_type_name %>
</div>

The model code contains stuff to support the new_media_type_name:

class Medium < ApplicationRecord
  has_many :authorships
  has_many :authors, through: :authorships
  belongs_to :book_genre
  belongs_to :media_type
  attr_accessor :new_media_type_name
  before_save :create_media_type_from_name

  def create_media_type_from_name
    create_media_type(:name => new_media_type_name) unless new_media_type_name.blank?
  end  
end

This all works correctly in the console:

irb(main):001> m = Medium.new
=>
#<Medium:0x00007f9c867307d8
...
irb(main):002> m.create_media_type(:name => "Test")
  TRANSACTION (1.1ms)  BEGIN
  MediaType Create (2.1ms)  INSERT INTO "media_types" ("name", "created_at",     "updated_at") VALUES ($1, $2, $3) RETURNING "id"  [["name", "Test"], ["created_at", "2024-09-29 01:06:50.826184"], ["updated_at", "2024-09-29 01:06:50.826184"]]   
TRANSACTION (17.3ms)  COMMIT
=>
#<MediaType:0x00007f9c86cb6270
 id: 6,
 name: "Test",
 created_at: Sun, 29 Sep 2024 01:06:50.826184000 UTC +00:00,
 updated_at: Sun, 29 Sep 2024 01:06:50.826184000 UTC +00:00>

But running the app from the browser does not save the new MediaType.

This is what I see in the output running rails s:

Started POST "/media" for 192.168.1.142 at 2024-09-28 17:34:33 -0500
Processing by MediaController#create as HTML
  Parameters: {"authenticity_token"=>"[FILTERED]", "medium"=>{"title"=>"The New Test",     "country"=>"Canada", "published_on"=>"2024-09-05", "media_type_id"=>"", "new_media_type_name"=>"Spaceship", "book_genre_id"=>"3", "summary"=>"The summary", "cover_art_path"=>"", "isbn"=>""}, "commit"=>"Create Medium"}
  TRANSACTION (0.8ms)  BEGIN
  ↳ app/controllers/media_controller.rb:26:in `create'
  BookGenre Load (1.2ms)  SELECT "book_genres".* FROM "book_genres"    WHERE "book_genres"."id" = $1 LIMIT $2  [["id", 3], ["LIMIT", 1]]
  ↳ app/controllers/media_controller.rb:26:in `create'
  TRANSACTION (0.9ms)  ROLLBACK
  ↳ app/controllers/media_controller.rb:26:in `create'
  Rendering layout layouts/application.html.erb
  Rendering media/new.html.erb within layouts/application
  MediaType Load (1.3ms)  SELECT "media_types".* FROM "media_types"
  ↳ app/views/media/_form.html.erb:35
  BookGenre Load (1.3ms)  SELECT "book_genres".* FROM "book_genres"   ORDER BY "book_genres"."name" ASC
  ↳ app/views/media/_form.html.erb:42:in `map'
  Rendered media/_form.html.erb (Duration: 48.9ms | Allocations: 8316)
  Rendered media/new.html.erb within layouts/application (Duration: 50.5ms | Allocations: 8853)
  Rendered layout layouts/application.html.erb (Duration: 85.5ms |   Allocations: 20627)
Completed 422 Unprocessable Entity in 181ms (Views: 85.5ms | ActiveRecord: 31.2ms | Allocations: 44624)

The problem is, the save fails and the error displayed in the browser is always "Media type must exist". I think this means it cannot create the Medium unless the media_type attached to it already exists. But I thought that was the purpose of the before_save block in the model.

Please, I beg you rails gods and godesses, what am I missing? Ryan? Ryan Bates, are you there??

Update: I discovered if I select a media_type in the form while entering a new_media_type_name at the same time, it creates the new media_type and sets the new media_type as the media_type reference in the new Medium object.


Solution

  • To create nested records in Rails you use accepts_nested_attributes to create a setter in the model:

    class Medium < ApplicationRecord
      # ... 
      belongs_to :media_type
      accepts_nested_attributes_for :media_type,
                                    reject_if: :all_blank
    end
    

    To create the inputs in the form you use fields_for:

    <div>
      <div class="field">
        <%= form.label :media_type_id, style: "display: block" %>
        <%= form.collection_select :media_type_id, MediaType.all, :id, :name, prompt: "Select a Media Type" %>
      </div>
      <fieldset>
        <legend>Or create one</legend>
        <%= form.fields_for(:media_type) do |media_type_form| %>
          <div class="field">
            <%= media_type_form.label :name %>
            <%= media_type_form.text_field :name %>
          </div>
        <% end %>
      </fieldset>
    </div>
    

    For the fields to appear you need to "seed" the assocation with @media.build_media_type in the controller.

    You then whitelist the nested attributes in your controller by passing a hash as the last argument (the method predates real keyword args):

    def medium_attributes
      params.require(:medium)
            .permit(
              :foo,
              :bar, 
              :baz,
              media_type_attributes: [:name]
            )
    end
    

    The other option to handle this if the other record can be created separately is to use AJAX to send a request to create the other record ansyncronously but that's really a topic for another day.