Skip to content

Instantly share code, notes, and snippets.

@digitalWestie
Last active September 16, 2025 14:22
Show Gist options
  • Select an option

  • Save digitalWestie/0cdaebd25d8e67a1083638843ed1f5b7 to your computer and use it in GitHub Desktop.

Select an option

Save digitalWestie/0cdaebd25d8e67a1083638843ed1f5b7 to your computer and use it in GitHub Desktop.
Turbo frame association pattern

Rails Turbo Frame Association Pattern (Updated)

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.

Overview

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)

Models

User Model

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
end

Role Model

class 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
end

Join Model

class 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 }
end

Controller

class 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

View Template

<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>

Search Results Partial

<!-- 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>

JavaScript Controller (Stimulus)

// 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 = ""
    }
  }
}

Bidirectional Associations

For bidirectional associations (A ↔ B), implement the pattern on both sides:

Side A (User ↔ Role)

  • User form has role search dialog
  • User controller handles selected_role_ids parameter
  • Uses user_roles_attributes for nested attributes

Side B (Role ↔ User)

  • Role form has user search dialog
  • Role controller handles selected_user_ids parameter
  • Uses role_users_attributes for nested attributes

Key Features

1. State Management

  • Uses @selected_item_ids to track current associations
  • Maintains state across search/add/remove operations
  • Handles both new and existing records

2. Turbo Frame Updates

  • item_fields frame updates the association list
  • items_search frame handles search results
  • No page refreshes required

3. Search Functionality

  • Real-time search with Searchable concern
  • Results appear in dedicated frame using partials
  • Add buttons update the main association list

4. Add/Remove Operations

  • Add: Appends item ID to selected list and builds association
  • Remove: Removes item ID and marks for destruction
  • Uses _destroy flag for proper nested attributes handling

5. Partial-Based Results

  • Search results rendered in reusable partials
  • Turbo-stream replace actions for clean updates
  • Generic classes for JavaScript targeting

6. URL State Management

  • Search and selection state preserved in URL parameters
  • Back/forward navigation works correctly
  • Bookmarkable search states

Database Schema

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
end

Migration for Existing Tables

If 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

Benefits

  1. No Page Refreshes: All operations use Turbo Frames
  2. Stateful: Search and selection state preserved
  3. Reusable: Pattern works for any many-to-many association
  4. Bidirectional: Works for both directions of associations
  5. Accessible: Uses proper form controls and ARIA attributes
  6. Progressive Enhancement: Works without JavaScript
  7. Clean URLs: State managed through query parameters
  8. Maintainable: Partial-based results for easy updates
  9. Generic: Shared JavaScript functions using CSS classes

Usage Notes

  • Replace User, Role, and UserRole with your actual models
  • Update the searchable columns in the SEARCHABLE_COLUMNS constant
  • 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_destruction functionality
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment