Filterable Index with Slide-Out Detail Drawer | LlamaPress Cookbook
LlamaPress
Cookbook
Tables Stable

Filterable Index with Slide-Out Detail Drawer

A Retool-style admin index — a condensed table showing only key columns, a collapsible left-hand filter/search panel, and a right-hand slide-out drawer that loads the full show/edit views for a row via one Turbo Frame, no page reloads.

Proven on leo-tozeb.leo.llamapress.ai view · stimulus_js · controller
Live demo — try it Open full screen

Filterable Index with Slide-Out Detail Drawer

⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.

The classic "Retool-style" admin screen, built with plain Rails + Hotwire. The index page shows a condensed table — only the 4–6 columns a human scans, not every model column. A thin strip on the left slides open a filter panel (search box, date range, multi-select status/association filters) that submits as a normal GET form, so every filtered view is a shareable URL. Clicking a row slides a drawer in from the right and loads that record's full show view — every column — inside a Turbo Frame. The pencil icon (or the Edit button inside the drawer) swaps the same drawer to the edit form. Create/update/validation round-trips all happen inside the drawer; the page underneath never reloads.

The trick that makes it cheap: the drawer is one global shell in the layout with one turbo_frame_tag "record_drawer" inside. Your show.html.erb and edit.html.erb are otherwise-normal views that render their content inside a matching frame tag. Turbo does all the fetching and swapping; ~90 lines of vanilla JS handle open/close/escape/backdrop. Adding the pattern to a second or third resource costs only the index markup — the drawer, its JS, and the partials are shared.

When to use: any admin/back-office index where rows have more fields than fit in a table — CRM records, orders, signals, jobs. The moment you're tempted to cram a 12th column in, reach for this instead. When not to: if rows are flat and every field fits in the table, inline cell editing is faster (see inline-editable-table). If the "detail" is a whole workspace (tabs, sub-lists), give it a real page instead of a drawer.

The example uses a Contact model with name / email / company / phone / website / status / notes columns. The table shows only name, company, status, created — the drawer shows everything. Swap in your own model — see How to adapt at the bottom.


The 80/20 in one breath

  1. Put a global drawer shell (backdrop + fixed right panel + an empty turbo_frame_tag "record_drawer") in layouts/application.html.erb, with ~90 lines of vanilla JS: open the drawer whenever that frame receives content, close on ✕ / backdrop / Escape, and navigate the frame when a <tr data-drawer-url> is clicked.
  2. Make show.html.erb and edit.html.erb render their content through a shared shared/_drawer_content partial that wraps everything in the same turbo_frame_tag "record_drawer".
  3. On the index, give each <tr> a data-drawer-url and point the row's name link and pencil link at the frame with data: { turbo_frame: "record_drawer" }.
  4. Wrap the whole index page in ONE form_with method: :get — the left filter panel's inputs are just fields of that form. A small Stimulus controller slides the panel open/closed from a thin trigger strip.
  5. In the controller's index, permit the filter params (arrays included), chain .where scopes conditionally, and paginate.
  6. Scaffold-standard create/update already work: redirects are followed inside the frame, so a successful save swaps the drawer to the fresh show view, and validation errors re-render the form in place. Only destroy needs to break out to _top.
  7. Make the sortable column headers links that set a sort param — the SAME param a "Sort by" dropdown in the filter panel uses, so there is one source of truth and the two controls can never disagree (see Layer 3.5).

Layer 1 — The layout: one global drawer shell + the JS

Add this once, near the end of <body>. Every resource reuses it.

<%# app/views/layouts/application.html.erb — just before </body> %>

<%# ── Slide-out detail drawer (right side) ─────────────────────────── %>
<div id="record-drawer-backdrop"
     class="fixed inset-0 bg-gray-900/50 backdrop-blur-sm z-40 opacity-0 pointer-events-none transition-opacity duration-200"
     aria-hidden="true"></div>

<aside id="record-drawer"
       class="fixed top-0 right-0 h-full w-full max-w-2xl bg-white shadow-2xl z-50 translate-x-full transition-transform duration-300 flex flex-col"
       aria-hidden="true" role="dialog" aria-label="Details panel">
  <div class="flex items-center justify-between px-5 h-12 border-b border-gray-200 bg-white shrink-0">
    <div id="record-drawer-title" class="text-sm font-medium text-gray-500">Details</div>
    <button type="button" id="record-drawer-close"
            class="text-gray-500 hover:text-gray-800 px-2 py-1" aria-label="Close panel">
      <i class="fas fa-times"></i>
    </button>
  </div>
  <div class="flex-1 overflow-y-auto">
    <%= turbo_frame_tag "record_drawer", class: "block" do %>
      <%# Row / pencil clicks load show & edit views in here %>
    <% end %>
  </div>
</aside>

<style>
  #record-drawer.is-open          { transform: translateX(0); }
  #record-drawer-backdrop.is-open { opacity: 1; pointer-events: auto; }
  body.record-drawer-open         { overflow: hidden; }
</style>

<script>
  (function() {
    'use strict';
    const drawer   = document.getElementById('record-drawer');
    const backdrop = document.getElementById('record-drawer-backdrop');
    const closeBtn = document.getElementById('record-drawer-close');
    const titleEl  = document.getElementById('record-drawer-title');
    if (!drawer || !backdrop) return;

    function openDrawer(title) {
      drawer.classList.add('is-open');
      backdrop.classList.add('is-open');
      document.body.classList.add('record-drawer-open');
      drawer.setAttribute('aria-hidden', 'false');
      if (title && titleEl) titleEl.textContent = title;
    }

    function closeDrawer() {
      drawer.classList.remove('is-open');
      backdrop.classList.remove('is-open');
      document.body.classList.remove('record-drawer-open');
      drawer.setAttribute('aria-hidden', 'true');
      if (titleEl) titleEl.textContent = 'Details';
      // Reset the frame so re-opening the SAME record refetches it.
      // BOTH lines matter — see Gotchas.
      const frame = drawer.querySelector('turbo-frame#record_drawer');
      if (frame) { frame.removeAttribute('src'); frame.innerHTML = ''; }
    }

    backdrop.addEventListener('click', closeDrawer);
    if (closeBtn) closeBtn.addEventListener('click', closeDrawer);
    document.addEventListener('keydown', function(e) {
      if (e.key === 'Escape' && drawer.classList.contains('is-open')) closeDrawer();
    });

    // Open the drawer whenever the frame receives content (NOT on click —
    // a slow fetch would flash an empty panel).
    document.addEventListener('turbo:frame-load', function(e) {
      if (!e.target || e.target.id !== 'record_drawer') return;
      if (e.target.innerHTML.trim()) {
        const t = e.target.querySelector('[data-drawer-title]');
        openDrawer(t ? t.textContent.trim() : null);
      }
    });

    // Row clicks: navigate the drawer frame to the row's URL.
    document.addEventListener('click', function(e) {
      const row = e.target.closest('tr[data-drawer-url]');
      if (!row) return;
      // Don't hijack clicks on real interactive elements inside the row.
      if (e.target.closest('a, button, input, select, textarea, [data-no-drawer]')) return;
      e.preventDefault();
      const frame = document.querySelector('turbo-frame#record_drawer');
      if (frame) frame.src = row.dataset.drawerUrl;
    });

    window.RecordDrawer = { open: openDrawer, close: closeDrawer };
  })();
</script>

Layer 2 — Shared partials

The frame wrapper every show/edit renders through:

