Search and Select Multiple with Rails and Hotwire

This post walks though a user selector for adding users to a chat. The key feature here is searching one model with the pg_search gem and then selecting objects from that search to add them to another model.That sounds a bit confusing, but what we're doing is adding users to a group chat. I'm including some additional bits that added complexity (and coolness) to the setup and solution.

The secondary purpose of this guide is as a memory aid for me! We're using a couple of additional gems for this feature, pg_search for the search and pagy for pagination and of course Hotwire to provide the dynamic interaction magic

This is taken from the Ryalto V4. At the time of writing we are using; Rails (7.0.4.3), Turbo-Rails (1.4.0), Stiumulus-Rails (1.2.1), pg_search (2.3.6), pagy (5.10.1)

Here's an example of what we're building.

gif of the feature in action

Contents

Background and Setup

Chat has many Users through the Chat::Participant model. I'm including a bit of detail about our migrations and models for additional context.

Migrations

class DeviseCreateUsers < ActiveRecord::Migration[7.0]
  def change
    enable_extension 'pgcrypto'
    create_table :users, id: :uuid do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""
      ## There's some other bits there but they're not relevant ##
      ## User Information
      t.string :first_name, null: false
      t.string :last_name, null: false
    end
    add_index :users, :email, unique: true
    add_index :users, :first_name
    add_index :users, :last_name
  end
end


class CreateChats < ActiveRecord::Migration[7.0]
  def change
    create_table :chats, id: :uuid do |t|
      t.belongs_to :organisation, null: false, foreign_key: true, index: true, type: :uuid
      t.string :title, index: true
      t.boolean :group_chat, default: false

      t.timestamps
    end
    add_index :chats, :created_at

  end
end

class CreateChatParticipants < ActiveRecord::Migration[7.0]
  def change
    create_table :chat_participants, id: :uuid do |t|
      t.belongs_to :user, null: false, foreign_key: true, index: true, type: :uuid
      t.belongs_to :chat, null: false, foreign_key: true, index: true, type: :uuid

      t.boolean :creator, default: false
      t.boolean :admin, index: true, default: false
      t.integer :unread_messages_count, default: 0
      t.datetime :last_seen_at, index: true
      t.string :color

      t.timestamps
    end

    add_index :chat_participants, %i[user_id chat_id], unique: true

  end
end

Our ChatParticipants table not only accounts for the users that are in a chat, but also holds extra details about their relationship with that chat.

You might have also spotted that the Chat belongs to an Organisation. This alludes to the bigger picture of our app: a User can be part of many Organisations and we store those relationships in a Memberships table. A User has one "current organisation" at any given time and everything a user does needs to be scoped to that organisation. A user can also be active or inactive in an organisation. We don't want to include inactive users in this search. This is important because we need to maintain that scoping when we are searching for Users.

Models

# app/models/user.rb
# User Model
class User < ApplicationRecord
  include PgSearch::Model

  has_many :memberships, dependent: :destroy
  has_many :organisations, through: :memberships
  has_many :chat_participants, class_name: 'Chat::Participant', dependent: :nullify
  has_many :chats, through: :chat_participants

  scope :for_organisation, ->(organisation) { joins(:memberships).merge(organisation.memberships.active) }
  pg_search_scope :search_by_name, against: %i[first_name last_name], using: { tsearch: { prefix: true } }
end

# app/models/chat.rb
class Chat < ApplicationRecord
  belongs_to :organisation
  has_many :participants, dependent: :destroy
  accepts_nested_attributes_for :participants, allow_destroy: true
  has_many :users, through: :participants
  validates :participants, length: { is: 2 }, unless: :group_chat
  validates :participants, length: { minimum: 3 }, if: :group_chat

  # Validate that the chat is not a duplicate
  validate :individual_chat_is_not_duplicate, on: :create
end

# app/models/chat/participant.rb
# Chat::Participant Model
# This is the model which connects users to chats.
class Chat::Participant < ApplicationRecord
  belongs_to :user
  belongs_to :chat

  validates :user, uniqueness: { scope: :chat }
end

You can see the relationships more clearly here. When creating a chat we must also create the participants.

We've also included the PgSearch::Model in our User model. This is the pg_search gem we're using to power our search. It's lightweight and the strong documentation makes it pretty procedural to setup.

We have a :for_organisation scope on the User model. This allows us to get all the users who have an "active" membership in the organisation which is passed to the scope. Chris Oliver has a good explanation of this on GoRails

** Is there anything else that should be explained here? **

By including the pg_search gem in our user model, we can include a pg_search_scope which allows us to create a search scope. We're only searching against the user's name fields, which are specified in the against: option. The using: section allows us to search for non-exact matches with the :prefix option, details are here.

Now we have that setup in our model, we want to set up a route and controller action for our searchable users index.

