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.
Contents
- Background and Setup
- Initial User Search
- Chat New and Create
- New Chat Page
- Summoning the Users Index (The Hotwire magic starts here)
- Making it more magic (with Stimulus.js)
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? **
Initial User Search
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:
- adds a user to the
users_for_chat_list
set of IDs (using a set here as sets cannot contain duplicates). - creates a hidden form input element, which is what submits the user IDs to the create action and appends it.
- creates a visual element which shows the user which users are due to be added to the chat, and includes a data action so clicking on them will invoke the remove_action and appends it.
- it then calls the
style_selected_users
function. Which applies some visual styling to indicate which users are selected and due to be added to the chat.
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
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!