Search code examples
ruby-on-railsaccepts-nested-attributes

Rails accepts_nested_attributes_for not working with has_many relationship and cocoon gem


I'm using the cocoon gem to build a form that creates a Tournament. A Tournament has_many Games. Cocoon lets me dynamically add more games to the form.

When I call @tournament.save, it generates the following errors:

Games team one must exist
Games team two must exist

tournament.rb

class Tournament < ApplicationRecord
  has_many :games

  accepts_nested_attributes_for :games, allow_destroy: true
end

game.rb

class Game < ApplicationRecord
  belongs_to :tournament, optional: false
  belongs_to :team_one, polymorphic: true
  belongs_to :team_two, polymorphic: true
  belongs_to :field, optional: true
end

schema.rb

ActiveRecord::Schema.define(version: 2019_12_24_011346) do
  ...
  create_table "club_teams", force: :cascade do |t|
    t.string "name"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

  create_table "fields", force: :cascade do |t|
    t.string "name"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

  create_table "games", force: :cascade do |t|
    t.bigint "tournament_id", null: false
    t.string "team_one_type", null: false
    t.bigint "team_one_id", null: false
    t.string "team_two_type", null: false
    t.bigint "team_two_id", null: false
    t.bigint "field_id", null: false
    t.date "date"
    t.datetime "start_time"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["field_id"], name: "index_games_on_field_id"
    t.index ["team_one_type", "team_one_id"], name: "index_games_on_team_one_type_and_team_one_id"
    t.index ["team_two_type", "team_two_id"], name: "index_games_on_team_two_type_and_team_two_id"
    t.index ["tournament_id"], name: "index_games_on_tournament_id"
  end

  create_table "high_school_teams", force: :cascade do |t|
    t.string "school_name"
    t.string "team_name"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

  create_table "tournaments", force: :cascade do |t|
    t.string "name"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

  add_foreign_key "games", "fields"
  add_foreign_key "games", "tournaments"
end

tournaments_controller.rb

class TournamentsController < ApplicationController
  ...
  def create
    @tournament = Tournament.new(tournament_params)

    if @tournament.save
      redirect_to @tournament
    else
      render 'new'
    end
  end

  private
    def tournament_params
      params
        .require(:tournament)
        .permit(:name, games_attributes: [:id, :_destroy, :team_one_id, :team_two_id, :field_id, :date, :start_time])
    end
end

request parameters in tournaments_controller#create

{
  "authenticity_token"=>"iB4JefT9jRdiOFKok38OtjzMwd6Dv3hlHP/QZRtlFgMuVZfbn9PFD7Lebc1DuvfL6/IatDpS5CiubTci5MsCFg==",
  "tournament"=>{
    "name"=>"foo",
    "games_attributes"=>{
      "1577935885397"=>{
        "team_one_id"=>"high-school-team-2",
        "team_two_id"=>"club-team-2",
        "date"=>"",
        "start_time"=>"",
        "_destroy"=>"false"
      }
    }
  },
  "commit"=>"Create Tournament"
}

tournament_params in tournaments_controller#create

<ActionController::Parameters {
  "name"=>"foo",
  "games_attributes"=><ActionController::Parameters {
    "1577937916236"=><ActionController::Parameters {
      "_destroy"=>"false",
      "team_one_id"=>"high-school-team-2",
      "team_two_id"=>"club-team-2",
      "date"=>"",
      "start_time"=>""
    } permitted: true>
  } permitted: true>
} permitted: true>

It seems to me that the tournament_params match what the accepts_nested_attributes documentation is expecting under One-to-many, so I don't see why there is an error.

Nested attributes for an associated collection can also be passed in the form of a hash of hashes instead of an array of hashes:

Member.create(
  name: 'joe',
  posts_attributes: {
    first:  { title: 'Foo' },
    second: { title: 'Bar' }
  }
)

has the same effect as

Member.create(
  name: 'joe',
  posts_attributes: [
    { title: 'Foo' },
    { title: 'Bar' }
  ]
)

Edit:

tournaments/new.html.erb

<h1>Create a tournament</h1>

<%= render 'form' %>

<%= link_to 'Back', tournaments_path %>

tournaments/_form.html.erb

<%= form_with model: @tournament, class: 'tournament-form' do |f| %>
  <p>
    <%= f.label :name %><br>
    <%= f.text_field :name %>
  </p>

  <section class="games">
    <%= f.fields_for :games do |game| %>
      <%= render 'game_fields', f: game %>
    <% end %>
    <hr>
    <p>
      <%= link_to_add_association "Add game", f, :games,
        data: {
          association_insertion_node: '.games',
          association_insertion_method: :prepend
        }
      %>
    </p>
  </section>

  <p>
    <%= f.submit %>
  </p>
