Custom Collections in Rails' Forms

Posted by Christopher Kalfas on July 17, 2020

Hi everybody! This week I’m going to talk about customizing the collection_select input field in a form-for in Rails.

If you read my blog last week, you’ll notice that I often get annoyed with lousy documentation. The Rails Docs, in my experience, are very hit or miss. The explanation of collection_select is pretty rough.

Before we dive in, let’s set up our sandbox with some example models.

  • User

has_many :memberships

has_many :groups, through: :memberships

has_many :owned_groups, foreign_key: ‘owner_id’, class_name: ‘Group’

  • Group

has_many :memberships

has_many :users, through: :memberships

belongs_to :owner, class_name: ‘User’, foreign_key: :owner_id

  • Membership

belongs_to :user

belongs_to :group

NOTE Also, only group OWNERS can add/remove another member of the group. A new user can’t join an existing group.

Here is an example of a membership form_for with collection_select.

<%= form_for @membership do |f| %>
	 <%= f.collection_select :group_id, Group.all, :id, :name %> 
	 <%= f.collection_select :user_id, User.all, :id, :name %>
	 <%= f.submit %>
<% end %>

Let’s break this down.

This form will work but isn’t what we need. Anytime a group owner wants to add someone new, they will have access to add a member to ANY group, not just the ones they own. No good!

Also, the collection of users accesses all users, including the group owner and every current member of the group. That is really no good! So it looks like we need to customize the arguements of collection_select.

Let’s see what the Ruby docs say about how to acomplish this.

collection_select(object, method, collection, value_method, text_method, options = {}, html_options = {})

I mean, come on y’all. All I get from this is the collection argument is the one we need to change. But it doesn’t really explain how.

Looks like we are on our own.

The boilerplate gives us a varibale of collection and we need to assign it to an array of group instances. We can use the :owned_groups relationship-alias and the current_user method from our authentications(bcrypt, devise, etc…)

For a group owner to only have a collection of THEIR owned groups, I passed current_user.owned_group as the collection and left everything else the same.

<%= f.collection_select :group_id, current_user.owned_groups, :id, :name %>

This works great, but we can take it one step further. What if I only want the owner to add a member to the group they are currently associated with?

Rails makes that super easy to accomplish with associations. Instead of having a separate form for membership, I moved the logic into the groups form.

To get the controller working, we need to add one line of code to the edit method, and two code lines to the update method.

    def edit 
   	 @group = Group.find(params[:id])
   	 @users = User.all <--- new line
    end 

    def update 
        @group = Group.find(params[:id])
        @user = User.find(params["user"]["id"]) <--- new line
        @group.users << @user <--- new line 
        if @group.update(group_params)
            redirect_to group_path(@group)
        else
            render :edit
        end 
    end 

Now, when we update, we can use the user instance variable to grab the user’s id and push them into @group.users. No need to put this in the membership controller. And we don’t have to account for the group_id in our views.

Now, let’s talk about how to give collection_select a collection of users who are NOT in the group. That means we need to remove the users who are already in the group.

In the group model, we can write a custom instance method to return an array of users, NOT in this group.

def not_in_group
   User.select {|user| user.groups.pluck(:id).exclude?(self.id)}
end

Let’s break this down. If you aren’t familiar with pluck() now is your chance. pluck() is a select shortcut that can grab one or more instance attributes without the server loading a bunch of data you don’t need.

Just like before with @group.users, we can can user.groups. We can pass pluck() the :id attribute. This gives us an array of just the group_ids associated with users. Then, we can chain exclude?() to the end.

Remember that in Ruby, defining a method that ends in a ? means it returns a boolean. That means if the group instance does see another instance of itself(true) in user.groups, it can exclude that instance of user from the returned array. If the group instance doesn’t see its self(false) in user.groups it ** WILL** add that user to the selectable collection of users.

That was a lot of logic in one line of code. Ruby is the coolest.

Now we can just add that custom instance method as the collection argument in group form.

<%= collection_select(:user, :id, @group.not_in_group, :id, :name) %>

There are several other ways we could have accomplished this. Maybe some that are much easier to implement than what we just did? I have no idea because this stuff isn’t well documented. If you know of other ways to accomplish this, please shoot me a note on LinkedIn and impart your wisdom upon me.