A clean approach for managing many-to-many associations with search, add, and remove functionality using Turbo Frames in Rails. This updated version includes partial-based search results and bidirectional associations.
This pattern allows users to:
- Search for items to associate
- Add items to an association
- Remove items from an association
- All without page refreshes using Turbo Frames
- Works bidirectionally (A ↔ B associations)
class User < ApplicationRecord
has_many :user_roles, dependent: :destroy
has_many :roles, through: :user_roles
has_many :permissions, through: :roles
accepts_nested_attributes_for :user_roles, allow_destroy: true
endclass Role < ApplicationRecord
include Searchable
has_many :user_roles, dependent: :destroy
has_many :users, through: :user_roles
has_many :role_permissions, dependent: :destroy
has_many :permissions, through: :role_permissions
validates :name, presence: true
SEARCHABLE_COLUMNS = ["roles.name", "roles.description"].freeze
endclass UserRole < ApplicationRecord
self.table_name = "user_roles" # Use existing table if available
belongs_to :user
belongs_to :role
validates :user_id, uniqueness: { scope: :role_id }
endclass UsersController < ApplicationController
def new
@user.roles.build
build_roles_from_selected_role_ids(params)
build_role_search_results(params)
end
def edit
build_roles_from_selected_role_ids(params)
build_role_search_results(params)
end
def create
if @user.save
redirect_to @user, notice: "User was successfully created."
else
build_roles_from_selected_role_ids(params)
build_role_search_results(params)
render :new
end
end
def update
if @user.update(user_params)
redirect_to @user, notice: "User was successfully updated."
else
build_roles_from_selected_role_ids(params)
build_role_search_results(params)
render :edit
end
end
private
def user_params
params.require(:user).permit(
:name, :email,
user_roles_attributes: [:id, :role_id, :_destroy]
)
end
def build_role_search_results(params)
@role_results = []
if params[:role_search].present?
@role_results = Role.search(params[:role_search])
end
end
def build_roles_from_selected_role_ids(params)
if params.has_key?(:selected_role_ids)
@selected_role_ids = []
split_ids = params[:selected_role_ids].split(",")
# Keep in @selected_role_ids or mark for destruction if not in split_ids
@user.user_roles.each do |user_role|
if split_ids.include?(user_role.role_id.to_s)
split_ids.delete(user_role.role_id.to_s)
@selected_role_ids << user_role.role_id.to_s
else
user_role.mark_for_destruction
end
end
# Add new roles to @selected_role_ids
split_ids.each do |role_id|
role = Role.find_by(id: role_id)
if role && can?(:read, role)
@selected_role_ids << role_id.to_s
@user.user_roles.build(role_id: role_id)
end
end
else
@selected_role_ids = @user.user_roles.pluck(:role_id).map(&:to_s)
end
end
end<div data-controller="user-roles" class="max-w-2xl mx-auto p-4">
<%= form_with(model: @user, local: true, id: "user_form") do |form| %>
<div class="space-y-8">
<%= render "layouts/errors", object: @user %>
<div class="space-y-6">
<div class="form-group">
<%= form.label :name %>
<%= form.text_field :name, class: "form-control" %>
</div>
<div class="form-group">
<%= form.label :email %>
<%= form.email_field :email, class: "form-control" %>
</div>
<div class="form-group">
<%= turbo_frame_tag "user_roles_fields" do %>
<h3 class="flex flex-row items-center gap-3 mb-3">
<%= content_tag :label, "Associated Roles", class: "my-0" %>
<%= content_tag :button, "Search & add", type: "button", class: "btn btn-sm btn-outline", data: { action: "click->user-roles#openDialog" } %>
</h3>
<ul class="list-disc list-inside">
<%= form.fields_for :user_roles do |user_role_form| %>
<%= user_role_form.hidden_field :role_id %>
<%= user_role_form.hidden_field :_destroy %>
<% unless user_role_form.object.marked_for_destruction? %>
<li class="odd:bg-gray-100 p-3 flex justify-between gap-4">
<%= content_tag :span, user_role_form.object.role.name, class: "font-medium" %>
<% trimmed_ids = (@selected_role_ids - [user_role_form.object.role_id.to_s]).join(",") %>
<% remove_action = @user.new_record? ?
new_user_path(selected_role_ids: trimmed_ids) :
edit_user_path(@user, selected_role_ids: trimmed_ids) %>
<%= link_to "Remove", remove_action, class: "btn btn-sm btn-delete", data: { turbo_frame: "user_roles_fields" } %>
</li>
<% end %>
<% end %>
</ul>
<turbo-stream action="replace" target="role-results">
<template>
<%= render "role_results", user: @user %>
</template>
</turbo-stream>
<% end %>
</div>
</div>
<div class="flex justify-end">
<%= link_to "Cancel", users_path, class: "btn btn-secondary mr-2" %>
<%= button_tag "Save", type: "submit", form: "user_form", class: "btn btn-primary" %>
</div>
</div>
<% end %>
<dialog role="dialog" <%= "open" if params[:role_search].present? %> data-user-roles-target="dialog" closedby="any" aria-hidden="true">
<div class="z-50 modal__overlay" tabindex="-1">
<div class="modal__container max-w-2xl" role="dialog" aria-modal="true" aria-labelledby="role-dialog-title">
<header class="modal__header">
<h2 class="modal__title">Search roles</h2>
</header>
<main class="form-group modal__content">
<%= turbo_frame_tag "roles_search" do %>
<% form_action = @user.new_record? ? new_user_path : edit_user_path(@user) %>
<%= form_tag form_action, data: { turbo_frame: "roles_search" }, method: :get do %>
<div class="flex flex-row items-center justify-between gap-4 mb-6">
<%= text_field_tag "role_search", params[:role_search], placeholder: "Search roles...", class: "form-control search-input" %>
<%= button_tag "Search", type: "submit", class: "btn btn-primary" %>
</div>
<%= render "role_results", user: @user %>
<% end %>
<% end %>
</main>
<footer class="modal__footer justify-end gap-3">
<button class="btn btn-secondary" data-action="click->user-roles#closeDialog">Cancel</button>
</footer>
</div>
</div>
</dialog>
</div><!-- app/views/users/_role_results.html.erb -->
<ul id="role-results" class="search-results">
<% if @role_results.empty? && params[:role_search].present? %>
<p class="text-gray-500 italic">No results found for "<%= params[:role_search] %>"</p>
<% elsif @role_results.empty? %>
<p class="text-gray-500 italic">Results will appear here</p>
<% end %>
<% @role_results.each do |role| %>
<li class="odd:bg-gray-100 p-3 flex flex-row items-center justify-between gap-4">
<%= content_tag :span, role.name, class: "font-medium" %>
<% appended_ids = (@selected_role_ids + [role.id]).join(",") %>
<% add_action = @user.new_record? ?
new_user_path(selected_role_ids: appended_ids) :
edit_user_path(@user, selected_role_ids: appended_ids) %>
<%= link_to "Add", add_action, data: { turbo_frame: "user_roles_fields" }, id: "add-role-#{role.id}", class: "btn btn-sm btn-outline" %>
</li>
<% end %>
</ul>// app/javascript/controllers/user_roles_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["dialog"]
openDialog() {
this.dialogTarget.showModal()
}
closeDialog(event) {
const dialog = event.currentTarget.closest('dialog')
if (dialog) {
dialog.close()
// Clear search input and results using classes
const searchInput = dialog.querySelector('.search-input')
const searchResults = dialog.querySelector('.search-results')
if (searchInput) searchInput.value = ""
if (searchResults) searchResults.innerHTML = ""
}
}
}For bidirectional associations (A ↔ B), implement the pattern on both sides:
- User form has role search dialog
- User controller handles
selected_role_idsparameter - Uses
user_roles_attributesfor nested attributes
- Role form has user search dialog
- Role controller handles
selected_user_idsparameter - Uses
role_users_attributesfor nested attributes
- Uses
@selected_item_idsto track current associations - Maintains state across search/add/remove operations
- Handles both new and existing records
item_fieldsframe updates the association listitems_searchframe handles search results- No page refreshes required
- Real-time search with
Searchableconcern - Results appear in dedicated frame using partials
- Add buttons update the main association list
- Add: Appends item ID to selected list and builds association
- Remove: Removes item ID and marks for destruction
- Uses
_destroyflag for proper nested attributes handling
- Search results rendered in reusable partials
- Turbo-stream replace actions for clean updates
- Generic classes for JavaScript targeting
- Search and selection state preserved in URL parameters
- Back/forward navigation works correctly
- Bookmarkable search states
create_table "users" do |t|
t.string "name"
t.string "email"
t.timestamps
end
create_table "roles" do |t|
t.string "name"
t.text "description"
t.timestamps
end
create_table "user_roles" do |t|
t.bigint "user_id", null: false
t.bigint "role_id", null: false
t.timestamps
t.index ["user_id", "role_id"], name: "index_user_roles_on_user_id_and_role_id", unique: true
endIf you have an existing join table without an ID column:
class AddIdToUserRoles < ActiveRecord::Migration[8.0]
def up
# Add id column as primary key
add_column :user_roles, :id, :bigint, null: false, primary_key: true
# Add timestamps if they don't exist
unless column_exists?(:user_roles, :created_at)
add_timestamps :user_roles, null: false, default: -> { 'CURRENT_TIMESTAMP' }
end
# Update existing records with proper timestamps if they're null
execute <<-SQL
UPDATE user_roles
SET created_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE created_at IS NULL OR updated_at IS NULL
SQL
end
def down
# Remove timestamps
remove_timestamps :user_roles if column_exists?(:user_roles, :created_at)
# Remove id column
remove_column :user_roles, :id
end
end- No Page Refreshes: All operations use Turbo Frames
- Stateful: Search and selection state preserved
- Reusable: Pattern works for any many-to-many association
- Bidirectional: Works for both directions of associations
- Accessible: Uses proper form controls and ARIA attributes
- Progressive Enhancement: Works without JavaScript
- Clean URLs: State managed through query parameters
- Maintainable: Partial-based results for easy updates
- Generic: Shared JavaScript functions using CSS classes
- Replace
User,Role, andUserRolewith your actual models - Update the searchable columns in the
SEARCHABLE_COLUMNSconstant - Customize the styling classes to match your design system
- Add validation as needed for your specific use case
- Use separate search parameters for multiple search functions (e.g.,
role_search,user_search) - Ensure join tables have ID columns for
mark_for_destructionfunctionality