<%# app/views/shared/_drawer_content.html.erb %>
<%# Drawer frame wrapper. Locals: title, subtitle (optional). Yields body. %>
<%= turbo_frame_tag "record_drawer" do %>
  <div class="p-6 space-y-6">
    <div class="pb-4 border-b border-gray-200">
      <div class="text-[11px] font-semibold text-gray-400 uppercase tracking-wider" data-drawer-title><%= title %></div>
      <% if local_assigns[:subtitle].present? %>
        <div class="mt-1 text-lg font-semibold text-gray-800 leading-snug"><%= subtitle %></div>
      <% end %>
    </div>
    <div class="drawer-body space-y-4 text-sm text-gray-700">
      <%= yield %>
    </div>
  </div>
<% end %>

<style>
  .drawer-body .field { margin-bottom: 0.75rem; }
  .drawer-body label {
    display: block; font-size: 0.75rem; font-weight: 600; color: #6B7280;
    text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem;
  }
  .drawer-body input[type="text"], .drawer-body input[type="email"],
  .drawer-body input[type="url"], .drawer-body input[type="number"],
  .drawer-body input[type="date"], .drawer-body textarea, .drawer-body select {
    width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #E5E7EB;
    border-radius: 0.375rem; background: white; font-size: 0.875rem; color: #1F2937;
  }
  .drawer-body input:focus, .drawer-body textarea:focus, .drawer-body select:focus {
    outline: none; border-color: #6366F1; box-shadow: 0 0 0 3px rgba(99,102,241,0.15);
  }
</style>

A reusable multi-select for the filter panel:

<%# app/views/shared/_filter_select.html.erb %>
<%# Locals: label, name (end it in [] for multi), options ([display, value] pairs), selected %>
<div>
  <label class="block text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-1.5">
    <%= label %>
  </label>
  <select name="<%= name %>" multiple size="1"
          class="w-full rounded-md border border-gray-300 bg-white text-sm text-gray-700 px-2 py-1.5">
    <% options.each do |display, value| %>
      <option value="<%= value %>" <%= "selected" if Array(selected).map(&:to_s).include?(value.to_s) %>>
        <%= display %>
      </option>
    <% end %>
  </select>
</div>

Layer 3 — The index view

One GET form wraps the entire screen: trigger strip, filter panel, and table. Submitting any filter is a plain page navigation with clean URL params.

<%# app/views/contacts/index.html.erb %>
<% content_for :title, "Contacts" %>

<%
  status_options    = Contact.statuses.keys.map { |s| [s.humanize, s] }
  page_size_options = [25, 50, 100, 200, 500]
  filter_keys       = [:q, :start_date, :end_date, :statuses]
  filters_active    = filter_keys.any? { |k| @filters[k].present? }
%>

<%= form_with url: contacts_path, method: :get, local: true, data: { turbo_frame: "_top" } do |f| %>
  <div class="flex min-h-[calc(100vh-4rem)] bg-gray-100" data-controller="filter-drawer">

    <!-- Thin trigger strip — always visible -->
    <div data-action="click->filter-drawer#toggle"
         class="shrink-0 w-10 flex flex-col items-center pt-5 gap-5 bg-white border-r border-gray-200 cursor-pointer hover:bg-gray-50 z-10">
      <i data-filter-drawer-target="filterIcon"
         class="fas fa-filter text-sm <%= filters_active ? 'text-indigo-600' : 'text-gray-400' %>"
         title="Toggle filters"></i>
      <i data-filter-drawer-target="closeIcon"
         class="fas fa-times hidden text-red-500 text-lg leading-none" title="Close filters"></i>
      <i class="fas fa-search text-sm text-gray-400" title="Search"></i>
    </div>

    <!-- Filter panel — collapsed by default, slides open -->
    <aside data-filter-drawer-target="panel"
           class="shrink-0 bg-white border-r border-gray-200 flex flex-col gap-4 text-sm overflow-hidden transition-all duration-200 ease-out w-0 px-0 opacity-0">
      <div class="w-64 shrink-0 px-4 py-5 flex flex-col gap-4 min-h-full">

        <div>
          <label class="block text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-1.5">Search</label>
          <%= f.search_field :q, value: @filters[:q], placeholder: "Name, email, company…",
                class: "w-full rounded-md border border-gray-300 bg-white text-sm px-2 py-1.5" %>
        </div>

        <div>
          <label class="block text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-1.5">Date Created</label>
          <div class="flex items-center gap-1.5">
            <%= f.date_field :start_date, value: @filters[:start_date], class: "w-1/2 rounded-md border border-gray-300 bg-white text-sm px-2 py-1.5" %>
            <span class="text-gray-400 text-xs">–</span>
            <%= f.date_field :end_date, value: @filters[:end_date], class: "w-1/2 rounded-md border border-gray-300 bg-white text-sm px-2 py-1.5" %>
          </div>
        </div>

        <%= render "shared/filter_select", label: "Status", name: "statuses[]",
              options: status_options, selected: @filters[:statuses] %>

        <div>
          <label class="block text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-1.5">Page Size</label>
          <%= f.select :page_size, page_size_options.map { |n| [n.to_s, n] },
                { selected: @filters[:page_size] },
                class: "w-full rounded-md border border-gray-300 bg-white text-sm px-2 py-1.5" %>
        </div>

        <div class="mt-2 flex items-center gap-2">
          <%= f.submit "Apply Filters", class: "flex-1 rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-3 py-1.5 cursor-pointer" %>
          <%= link_to "Reset", contacts_path, class: "text-sm text-gray-500 hover:text-gray-700 px-2" %>
        </div>

        <div class="mt-auto pt-4 text-[11px] text-gray-400 text-right">
          <%= @total_count %> total
        </div>
      </div>
    </aside>

    <main class="flex-1 flex flex-col min-w-0">
      <div class="flex items-center justify-end gap-3 px-6 py-3 bg-white border-b border-gray-200">
        <%= link_to new_contact_path, class: "rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-3 py-1.5 inline-flex items-center gap-2",
              data: { turbo_frame: "record_drawer" } do %>
          <i class="fas fa-plus text-xs"></i>New Contact
        <% end %>
      </div>

      <div class="flex-1 overflow-auto bg-white">
        <table class="w-full text-sm">
          <thead class="sticky top-0 bg-gray-50 border-b border-gray-200 text-gray-500 text-[11px] uppercase tracking-wider">
            <tr>
              <th class="text-left font-semibold px-4 py-2.5 min-w-[240px]">Name</th>
              <th class="text-left font-semibold px-4 py-2.5">Company</th>
              <th class="text-left font-semibold px-4 py-2.5">Status</th>
              <th class="text-left font-semibold px-4 py-2.5 whitespace-nowrap">Created at</th>
              <th class="w-12 px-2 py-2.5 text-right">Edit</th>
            </tr>
          </thead>
          <tbody class="divide-y divide-gray-100">
            <% @contacts.each do |contact| %>
              <tr class="hover:bg-gray-50 cursor-pointer" data-drawer-url="<%= contact_path(contact) %>">
                <td class="px-4 py-2 text-gray-700 max-w-md">
                  <%= link_to contact.name.to_s.truncate(80), contact_path(contact),
                        class: "hover:text-indigo-600",
                        data: { turbo_frame: "record_drawer" } %>
                </td>
                <td class="px-4 py-2 text-gray-600 whitespace-nowrap"><%= contact.company.presence || "—" %></td>
                <td class="px-4 py-2">
                  <%
                    badge_class = case contact.status
                      when "active"   then "bg-emerald-50 text-emerald-700 border-emerald-200"
                      when "inactive" then "bg-amber-50 text-amber-700 border-amber-200"
                      else                 "bg-gray-100 text-gray-500 border-gray-200"
                    end
                  %>
                  <span class="inline-flex items-center px-2 py-0.5 rounded border text-[11px] font-medium <%= badge_class %>">
                    <%= contact.status.humanize %>
                  </span>
                </td>
                <td class="px-4 py-2 text-gray-500 whitespace-nowrap"><%= contact.created_at.strftime("%b %-d, %Y") %></td>
                <td class="px-2 py-2 text-right whitespace-nowrap">
                  <%= link_to edit_contact_path(contact),
                        class: "inline-flex items-center justify-center w-7 h-7 rounded text-gray-400 hover:text-indigo-600 hover:bg-gray-100",
                        title: "Edit", data: { turbo_frame: "record_drawer" } do %>
                    <i class="fas fa-pen text-xs"></i>
                  <% end %>
                </td>
              </tr>
            <% end %>

            <% if @contacts.empty? %>
              <tr>
                <td colspan="5" class="text-center py-16">
                  <div class="flex flex-col items-center gap-2 text-gray-400">
                    <i class="fas fa-address-book text-3xl"></i>
                    <span class="text-sm">No contacts match these filters</span>
                  </div>
                </td>
              </tr>
            <% end %>
          </tbody>
        </table>
      </div>
    </main>
  </div>
<% end %>

Layer 3.5 — Sortable column headers (server-side, one param with the dropdown)

Clickable headers are where people expect sorting to live; a "Sort by" dropdown in the filter panel is still useful for sorts that have no column. Ship both, sharing ONE sort param whose whitelist lives next to the filter code — never two sorting code paths.

# app/controllers/contacts_controller.rb (or a query object)
SORTS = {
  "newest"  => "Newest first",
  "oldest"  => "Oldest first",
  "name"    => "Name A–Z",
  "status"  => "Status"
}.freeze

def sort
  SORTS.key?(params[:sort].to_s) ? params[:sort].to_s : "newest"
end

# in index, after the filters:
scope = case sort
        when "oldest" then scope.reorder(created_at: :asc)
        when "name"   then scope.reorder(Arel.sql("LOWER(name) ASC"))
        when "status" then scope.reorder(status: :asc, created_at: :desc)
        else               scope.reorder(created_at: :desc)
        end

Each header is a link that swaps sort while keeping every filter param (and dropping page, so a re-sort lands on page 1). Give each sort key a FIXED direction and let the primary date column toggle between its two keys — simpler and more predictable than tri-state asc/desc/none on every column:

<%# app/views/contacts/index.html.erb — replace the static <th> labels %>
<%
  sort_qp = request.query_parameters.except("page")
  sort_header = lambda do |label, key, toggle_with: nil|
    active = params_sort == key || (toggle_with && params_sort == toggle_with)
    target = (params_sort == key && toggle_with) ? toggle_with : key
    arrow  = if !active then "fa-sort text-gray-300"
             elsif params_sort == "oldest" then "fa-arrow-up-long text-indigo-600"
             else "fa-arrow-down-long text-indigo-600"
             end
    link_to contacts_path(sort_qp.merge(sort: target)),
            class: "inline-flex items-center gap-1 hover:text-gray-700 #{'text-indigo-700' if active}" do
      safe_join([label, tag.i(class: "fas #{arrow} text-[9px]")], " ")
    end
  end
%>
<th class="text-left font-semibold px-4 py-2.5"><%= sort_header.call("Name", "name") %></th>
<th class="text-left font-semibold px-4 py-2.5"><%= sort_header.call("Status", "status") %></th>
<th class="text-left font-semibold px-4 py-2.5"><%= sort_header.call("Created", "newest", toggle_with: "oldest") %></th>

(params_sort above = the whitelisted sort reader from the controller/query object, exposed however you like — an @sort ivar works.)

Sorting by a value that is NOT a database column (a score from a cache file, an external API figure): you cannot ORDER BY it. If the ranked set is small, rank those ids explicitly with array_position and leave everyone else in default order after them — proven with a ~50-customer lifetime-revenue cache over an 11k-row table:

# ids already sorted by the external value, highest first; integers only —
# never interpolate user input into this array.
ranked_ids = ltv_cache.sort_by { |_, cents| -cents }.map { |id, _| id.to_i }
scope.reorder(Arel.sql(
  "array_position(ARRAY[#{ranked_ids.join(',')}]::bigint[], contacts.id::bigint) ASC NULLS LAST, contacts.created_at DESC"
))

Layer 4 — Stimulus: the left filter panel

Width-transition open/close. Collapsed is w-0 px-0 opacity-0; the inner w-64 div keeps the content from reflowing during the animation.

// app/javascript/controllers/filter_drawer_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["panel", "filterIcon", "closeIcon"]

  connect() { this.close() }

  toggle() {
    this.panelTarget.classList.contains("w-0") ? this.open() : this.close()
  }

  open() {
    this.panelTarget.classList.remove("w-0", "px-0", "opacity-0")
    this.panelTarget.classList.add("w-64", "px-4", "opacity-100")
    this.panelTarget.style.pointerEvents = ""
    if (this.hasCloseIconTarget)  this.closeIconTarget.classList.remove("hidden")
    if (this.hasFilterIconTarget) this.filterIconTarget.classList.add("hidden")
  }

  close() {
    this.panelTarget.classList.add("w-0", "px-0", "opacity-0")
    this.panelTarget.classList.remove("w-64", "px-4", "opacity-100")
    this.panelTarget.style.pointerEvents = "none"
    if (this.hasCloseIconTarget)  this.closeIconTarget.classList.add("hidden")
    if (this.hasFilterIconTarget) this.filterIconTarget.classList.remove("hidden")
  }
}

Layer 5 — Controller: permit the filters, chain the scopes

# app/controllers/contacts_controller.rb
class ContactsController < ApplicationController
  before_action :set_contact, only: %i[ show edit update destroy ]

  def index
    @filters = index_filter_params

    scope = Contact.order(created_at: :desc)

    if @filters[:q].present?
      q = "%#{Contact.sanitize_sql_like(@filters[:q])}%"
      scope = scope.where("name ILIKE :q OR email ILIKE :q OR company ILIKE :q", q: q)
    end

    scope = scope.where("created_at >= ?", parse_date(@filters[:start_date]))            if @filters[:start_date].present?
    scope = scope.where("created_at <= ?", parse_date(@filters[:end_date])&.end_of_day)  if @filters[:end_date].present?
    scope = scope.where(status: @filters[:statuses])                                     if @filters[:statuses].present?

    per_page = @filters[:page_size].to_i.clamp(1, 500)
    @pagy, @contacts = pagy(scope, limit: per_page)   # no pagy? see How to adapt

    @total_count = Contact.count
  end

  def show; end
  def edit; end

  def new
    @contact = Contact.new
  end

  # Scaffold-standard create/update work UNCHANGED with the drawer:
  # the redirect is followed INSIDE the frame → drawer swaps to the fresh show view;
  # validation errors re-render the form inside the drawer.
  def create
    @contact = Contact.new(contact_params)
    if @contact.save
      redirect_to @contact, notice: "Contact created."
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    if @contact.update(contact_params)
      redirect_to @contact, notice: "Contact updated.", status: :see_other
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @contact.destroy!
    redirect_to contacts_path, notice: "Contact deleted.", status: :see_other
  end

  private

  def set_contact
    @contact = Contact.find(params[:id])
  end

  def contact_params
    params.require(:contact).permit(:name, :email, :company, :phone, :website, :status, :notes)
  end

  def index_filter_params
    permitted = params.permit(:q, :start_date, :end_date, :page_size, statuses: []).to_h.symbolize_keys
    permitted[:page_size] = 100 if permitted[:page_size].blank?
    permitted
  end

  # Bad date strings from the URL must not 500 the page.
  def parse_date(value)
    Date.parse(value.to_s)
  rescue ArgumentError, TypeError
    nil
  end
end

Layer 6 — show & edit: normal views that land in the drawer

The drawer shows all the columns, even though the table showed four.