<% end %>

tournaments/_game_fields.html.erb

<section class="nested-fields">
  <hr>
  <p><strong>Game</strong></p>
  <%= render "games/form_fields", f: f %>
  <p><%= link_to_remove_association "Remove game", f %></p>
</section>

games/_form_fields.html.erb

<section>
  <% if HighSchoolTeam.all.count + ClubTeam.all.count < 2 %>
    <p>You neeed at least two teams to create a game. Create more high school and/or club teams first.</p>
  <% else %>
    <section class="game-form">
      <p>
        <%= f.label :team_one %><br>
        <%= f.select :team_one_id, nil, {}, class: "team-one-dropdown" do %>
          <optgroup label="High School Teams">
            <% HighSchoolTeam.all.each do |high_school_team| %>
              <option value="high-school-team-<%= high_school_team.id %>"><%= high_school_team.school_name %></option>
            <% end %>
          </optgroup>
          <optgroup label="Club Teams">
            <% ClubTeam.all.each do |club_team| %>
              <option value="club-team-<%= club_team.id %>"><%= club_team.name %></option>
            <% end %>
          </optgroup>
        <% end %>
      </p>

      <p>
        <%= f.label :team_two %><br>
        <%= f.select :team_two_id, nil, {}, class: "team-two-dropdown" do %>
          <optgroup label="High School Teams">
            <% HighSchoolTeam.all.each do |high_school_team| %>
              <option value="high-school-team-<%= high_school_team.id %>"><%= high_school_team.school_name %></option>
            <% end %>
          </optgroup>
          <optgroup label="Club Teams">
            <% ClubTeam.all.each do |club_team| %>
              <option value="club-team-<%= club_team.id %>"><%= club_team.name %></option>
            <% end %>
          </optgroup>
        <% end %>
      </p>

      <p>
        <%= f.label :field %><br>
        <%= f.collection_select(:field_id, Field.all, :id, :name) %>
      </p>

      <p>
        <%= f.label :date %><br>
        <%= f.date_field :date %>
      </p>

      <p>
        <%= f.label :start_time %><br>
        <%= f.time_field :start_time %>
      </p>
    </section>
  <% end %>
</section>

Solution

  • You seem to have problem saving team and not an issue of Cocoon gem.

    Since you customize your select value to club-team-id and high-school-team-id. I think you just need to change it to something like this:

    <option value="HighSchoolTeam-<%= high_school_team.id %>"><%= high_school_team.school_name %></option>

    and

    <option value="ClubTeam-<%= club_team.id %>"><%= club_team.name %></option>

    Then the params will be

    {
      "authenticity_token"=>"iB4JefT9jRdiOFKok38OtjzMwd6Dv3hlHP/QZRtlFgMuVZfbn9PFD7Lebc1DuvfL6/IatDpS5CiubTci5MsCFg==",
      "tournament"=>{
        "name"=>"foo",
        "games_attributes"=>{
          "1577935885397"=>{
            "team_one_id"=>"HighSchoolTeam-2",
            "team_two_id"=>"ClubTeam-2",
            "date"=>"",
            "start_time"=>"",
            "_destroy"=>"false"
          }
        }
      },
      "commit"=>"Create Tournament"
    }
    

    then you need to modified your params by:

    # Adding before_action on top of your controller
    before_action :modify_params, only: [:create, :update]
    
    private
    # Not the cleanest way, but this is what I can think of right now.
    def modify_params
      params.dig(:tournament, :games_attributes).each do |game_id, game_attribute|
        team_one_type = game_attribute[:team_one_id].split('-').first
        team_one_id = game_attribute[:team_one_id].split('-').last
    
        team_two_type = game_attribute[:team_two_id].split('-').first
        team_two_id = game_attribute[:team_two_id].split('-').last
    
        params[:tournament][:games_attributes][game_id] = game_attribute.merge(
          team_one_type: team_one_type,
          team_one_id: team_one_id,
          team_two_type: team_two_type,
          team_two_id: team_two_id
        )
      end
    end
    
    # And update this method to allow team_one_type and team_two_type
    def tournament_params
      params.require(:tournament)
        .permit(:name, games_attributes: [:id, :_destroy, :team_one_id, :team_two_id, :team_one_type, :team_two_type, :field_id, :date, :start_time])
    end