1

I have the models Game, Player and Country. I'm working on a form with nested fields for Player which should create the Player with it's associated Countries.

The array of country_ids are sent through a nested_player.hidden_field :country_ids as an array of values.

game:

class Game < ApplicationRecord
  has_and_belongs_to_many :players
  accepts_nested_attributes_for :players
end

player:

class Player < ApplicationRecord
  has_and_belongs_to_many :games
  has_many :countries
end

country:

class Country < ApplicationRecord
  belongs_to :player, optional: true
end

game controller:

  def game_params
    params.require(:game).permit(:metadata, players_attributes: [:name, :color, country_ids: []])
  end

form:

<%= simple_form_for @game do |f| %>

  <%= f.fields_for :players do |player| %>
    <%= player.input :name %>
    <%= player.input :color, as: :color %>
    <%= player.hidden_field :country_ids, value: ["226"] %>
  <% end %>

  <%= f.submit "Submit", class: "btn btn-primary" %>
<% end %>

Problem:

The controller is receiving the country_ids as expected. The Game and nested Players are saved, but no player-country associations are built.

Parameters:

{"game"=>{"players_attributes"=>{"0"=>{"name"=>"foo", "color"=>"#000000", "country_ids"=>"226"}}},
 "commit"=>"Submit"}
pinkfloyd90
  • 588
  • 3
  • 17
  • This won't work since the country belongs to a player and thus the player must be created first. I think you're missing a join model here between players and countries. – max Apr 19 '22 at 01:54
  • @pinkfloyd90 just to add to my answer. does it not show in the log that `country_ids` are not permitted? otherwise it would actually associate country to user. – Alex Apr 19 '22 at 08:29

1 Answers1

1

The way you have it set up at the moment will reassign Country#player_id each time you create a new game with a new player, that last player id will be in Country#player_id; Right now it is one country -> one player.

To fix it, add another join table between Country and Player.

# db/migrate/20220419040615_create_pink_floyd90_game.rb

class CreatePinkFloyd90Game < ActiveRecord::Migration[7.0]
  def change
    create_table :countries do |t|
      t.string :name
    end

    create_table :games do |t|
      t.string :name
    end

    create_table :players do |t|
      t.string :name 
    end

    create_join_table :countries, :players # fixed
    create_join_table :games, :players
  end
end
# app/models/*.rb

class Country < ApplicationRecord
  has_and_belongs_to_many :players
end

class Player < ApplicationRecord
  has_and_belongs_to_many :games
  has_and_belongs_to_many :countries
end

class Game < ApplicationRecord
  has_and_belongs_to_many :players
  accepts_nested_attributes_for :players
end

Before setting up a form, it's better to test the associations if they are not obvious:

>> Country.create!([{name: 'Country 1'}, {name: 'Country 2'}])

>> Game.create!(name: 'Game 1', players_attributes: {one: {name: 'Player 1', country_ids: [1,2]}})

# NOTE: notice the actual records that are created, and make sure this is the intention

# Load the referenced by ids countries (for validation, I think)
  Country Load (0.7ms)  SELECT "countries".* FROM "countries" WHERE "countries"."id" IN ($1, $2)  [["id", 1], ["id", 2]]

  TRANSACTION (0.3ms)  BEGIN

# Create a game
  Game Create (0.7ms)  INSERT INTO "games" ("name") VALUES ($1) RETURNING "id"  [["name", "Game 1"]]

# Create a player
  Player Create (0.7ms)  INSERT INTO "players" ("name") VALUES ($1) RETURNING "id"  [["name", "Player 1"]]

# Associate 'Country 1' with 'Player 1'
  Player::HABTM_Countries Create (0.5ms)  INSERT INTO "countries_players" ("country_id", "player_id") VALUES ($1, $2)  [["country_id", 1], ["player_id", 1]]

# Associate 'Country 2' with 'Player 1'
  Player::HABTM_Countries Create (0.3ms)  INSERT INTO "countries_players" ("country_id", "player_id") VALUES ($1, $2)  [["country_id", 2], ["player_id", 1]]

# Associate 'Game 1' with 'Player 1'
  Game::HABTM_Players Create (0.5ms)  INSERT INTO "games_players" ("game_id", "player_id") VALUES ($1, $2)  [["game_id", 1], ["player_id", 1]]

  TRANSACTION (2.8ms)  COMMIT
=> #<Game:0x00007f3ca4a82540 id: 1, name: "Game 1">

>> Game.first.players.pluck(:name)
=> ["Player 1"]                                                    
>> Player.first.countries.pluck(:name)                                                
=> ["Country 1", "Country 2"]      

Now I know this is working and anything unexpected will be in the controller or the form. Which is where the second issue is and the reason player-country did not associate.

{
  "game"=>{
    "players_attributes"=>{
      "0"=>{
        "name"=>"foo",
        "color"=>"#000000",
        "country_ids"=>"226" # doesn't look like an array
      }
    }
  },
  "commit"=>"Submit"
}

Because country_ids is not an array and it is not a hash that rails recognizes as array { "0"=>{}, "1"=>{} } permitted parameters do not allow it through.

Game.create(game_params) # <= never receives `country_ids`

Easy to check in rails console

https://api.rubyonrails.org/classes/ActionController/Parameters.html

# this is a regular attribute, not an array
>> params = {"country_ids"=>"1"}
>> ActionController::Parameters.new(params).permit(country_ids: []).to_h
=> {} # not allowed

# how about a nested hash
>> params = {"country_ids"=>{"0"=>{"id"=>"1"}}}
>> ActionController::Parameters.new(params).permit(country_ids: [:id]).to_h
=> {"country_ids"=>{"0"=>{"id"=>"1"}}} # allowed, but not usable without modifications

# how about an array
>> params = {"country_ids"=>["1","2"]}
>> ActionController::Parameters.new(params).permit(country_ids: []).to_h
=> {"country_ids"=>["1", "2"]} # TADA!

How to make a form submit an array.

Submitting an actual array is a bit of a hassle. The trick is to make the input name attribute end with []. For country_ids, input should look like this

<input value="1" type="text" name="game[players_attributes][0][country_ids][]">
<input value="2" type="text" name="game[players_attributes][0][country_ids][]">
# this will submit these parameters
# {"game"=>{"players_attributes"=>{"0"=>{"country_ids"=>["1", "2"]}}}

Form builders seem to not like that setup, so we have to do some shenanigans, especially for this nested setup:

form_for

<% game.players.build %>

<%= form_with model: game do |f| %>
  <%= f.fields_for :players do |ff| %>

    <%# using plain tag helper  %>
    <%# NOTE: `ff.object_name` returns "game[players_attributes][0]" %>
    <%= text_field_tag "#{ff.object_name}[country_ids][]", 1 %> <%# <input type="text" name="game[players_attributes][0][country_ids][]" id="game_players_attributes_0_country_ids_" value="1"> %>
    <%= text_field_tag "#{ff.object_name}[country_ids][]", 2 %> <%# <input type="text" name="game[players_attributes][0][country_ids][]" id="game_players_attributes_0_country_ids_" value="2"> %>

    <%# using `fields_for` helper  %>
    <%= ff.fields_for :country_ids do |fff| %>
      <%# NOTE: empty string '' gives us [] %>
      <%= fff.text_field '', value: 1 %> <%# <input value="1" type="text" name="game[players_attributes][0][country_ids][]" id="game_players_attributes_0_country_ids_"> %>
      <%= fff.text_field '', value: 2 %> <%# <input value="2" type="text" name="game[players_attributes][0][country_ids][]" id="game_players_attributes_0_country_ids_"> %>
    <% end %>

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

simple_form same thing

<% game.players.build %>

<%= simple_form_for game do |f| %>
  <%= f.simple_fields_for :players do |ff| %>

    <%= ff.simple_fields_for :country_ids do |fff| %>
      <%= fff.input '', input_html: { value: 1 } %> <%# <input value="1" class="string required" type="text" name="game[players_attributes][0][country_ids][]" id="game_players_attributes_0_country_ids_"> %>
      <%= fff.input '', input_html: { value: 2 } %> <%# <input value="2" class="string required" type="text" name="game[players_attributes][0][country_ids][]" id="game_players_attributes_0_country_ids_"> %>
    <% end %>

  <% end %>
<% end %>

Or forget all that because it's complicated and have country_ids as a plain string and split it in the controller

<%= simple_form_for game do |f| %>
  <%= f.simple_fields_for :players do |ff| %>
    <%= ff.input :country_ids, input_html: { value: [1,2] } %> <%# <input value="1 2" class="string optional" type="text" name="game[players_attributes][0][country_ids]" id="game_players_attributes_0_country_ids"> %>
  <% end %>
  <%= f.submit %>
<% end %>
def game_params
  # modify only once
  @game_params ||= modify_params(
    params.require(:game).permit(players_attributes: [:name, :country_ids])
  )
end

def modify_params permitted
  # NOTE: take "1 2" and split into ["1", "2"]
  permitted[:players_attributes].each_value{|p| p[:country_ids] = p[:country_ids].split }
  permitted
end

def create
  Game.create(game_params)
end

Hope this isn't too confusing.

Alex
  • 16,409
  • 6
  • 40
  • 56
  • 1
    That seems to have done the trick. Thank you for the detailed walkthrough, really appreciated it! Just one correction: The input for the `country_ids` should not be inside a `player.simple_fields_for :country_ids` block but rather as a `player.text_field :country_ids` input, otherwise you'd end up with `country_ids: {country_ids: [..]}` . Figured it out when the controller broke modifying the params. BTW, I did not know passing an array was so complicated and required modifications after. Seemed so natural to me that I discarded this option right away when I read it suggested somewhere else. – pinkfloyd90 Apr 19 '22 at 22:48
  • And if you don't mind, may I ask if this logic would also work if I added, say a Round model/join table to store the state of the player and it's owned countries in each round? Would that make sense or should it be handled differently? Thanks again for your time. – pinkfloyd90 Apr 19 '22 at 22:50
  • I've updated the answer with the generated inputs that i'm getting. I'm not seeing `country_ids` double nesting; as long as you've figured it out for your setup. you can add any model the same way, if it makes sense logically. `Round` model would be one-to-many relationship; `Game` `has_many :rounds`, create a new round on every update, serialize `player.as_json(include: :countries)` save into `json` column or similar. – Alex Apr 20 '22 at 00:55