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 Player
s are saved, but no player-country associations are built.
Parameters:
{"game"=>{"players_attributes"=>{"0"=>{"name"=>"foo", "color"=>"#000000", "country_ids"=>"226"}}},
"commit"=>"Submit"}
CodePudding user response:
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" 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" 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" 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.