How to use a select tag with multiple attribute for an has many association in Ruby on Rails
Last week a HasManyThrough
association gave me a headache so I’ve decided to write
down the solution so I can come back to it, but also share it so other people like me
find an answer faster, since it took me like an hour to duck it.
Context
So, in an application I have a many-to-many relationship which I decided to implement
using has_many(through:)
, and while tackling the form for a has_many association
is fairly straight forward (IIRC), I couldn’t for the life of me make the nested
attributes work out-of-the box with the form_with helper.
![Two people visibly confused.](
/assets/images/confused.png
)
For this example
Let’s picture a simple case: we want a blog where posts
can have several categories
and we want to manage categories separately for whatever reason.
$ rails g model Post title content:text
$ rails g model Category name
$ rails g model PostCategory post:references category:references
First attempt: accepts_nested_attributes_for + fields_for
Since I wanted to create a PostCategory
record, using accepts_nested_attributes_for
in conjunction with the fields_for helper made sense to me, since it was actually
dealing with a different model, but seeing the logs quickly made me realize that
I was doing something wrong:
Second attempt: yada yada
I tried different permutations of accepts_nested_attributes_for, didn’t change much.
Nth attempt: read the has_many docs
After feeling more than annoyed I eventually found an old post saying that has_many
associations add <model>_ids
& <model>_ids=
methods, so I had a look and what do
you know? I could skip the attributes joggling and simply add category_ids
to the
accepted parameters from the controller and voilà! It works:
NOTE: that in the parameters we accept a list.
PostsController < ApplicationController
# ...
def create
@post = Post.new(post_params)
if @post.save
redirect_to @post, notice: 'All good!'
else
render :new, alert: 'Something went wrong~'
end
end
# ...
private
def post_params
params.require(:post)
.permit(:title,
:content,
category_ids: [])
end
end
And in your view you can use a native <select multiple> or enhance it with something like select2:
# app/views/posts/_form.html.erb
# ...
<div class="field">
<%= f.label :category_ids, "Categories" %>
<%= f.select :category_ids,
options_from_collection_for_select(categories, :id, :name),
{ include_blank: false },
multiple: true,
class: 'select multiple-select',
size: 5
%>
</div>
# ...
Bonus: nested attributes using JS
If you’re using JSON then it’s easy, just use the correct keys and nest the entries in an array, if you’re using FormData the naming is a bit more cumbersome:
function json2formData(post) {
const data = new FormData
// ...
for (const cat of json.post_categories) {
// if this is a new record it won't affect
data.append(
'post[post_categories_attributes][][id]',
cat.id
)
data.append(
'post[post_categories_attributes][][category_id]',
cat.category_id
)
// this is for existing records only, though it doesn't affect if it's new
data.append(
'post[post_categories_attributes][][_destroy]',
cat._destroy === 1 ? 1 : 0
)
}
return data
}
Closing thoughts
As with most things in Rails, the solution is pretty and subtle. When you know it, it feels great; when you don’t, it may feel like a pain in the ass — but if it doesn’t feel right, keep looking!
Or implement your own solution, with blackjack and hookers.
"Confused?" by JoeBenjamin is licensed under CC BY-NC 2.0 .