<%# app/views/contacts/show.html.erb %>
<%= render "shared/drawer_content", title: "Contact", subtitle: @contact.name do %>
  <% { "Name"    => @contact.name,    "Email"   => @contact.email,
       "Company" => @contact.company, "Phone"   => @contact.phone,
       "Website" => @contact.website, "Status"  => @contact.status.humanize,
       "Notes"   => @contact.notes,
       "Created" => @contact.created_at.strftime("%b %-d, %Y %H:%M") }.each do |label, value| %>
    <div>
      <strong class="block text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-0.5"><%= label %></strong>
      <span class="text-gray-800"><%= value.presence || "—" %></span>
    </div>
  <% end %>

  <div class="pt-4 mt-6 border-t border-gray-100 flex flex-wrap items-center gap-2">
    <%= link_to edit_contact_path(@contact),
          class: "rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-3 py-1.5 inline-flex items-center gap-1",
          data: { turbo_frame: "record_drawer" } do %>
      <i class="fas fa-pen text-xs"></i>Edit
    <% end %>
    <%= button_to @contact, method: :delete,
          class: "rounded-md bg-rose-500 hover:bg-rose-600 text-white text-sm px-3 py-1.5 inline-flex items-center gap-1",
          form: { data: { turbo_frame: "_top", turbo_confirm: "Delete this contact?" }, class: "inline" } do %>
      <i class="fas fa-trash text-xs"></i>Delete
    <% end %>
  </div>
<% end %>
<%# app/views/contacts/edit.html.erb %>
<%= render "shared/drawer_content", title: "Editing Contact", subtitle: @contact.name do %>
  <%= render "form", contact: @contact %>
  <div class="pt-4 mt-2 border-t border-gray-100">
    <%= link_to "Cancel", contact_path(@contact), class: "text-sm text-gray-500 hover:text-gray-700",
          data: { turbo_frame: "record_drawer" } %>
  </div>
<% end %>

new.html.erb is the same shape as edit (title "New Contact", render the form). The _form partial is a scaffold-standard form_with model: contact — no frame attributes needed on it; a form inside a frame targets that frame by default.


Gotchas (the hard-won stuff)

  • Reset the frame's src on close, not just its content. Turbo won't re-navigate a frame whose src already equals the clicked URL. If close only clears innerHTML, clicking the SAME row again silently does nothing and the drawer never reopens. The close handler must do both: frame.removeAttribute('src'); frame.innerHTML = ''.
  • Open the drawer on turbo:frame-load, not on click. Opening on click flashes an empty white panel while the fetch is in flight. Waiting for the frame to load means the drawer slides in already populated. (Corollary: if the drawer "doesn't open", check the browser console — a failed frame fetch means no turbo:frame-load fires.)
  • The row-click handler must ignore interactive descendants. Without the e.target.closest('a, button, input, select, textarea, [data-no-drawer]') guard, the pencil link fires AND the row handler fires — double navigation. [data-no-drawer] is the escape hatch for any future in-row widget.
  • Scaffold redirects are followed INSIDE the frame — usually what you want. After a successful update, redirect_to @contact swaps the drawer to the fresh show view. Trade-off: the table row behind the drawer still shows stale values until the next page navigation. If that matters, break out instead: submit the form with data: { turbo_frame: "_top" } to reload the whole filtered index after save.
  • destroy MUST break out to _top. A delete that responds inside the frame leaves the dead record's row sitting in the table. button_to ... form: { data: { turbo_frame: "_top" } } makes the redirect reload the index so the row disappears.
  • Wrap the index in ONE GET form and stamp it data: { turbo_frame: "_top" }. Everything in the filter panel is a plain field of that form, so "Apply Filters" is a normal navigation with clean, shareable URL params — and the _top stamp guarantees a filter submit never gets captured by the drawer frame.
  • Permit array params explicitly. params.permit(:q, statuses: []) — forget the statuses: [] declaration and multi-selects silently arrive empty.
  • Guard date parsing. A hand-edited URL like ?start_date=garbage will 500 the index through Date.parse. Always rescue to nil (the parse_date helper above).
  • Direct visits to /contacts/1 still work, but look bare. The frame renders inline in the main content area (the drawer shell stays closed). Fine for an admin tool; if it bothers you, have show redirect HTML requests without a Turbo-Frame header to the index.
  • Sticky header needs the right scroll container. sticky top-0 on <thead> only works because the overflow-auto div is the table's nearest scroll ancestor. Put overflow-x-auto on a different wrapper and the header scrolls away. Corollary for a table that scrolls WITH THE PAGE (no fixed-height layout): an overflow-x-auto wrapper — which wide tables need — becomes the scroll ancestor and kills vertical sticking against body scroll. The fix is to stop body-scrolling the table: cap the card (max-h-[calc(100vh-<header px>px)]), make the table wrapper flex-1 overflow-auto, and add z-10 (plus the bg-gray-50) on the thead so row content doesn't paint over it. Pagination stays OUTSIDE the scrolling card.
  • Sort headers and a sort dropdown must share one param + one whitelist. Two sort code paths (header links with their own order param, dropdown with another) is how a page silently shows one order while claiming another. One whitelisted sort key, one case in the controller; the header link builder and the dropdown both read it. Build header links from request.query_parameters.except("page") so re-sorting keeps every filter and returns to page 1.
  • Flash inside a frame response is invisible. A notice: on the redirect renders in the layout, which frame responses strip — so nothing shows. Either live without toasts for drawer actions, or embed the flash inside the frame content in a hidden [data-drawer-flash] div and have the layout JS move it to a fixed toast host on turbo:frame-load (the source project does exactly this).
  • Font Awesome isn't guaranteed on every box. The icons here (fa-filter, fa-times, fa-search, fa-pen, fa-plus, fa-trash) need Font Awesome in the layout. No FA? Use inline SVGs or text glyphs (✕, ✎) — the pattern doesn't care.
  • Collapse the panel with w-0 px-0 opacity-0, keep a fixed-width inner div. Animating the outer width while an inner w-64 div holds the content is what makes the slide smooth — animate the padding/width of the content itself and it reflows mid-animation.

Files this pattern touches

app/views/layouts/application.html.erb          # drawer shell + CSS + JS (once, global)
app/views/shared/_drawer_content.html.erb       # frame wrapper for show/edit/new
app/views/shared/_filter_select.html.erb        # reusable multi-select filter
app/views/contacts/index.html.erb               # trigger strip + filter panel + table
app/views/contacts/show.html.erb                # all columns, in the drawer
app/views/contacts/edit.html.erb                # form, in the drawer
app/views/contacts/new.html.erb                 # form, in the drawer
app/javascript/controllers/filter_drawer_controller.js
app/controllers/contacts_controller.rb          # filter params + scopes

How to adapt to your schema

  1. Swap the model. Replace Contact and its routes. Pick the 4–6 columns humans scan for the table; everything else lives only in the drawer's show/edit views.
  2. Pick your filters. Text search + date range + one or two multi-selects covers most admin tables. For an association filter (e.g. by category), render another shared/filter_select with options: Category.order(:name).pluck(:name, :id) and add category_ids: [] to the permitted params + a where(category_id: ...) scope.
  3. No pagy? Replace the pagy line with @contacts = scope.limit(per_page).offset(([params[:page].to_i, 1].max - 1) * per_page) and simple prev/next links that carry the filter params. Keep the 500 cap — an unbounded index on a big table can eat a small box's RAM.
  4. Second resource? Reuse the layout drawer, both shared partials, and the Stimulus controller as-is. You only write the new index markup and the show/edit bodies.
  5. Drawer too narrow/wide? It's one class on the <aside>: max-w-2xl. Forms with many columns like max-w-3xl; a read-mostly log viewer is fine at max-w-xl.
  6. Want the filter panel open by default? Change connect() to call this.open() when any filter param is present (pass them via a Stimulus value), so a shared filtered URL shows its filters.

Related