# app/controllers/users_controller.rb
# Controller for Users Index and User Profiles
class UsersController < ApplicationController
  # GET /users
  def index
	organisation = current_user.current_organisation
	users = User.for_organisation(organisation)
	users = users.not_super_users unless organisation == ryalto_team_org
	users = users.search_by_name(params[:search]) if params[:search].present?
	params[:page_size] = 100 if params[:page_size]&.to_i&.> 100
	@pagy_users, @users = pagy(users, items: params[:page_size] || 10)
  end
end

This index action gradually scopes the local users variable and paginates it before rendering the index. We have a pretty custom line around super_users, which can probably be ignored if you're implementing this yourself. We allow the request to specific the page size, but only up to a page_size of 100.

The search and pagination here are made nice and simple by pg_search and pagy.

We use the default rails magic for rendering, so we'll render html, json or a turbo stream depending on the type of request.

Chat New and Create

Before we get into the real fun of the front end mix, I want to take a quick look at our ChatsController and specifically the new and create actions

# GET /chats/new
def new
    @chat = Chat.new
end

# POST /chats
def create
    @chat = current_user.current_organisation.chats.new(chat_params)
    @chat.users << current_user
    @chat.group_chat = true if @chat.users.size > 2
    @chat.title = nil unless @chat.group_chat?
    @chat.save ? success_actions : failure_response
end

Both of these are fairly stock rails controller actions. When we're creating a chat we always want the current user to be included. We also have some differences in behaviour and UI between group chats and "individual" chats.

The new action renders the new.html.erb view and this is where we start digging into some of the magic.

New Chat Page

Our new page itself is pretty uninteresting, it just loads the chat form which is shared with the edit action.

# app/views/chats/new.html.erb
<%= turbo_frame_tag "active_chat" do %>
  <div class="new-chat">

    <div class="form chat-form">
      <h1 class="form-title">New chat</h1>
      <%= render "form", chat: @chat %>
    </div>

  </div>
<% end %>

It does load into our active_chat turbo frame so we maintain the chat list in the UI.

Our form has a little more going on in it, I've excluded a few bits that aren't relevant to this.

# app/views/chats/_form.html.erb
<%= form_with(model: chat) do |form| %>
  <div id="chat_title_field" class="field hidden">
	<%= form.text_field :title, class: 'form-input', placeholder: 'Give your chat a name?' %>
	<%= form.label "Give your group chat a title?", class: 'form-label' %>
	<p class="field-hint">Your chat name can be edited later too</p>
  </div>

  <div data-controller="users-selector" data-users-selector-current-user-value="<%= current_user.id %>" class="users-selector">
    <%= turbo_frame_tag :users_selector_users_index, src: users_path(page_size: 12) do %>
      <h4 class="fa-beat-fade"> Loading... </h4>
    <% end %>

    <div id="selected_users_container" data-users-selector-target="selectedUsersContainer" >
      <hr>
      <h3>Selected Users</h3>
      <em class="small hint">You will also be included in the chat.</em>
      <div id="selected_users" data-users-selector-target="selectedUsers" class="users-list"></div>
    </div>

  </div>

  <div class="btn-wrapper"><%= form.submit "Create Chat", class: 'btn btn-submit btn-primary' %></div>
<% end %>

The important things here; we have the users-selector stimulus controller and we have a turbo frame which makes a request to our users path.

Summoning the Users Index

(The Hotwire magic starts here)

Now the real fun begins with the way we're calling the user's index to allow us to add users to a chat.

As this is a turbo frame request, when we hit the HTML index page, we only render the content within the matching turbo_frame_tag. This allows us to use the index action in different parts of the app and only render the turbo frames we need. Anything else in the app/views/users/index.html file outside of the users_selector_users_index turbo frame will not be loaded. The example here is the <h2> tag below.

# app/views/users/index.html
<h2>If you're seeing this, something's gone wrong.</h2>

<%= turbo_frame_tag :users_selector_users_index do %>
  <div>
    <h3>Select Users</h3>
    <%= form_with url: users_path, method: :get,
                  class: "search-wrapper" do %>
      <%= text_field_tag :search,
                         params[:search],
                         placeholder: "Search by Name",
                         class: "search-bar",
                         autocomplete: "off" %>
      <%= hidden_field_tag :page_size, params[:page_size], value: 12 %>
      <%#= submit_tag "Search" %>
    <% end %>

    <% if @users.present? %>
      <%= @pagy_users.count %> users found.
      <div data-controller="users-selector-users-index-page"
           data-action="users-selector-users-index-page:new-page@window->users-selector#style_selected_users"
           class="users-list index-list">
        <% @users.each do |user| %>
          <h4 data-action="click->users-selector#select_user"
              id="available_user_<%= user.id %>"
              value="<%= user.id %>"
              class="user-name">
            <%= user.full_name %>
          </h4>
        <% end %>
      </div>
      <div class="pagy-controls">
        <div class="control">
          <% if @pagy_users.prev %>
            <%= link_to "Previous", users_path(search: params[:search], page: @pagy_users.prev, page_size: 12) %>
          <% end %>
        </div>
        <div class="control-center">
          Page <%= @pagy_users.page %> of <%= @pagy_users.pages %>
        </div>
        <div class="control">
          <% if @pagy_users.next %>
            <%= link_to "Next", users_path(search: params[:search], page: @pagy_users.next, page_size: 12) %>
          <% end %>
        </div>
      </div>
    <% else %>
      <div class="no-users">
        <h3>No users found</h3>
        <p>Please try a different search</p>
      </div>
    <% end %>

  </div>
<% end %>

We can see here that we have a search form and pagination controls on this page, both of these make requests back to the `users_path`. As were not breaking out of the the turbo frame (with `target: "_top"`), the new content gets loaded within the same frame, depending on the params which are passed.

We connect to another (somewhat verbosely named) stimulus controller if @users is present, but we'll come back to that later.

Making it more magic

(with Stimulus.js)

On each user, we have a data-action call to the users-selector controller and the select-user action. The controller is initialised in the chat form and handles what happens when a user clicks on a users name. A users_for_chat_list set constant is created when the controller loads.

We get the user ID from the event target, and if that ID is already in the users_for_chat_list we remove that user by calling the remove_user function, if its not present we add the user via the inventively named add_user method.

The add and remove actions both do the same but opposite things.

The add_user function:

The remove_user action reverses all of the above.

The full controller is below.

# app/javascript/users_selector_controller.js
import {Controller} from "@hotwired/stimulus"

const users_for_chat_list = new Set()

export default class extends Controller {
    static targets = ["selectedUsers", "selectedUsersContainer"]

    select_user(event) {
        let user_id = event.target.id.replace(/\w+_user_/, "")
        let user_name = event.target.innerHTML
        users_for_chat_list.has(user_id) ? this.remove_user(user_id) : this.add_user(user_id, user_name)
    }

    add_user(user_id, user_name) {
        users_for_chat_list.add(user_id)
        let user_form_input = document.createElement("input")
        user_form_input.setAttribute("type", "hidden")
        user_form_input.setAttribute("name", "chat[user_ids][]")
        user_form_input.setAttribute("value", user_id)
        user_form_input.setAttribute("id", "chat_user_id_" + user_id)
        let selected_user = document.createElement("div")
        selected_user.setAttribute("class", "user-name selected")
        selected_user.setAttribute("id", "selected_user_" + user_id)
        selected_user.setAttribute("data-action", "click->users-selector#select_user")
        selected_user.innerHTML = user_name
        this.style_selected_users()
        this.selectedUsersTarget.appendChild(user_form_input)
        this.selectedUsersTarget.appendChild(selected_user)
        document.getElementById("available_user_" + user_id).classList.add("selected")
    }

    remove_user(user_id) {
        document.getElementById("chat_user_id_" + user_id).remove();
        document.getElementById("selected_user_" + user_id).remove();
        let user_button = document.getElementById("available_user_" + user_id)
        if (user_button) user_button.classList.remove("selected");
        this.style_selected_users()
    }

    style_selected_users() {
        if (users_for_chat_list.size == 0) {
            this.selectedUsersContainerTarget.classList.add("hidden")
        } else {
            this.selectedUsersContainerTarget.classList.remove("hidden")
            users_list.forEach(user_id => {
                let user_button = document.getElementById("available_user_" + user_id)
                if (user_button) user_button.classList.add("selected");
            })
        }
        let chat_title_field_class_list = document.getElementById("chat_title_field").classList
        users_for_chat_list.size > 1 ? chat_title_field_class_list.remove("hidden") : chat_title_field_class_list.add("hidden")
    }
}

The final piece was the most difficult to get working.

Initially when a user clicked on another user, everything worked as intended, until they changed pages in the users index or performed a search. When this happened the styling for selected users would be lost.

In order to get this working we connect to the rather verbosely named users-selector-users-index-page controller, whenever the users index page loads.

This controller does one thing, dispatch an action which calls the style_selected_users function on the users-selector controller.

# app/javascript/users_selector_users_index_page_controller.js
import {Controller} from "@hotwired/stimulus"

export default class extends Controller {
    connect() {
        this.dispatch("new-page")
    }
}

The key line here is the data action within the scope of this controller on the users index html page:

data-action="users-selector-users-index-page:new-page@window->users-selector#style_selected_users"

The format of this is dispatching controller : dispatched action/event name @ scope -> controller to call # action to invoke. Note: The stimulus docs are missing the scope requirement.

So we take the dispatched event and use that to trigger a call back to our style_selected_users action which highlights the users which have been selected on the index page which has just loaded

gif of the feature in action

And that's it!

It's a bit of a long post, but I hope it's useful to someone (and is useful to future me!)

Any questions, thoughts or suggests please get in touch!