{"slug":"filterable-index-with-detail-drawer","meta":{"title":"Filterable Index with Slide-Out Detail Drawer","slug":"filterable-index-with-detail-drawer","category":"Tables","summary":"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.","tags":["stimulus","turbo","turbo-frames","drawer","filters","search","tables","admin","ux-default"],"status":"stable","visibility":"public","source_project":"leo-tozeb.leo.llamapress.ai","layers":["view","stimulus_js","controller"],"related":[{"title":"High-Quality Inline-Editable Table","url":"/cookbook/inline-editable-table","summary":"The sibling pattern — edit flat scalar columns directly in the cells. Use THIS guide instead when a row has more fields than the table shows and needs a real multi-field form."},{"title":"Cmd+K Global Search","url":"/cookbook/global-search-command-palette","summary":"Cross-table search from anywhere. This guide's filter panel is per-table; the palette is app-wide. They compose well."}]},"body":"# Filterable Index with Slide-Out Detail Drawer\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nThe classic \"Retool-style\" admin screen, built with plain Rails + Hotwire. The index\npage shows a **condensed table** — only the 4–6 columns a human scans, not every model\ncolumn. A thin strip on the **left** slides open a **filter panel** (search box, date\nrange, multi-select status/association filters) that submits as a normal GET form, so\nevery filtered view is a shareable URL. Clicking a row slides a **drawer in from the\nright** and loads that record's **full `show` view — every column — inside a Turbo\nFrame**. The pencil icon (or the Edit button inside the drawer) swaps the same drawer to\nthe **`edit` form**. Create/update/validation round-trips all happen inside the drawer;\nthe page underneath never reloads.\n\nThe trick that makes it cheap: the drawer is **one global shell in the layout** with one\n`turbo_frame_tag \"record_drawer\"` inside. Your `show.html.erb` and `edit.html.erb` are\notherwise-normal views that render their content inside a matching frame tag. Turbo does\nall the fetching and swapping; ~90 lines of vanilla JS handle open/close/escape/backdrop.\nAdding the pattern to a second or third resource costs only the index markup — the\ndrawer, its JS, and the partials are shared.\n\n\u003e **When to use:** any admin/back-office index where rows have more fields than fit in a\n\u003e table — CRM records, orders, signals, jobs. The moment you're tempted to cram a 12th\n\u003e column in, reach for this instead. **When not to:** if rows are flat and every field\n\u003e fits in the table, inline cell editing is faster (see\n\u003e [inline-editable-table](/cookbook/inline-editable-table)). If the \"detail\" is a whole\n\u003e workspace (tabs, sub-lists), give it a real page instead of a drawer.\n\n\u003e The example uses a `Contact` model with\n\u003e `name / email / company / phone / website / status / notes` columns. The **table shows\n\u003e only** name, company, status, created — the **drawer shows everything**. Swap in your\n\u003e own model — see **How to adapt** at the bottom.\n\n---\n\n## The 80/20 in one breath\n\n1. Put a **global drawer shell** (backdrop + fixed right panel + an empty\n   `turbo_frame_tag \"record_drawer\"`) in `layouts/application.html.erb`, with ~90 lines\n   of vanilla JS: open the drawer whenever that frame receives content, close on\n   ✕ / backdrop / Escape, and navigate the frame when a `\u003ctr data-drawer-url\u003e` is clicked.\n2. Make `show.html.erb` and `edit.html.erb` render their content through a shared\n   `shared/_drawer_content` partial that wraps everything in the same\n   `turbo_frame_tag \"record_drawer\"`.\n3. On the index, give each `\u003ctr\u003e` a `data-drawer-url` and point the row's name link and\n   pencil link at the frame with `data: { turbo_frame: \"record_drawer\" }`.\n4. Wrap the whole index page in ONE `form_with method: :get` — the left filter panel's\n   inputs are just fields of that form. A small Stimulus controller slides the panel\n   open/closed from a thin trigger strip.\n5. In the controller's `index`, permit the filter params (arrays included), chain\n   `.where` scopes conditionally, and paginate.\n6. Scaffold-standard `create`/`update` already work: redirects are followed *inside*\n   the frame, so a successful save swaps the drawer to the fresh `show` view, and\n   validation errors re-render the form in place. Only `destroy` needs to break out to\n   `_top`.\n\n---\n\n## Layer 1 — The layout: one global drawer shell + the JS\n\nAdd this once, near the end of `\u003cbody\u003e`. Every resource reuses it.\n\n```erb\n\u003c%# app/views/layouts/application.html.erb — just before \u003c/body\u003e %\u003e\n\n\u003c%# ── Slide-out detail drawer (right side) ─────────────────────────── %\u003e\n\u003cdiv id=\"record-drawer-backdrop\"\n     class=\"fixed inset-0 bg-gray-900/50 backdrop-blur-sm z-40 opacity-0 pointer-events-none transition-opacity duration-200\"\n     aria-hidden=\"true\"\u003e\u003c/div\u003e\n\n\u003caside id=\"record-drawer\"\n       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\"\n       aria-hidden=\"true\" role=\"dialog\" aria-label=\"Details panel\"\u003e\n  \u003cdiv class=\"flex items-center justify-between px-5 h-12 border-b border-gray-200 bg-white shrink-0\"\u003e\n    \u003cdiv id=\"record-drawer-title\" class=\"text-sm font-medium text-gray-500\"\u003eDetails\u003c/div\u003e\n    \u003cbutton type=\"button\" id=\"record-drawer-close\"\n            class=\"text-gray-500 hover:text-gray-800 px-2 py-1\" aria-label=\"Close panel\"\u003e\n      \u003ci class=\"fas fa-times\"\u003e\u003c/i\u003e\n    \u003c/button\u003e\n  \u003c/div\u003e\n  \u003cdiv class=\"flex-1 overflow-y-auto\"\u003e\n    \u003c%= turbo_frame_tag \"record_drawer\", class: \"block\" do %\u003e\n      \u003c%# Row / pencil clicks load show \u0026 edit views in here %\u003e\n    \u003c% end %\u003e\n  \u003c/div\u003e\n\u003c/aside\u003e\n\n\u003cstyle\u003e\n  #record-drawer.is-open          { transform: translateX(0); }\n  #record-drawer-backdrop.is-open { opacity: 1; pointer-events: auto; }\n  body.record-drawer-open         { overflow: hidden; }\n\u003c/style\u003e\n\n\u003cscript\u003e\n  (function() {\n    'use strict';\n    const drawer   = document.getElementById('record-drawer');\n    const backdrop = document.getElementById('record-drawer-backdrop');\n    const closeBtn = document.getElementById('record-drawer-close');\n    const titleEl  = document.getElementById('record-drawer-title');\n    if (!drawer || !backdrop) return;\n\n    function openDrawer(title) {\n      drawer.classList.add('is-open');\n      backdrop.classList.add('is-open');\n      document.body.classList.add('record-drawer-open');\n      drawer.setAttribute('aria-hidden', 'false');\n      if (title \u0026\u0026 titleEl) titleEl.textContent = title;\n    }\n\n    function closeDrawer() {\n      drawer.classList.remove('is-open');\n      backdrop.classList.remove('is-open');\n      document.body.classList.remove('record-drawer-open');\n      drawer.setAttribute('aria-hidden', 'true');\n      if (titleEl) titleEl.textContent = 'Details';\n      // Reset the frame so re-opening the SAME record refetches it.\n      // BOTH lines matter — see Gotchas.\n      const frame = drawer.querySelector('turbo-frame#record_drawer');\n      if (frame) { frame.removeAttribute('src'); frame.innerHTML = ''; }\n    }\n\n    backdrop.addEventListener('click', closeDrawer);\n    if (closeBtn) closeBtn.addEventListener('click', closeDrawer);\n    document.addEventListener('keydown', function(e) {\n      if (e.key === 'Escape' \u0026\u0026 drawer.classList.contains('is-open')) closeDrawer();\n    });\n\n    // Open the drawer whenever the frame receives content (NOT on click —\n    // a slow fetch would flash an empty panel).\n    document.addEventListener('turbo:frame-load', function(e) {\n      if (!e.target || e.target.id !== 'record_drawer') return;\n      if (e.target.innerHTML.trim()) {\n        const t = e.target.querySelector('[data-drawer-title]');\n        openDrawer(t ? t.textContent.trim() : null);\n      }\n    });\n\n    // Row clicks: navigate the drawer frame to the row's URL.\n    document.addEventListener('click', function(e) {\n      const row = e.target.closest('tr[data-drawer-url]');\n      if (!row) return;\n      // Don't hijack clicks on real interactive elements inside the row.\n      if (e.target.closest('a, button, input, select, textarea, [data-no-drawer]')) return;\n      e.preventDefault();\n      const frame = document.querySelector('turbo-frame#record_drawer');\n      if (frame) frame.src = row.dataset.drawerUrl;\n    });\n\n    window.RecordDrawer = { open: openDrawer, close: closeDrawer };\n  })();\n\u003c/script\u003e\n```\n\n---\n\n## Layer 2 — Shared partials\n\nThe frame wrapper every `show`/`edit` renders through:\n\n```erb\n\u003c%# app/views/shared/_drawer_content.html.erb %\u003e\n\u003c%# Drawer frame wrapper. Locals: title, subtitle (optional). Yields body. %\u003e\n\u003c%= turbo_frame_tag \"record_drawer\" do %\u003e\n  \u003cdiv class=\"p-6 space-y-6\"\u003e\n    \u003cdiv class=\"pb-4 border-b border-gray-200\"\u003e\n      \u003cdiv class=\"text-[11px] font-semibold text-gray-400 uppercase tracking-wider\" data-drawer-title\u003e\u003c%= title %\u003e\u003c/div\u003e\n      \u003c% if local_assigns[:subtitle].present? %\u003e\n        \u003cdiv class=\"mt-1 text-lg font-semibold text-gray-800 leading-snug\"\u003e\u003c%= subtitle %\u003e\u003c/div\u003e\n      \u003c% end %\u003e\n    \u003c/div\u003e\n    \u003cdiv class=\"drawer-body space-y-4 text-sm text-gray-700\"\u003e\n      \u003c%= yield %\u003e\n    \u003c/div\u003e\n  \u003c/div\u003e\n\u003c% end %\u003e\n\n\u003cstyle\u003e\n  .drawer-body .field { margin-bottom: 0.75rem; }\n  .drawer-body label {\n    display: block; font-size: 0.75rem; font-weight: 600; color: #6B7280;\n    text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.25rem;\n  }\n  .drawer-body input[type=\"text\"], .drawer-body input[type=\"email\"],\n  .drawer-body input[type=\"url\"], .drawer-body input[type=\"number\"],\n  .drawer-body input[type=\"date\"], .drawer-body textarea, .drawer-body select {\n    width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #E5E7EB;\n    border-radius: 0.375rem; background: white; font-size: 0.875rem; color: #1F2937;\n  }\n  .drawer-body input:focus, .drawer-body textarea:focus, .drawer-body select:focus {\n    outline: none; border-color: #6366F1; box-shadow: 0 0 0 3px rgba(99,102,241,0.15);\n  }\n\u003c/style\u003e\n```\n\nA reusable multi-select for the filter panel:\n\n```erb\n\u003c%# app/views/shared/_filter_select.html.erb %\u003e\n\u003c%# Locals: label, name (end it in [] for multi), options ([display, value] pairs), selected %\u003e\n\u003cdiv\u003e\n  \u003clabel class=\"block text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-1.5\"\u003e\n    \u003c%= label %\u003e\n  \u003c/label\u003e\n  \u003cselect name=\"\u003c%= name %\u003e\" multiple size=\"1\"\n          class=\"w-full rounded-md border border-gray-300 bg-white text-sm text-gray-700 px-2 py-1.5\"\u003e\n    \u003c% options.each do |display, value| %\u003e\n      \u003coption value=\"\u003c%= value %\u003e\" \u003c%= \"selected\" if Array(selected).map(\u0026:to_s).include?(value.to_s) %\u003e\u003e\n        \u003c%= display %\u003e\n      \u003c/option\u003e\n    \u003c% end %\u003e\n  \u003c/select\u003e\n\u003c/div\u003e\n```\n\n---\n\n## Layer 3 — The index view\n\nOne GET form wraps the entire screen: trigger strip, filter panel, and table. Submitting\nany filter is a plain page navigation with clean URL params.\n\n```erb\n\u003c%# app/views/contacts/index.html.erb %\u003e\n\u003c% content_for :title, \"Contacts\" %\u003e\n\n\u003c%\n  status_options    = Contact.statuses.keys.map { |s| [s.humanize, s] }\n  page_size_options = [25, 50, 100, 200, 500]\n  filter_keys       = [:q, :start_date, :end_date, :statuses]\n  filters_active    = filter_keys.any? { |k| @filters[k].present? }\n%\u003e\n\n\u003c%= form_with url: contacts_path, method: :get, local: true, data: { turbo_frame: \"_top\" } do |f| %\u003e\n  \u003cdiv class=\"flex min-h-[calc(100vh-4rem)] bg-gray-100\" data-controller=\"filter-drawer\"\u003e\n\n    \u003c!-- Thin trigger strip — always visible --\u003e\n    \u003cdiv data-action=\"click-\u003efilter-drawer#toggle\"\n         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\"\u003e\n      \u003ci data-filter-drawer-target=\"filterIcon\"\n         class=\"fas fa-filter text-sm \u003c%= filters_active ? 'text-indigo-600' : 'text-gray-400' %\u003e\"\n         title=\"Toggle filters\"\u003e\u003c/i\u003e\n      \u003ci data-filter-drawer-target=\"closeIcon\"\n         class=\"fas fa-times hidden text-red-500 text-lg leading-none\" title=\"Close filters\"\u003e\u003c/i\u003e\n      \u003ci class=\"fas fa-search text-sm text-gray-400\" title=\"Search\"\u003e\u003c/i\u003e\n    \u003c/div\u003e\n\n    \u003c!-- Filter panel — collapsed by default, slides open --\u003e\n    \u003caside data-filter-drawer-target=\"panel\"\n           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\"\u003e\n      \u003cdiv class=\"w-64 shrink-0 px-4 py-5 flex flex-col gap-4 min-h-full\"\u003e\n\n        \u003cdiv\u003e\n          \u003clabel class=\"block text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-1.5\"\u003eSearch\u003c/label\u003e\n          \u003c%= f.search_field :q, value: @filters[:q], placeholder: \"Name, email, company…\",\n                class: \"w-full rounded-md border border-gray-300 bg-white text-sm px-2 py-1.5\" %\u003e\n        \u003c/div\u003e\n\n        \u003cdiv\u003e\n          \u003clabel class=\"block text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-1.5\"\u003eDate Created\u003c/label\u003e\n          \u003cdiv class=\"flex items-center gap-1.5\"\u003e\n            \u003c%= 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\" %\u003e\n            \u003cspan class=\"text-gray-400 text-xs\"\u003e–\u003c/span\u003e\n            \u003c%= 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\" %\u003e\n          \u003c/div\u003e\n        \u003c/div\u003e\n\n        \u003c%= render \"shared/filter_select\", label: \"Status\", name: \"statuses[]\",\n              options: status_options, selected: @filters[:statuses] %\u003e\n\n        \u003cdiv\u003e\n          \u003clabel class=\"block text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-1.5\"\u003ePage Size\u003c/label\u003e\n          \u003c%= f.select :page_size, page_size_options.map { |n| [n.to_s, n] },\n                { selected: @filters[:page_size] },\n                class: \"w-full rounded-md border border-gray-300 bg-white text-sm px-2 py-1.5\" %\u003e\n        \u003c/div\u003e\n\n        \u003cdiv class=\"mt-2 flex items-center gap-2\"\u003e\n          \u003c%= 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\" %\u003e\n          \u003c%= link_to \"Reset\", contacts_path, class: \"text-sm text-gray-500 hover:text-gray-700 px-2\" %\u003e\n        \u003c/div\u003e\n\n        \u003cdiv class=\"mt-auto pt-4 text-[11px] text-gray-400 text-right\"\u003e\n          \u003c%= @total_count %\u003e total\n        \u003c/div\u003e\n      \u003c/div\u003e\n    \u003c/aside\u003e\n\n    \u003cmain class=\"flex-1 flex flex-col min-w-0\"\u003e\n      \u003cdiv class=\"flex items-center justify-end gap-3 px-6 py-3 bg-white border-b border-gray-200\"\u003e\n        \u003c%= 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\",\n              data: { turbo_frame: \"record_drawer\" } do %\u003e\n          \u003ci class=\"fas fa-plus text-xs\"\u003e\u003c/i\u003eNew Contact\n        \u003c% end %\u003e\n      \u003c/div\u003e\n\n      \u003cdiv class=\"flex-1 overflow-auto bg-white\"\u003e\n        \u003ctable class=\"w-full text-sm\"\u003e\n          \u003cthead class=\"sticky top-0 bg-gray-50 border-b border-gray-200 text-gray-500 text-[11px] uppercase tracking-wider\"\u003e\n            \u003ctr\u003e\n              \u003cth class=\"text-left font-semibold px-4 py-2.5 min-w-[240px]\"\u003eName\u003c/th\u003e\n              \u003cth class=\"text-left font-semibold px-4 py-2.5\"\u003eCompany\u003c/th\u003e\n              \u003cth class=\"text-left font-semibold px-4 py-2.5\"\u003eStatus\u003c/th\u003e\n              \u003cth class=\"text-left font-semibold px-4 py-2.5 whitespace-nowrap\"\u003eCreated at\u003c/th\u003e\n              \u003cth class=\"w-12 px-2 py-2.5 text-right\"\u003eEdit\u003c/th\u003e\n            \u003c/tr\u003e\n          \u003c/thead\u003e\n          \u003ctbody class=\"divide-y divide-gray-100\"\u003e\n            \u003c% @contacts.each do |contact| %\u003e\n              \u003ctr class=\"hover:bg-gray-50 cursor-pointer\" data-drawer-url=\"\u003c%= contact_path(contact) %\u003e\"\u003e\n                \u003ctd class=\"px-4 py-2 text-gray-700 max-w-md\"\u003e\n                  \u003c%= link_to contact.name.to_s.truncate(80), contact_path(contact),\n                        class: \"hover:text-indigo-600\",\n                        data: { turbo_frame: \"record_drawer\" } %\u003e\n                \u003c/td\u003e\n                \u003ctd class=\"px-4 py-2 text-gray-600 whitespace-nowrap\"\u003e\u003c%= contact.company.presence || \"—\" %\u003e\u003c/td\u003e\n                \u003ctd class=\"px-4 py-2\"\u003e\n                  \u003c%\n                    badge_class = case contact.status\n                      when \"active\"   then \"bg-emerald-50 text-emerald-700 border-emerald-200\"\n                      when \"inactive\" then \"bg-amber-50 text-amber-700 border-amber-200\"\n                      else                 \"bg-gray-100 text-gray-500 border-gray-200\"\n                    end\n                  %\u003e\n                  \u003cspan class=\"inline-flex items-center px-2 py-0.5 rounded border text-[11px] font-medium \u003c%= badge_class %\u003e\"\u003e\n                    \u003c%= contact.status.humanize %\u003e\n                  \u003c/span\u003e\n                \u003c/td\u003e\n                \u003ctd class=\"px-4 py-2 text-gray-500 whitespace-nowrap\"\u003e\u003c%= contact.created_at.strftime(\"%b %-d, %Y\") %\u003e\u003c/td\u003e\n                \u003ctd class=\"px-2 py-2 text-right whitespace-nowrap\"\u003e\n                  \u003c%= link_to edit_contact_path(contact),\n                        class: \"inline-flex items-center justify-center w-7 h-7 rounded text-gray-400 hover:text-indigo-600 hover:bg-gray-100\",\n                        title: \"Edit\", data: { turbo_frame: \"record_drawer\" } do %\u003e\n                    \u003ci class=\"fas fa-pen text-xs\"\u003e\u003c/i\u003e\n                  \u003c% end %\u003e\n                \u003c/td\u003e\n              \u003c/tr\u003e\n            \u003c% end %\u003e\n\n            \u003c% if @contacts.empty? %\u003e\n              \u003ctr\u003e\n                \u003ctd colspan=\"5\" class=\"text-center py-16\"\u003e\n                  \u003cdiv class=\"flex flex-col items-center gap-2 text-gray-400\"\u003e\n                    \u003ci class=\"fas fa-address-book text-3xl\"\u003e\u003c/i\u003e\n                    \u003cspan class=\"text-sm\"\u003eNo contacts match these filters\u003c/span\u003e\n                  \u003c/div\u003e\n                \u003c/td\u003e\n              \u003c/tr\u003e\n            \u003c% end %\u003e\n          \u003c/tbody\u003e\n        \u003c/table\u003e\n      \u003c/div\u003e\n    \u003c/main\u003e\n  \u003c/div\u003e\n\u003c% end %\u003e\n```\n\n---\n\n## Layer 4 — Stimulus: the left filter panel\n\nWidth-transition open/close. Collapsed is `w-0 px-0 opacity-0`; the inner `w-64` div\nkeeps the content from reflowing during the animation.\n\n```js\n// app/javascript/controllers/filter_drawer_controller.js\nimport { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  static targets = [\"panel\", \"filterIcon\", \"closeIcon\"]\n\n  connect() { this.close() }\n\n  toggle() {\n    this.panelTarget.classList.contains(\"w-0\") ? this.open() : this.close()\n  }\n\n  open() {\n    this.panelTarget.classList.remove(\"w-0\", \"px-0\", \"opacity-0\")\n    this.panelTarget.classList.add(\"w-64\", \"px-4\", \"opacity-100\")\n    this.panelTarget.style.pointerEvents = \"\"\n    if (this.hasCloseIconTarget)  this.closeIconTarget.classList.remove(\"hidden\")\n    if (this.hasFilterIconTarget) this.filterIconTarget.classList.add(\"hidden\")\n  }\n\n  close() {\n    this.panelTarget.classList.add(\"w-0\", \"px-0\", \"opacity-0\")\n    this.panelTarget.classList.remove(\"w-64\", \"px-4\", \"opacity-100\")\n    this.panelTarget.style.pointerEvents = \"none\"\n    if (this.hasCloseIconTarget)  this.closeIconTarget.classList.add(\"hidden\")\n    if (this.hasFilterIconTarget) this.filterIconTarget.classList.remove(\"hidden\")\n  }\n}\n```\n\n---\n\n## Layer 5 — Controller: permit the filters, chain the scopes\n\n```ruby\n# app/controllers/contacts_controller.rb\nclass ContactsController \u003c ApplicationController\n  before_action :set_contact, only: %i[ show edit update destroy ]\n\n  def index\n    @filters = index_filter_params\n\n    scope = Contact.order(created_at: :desc)\n\n    if @filters[:q].present?\n      q = \"%#{Contact.sanitize_sql_like(@filters[:q])}%\"\n      scope = scope.where(\"name ILIKE :q OR email ILIKE :q OR company ILIKE :q\", q: q)\n    end\n\n    scope = scope.where(\"created_at \u003e= ?\", parse_date(@filters[:start_date]))            if @filters[:start_date].present?\n    scope = scope.where(\"created_at \u003c= ?\", parse_date(@filters[:end_date])\u0026.end_of_day)  if @filters[:end_date].present?\n    scope = scope.where(status: @filters[:statuses])                                     if @filters[:statuses].present?\n\n    per_page = @filters[:page_size].to_i.clamp(1, 500)\n    @pagy, @contacts = pagy(scope, limit: per_page)   # no pagy? see How to adapt\n\n    @total_count = Contact.count\n  end\n\n  def show; end\n  def edit; end\n\n  def new\n    @contact = Contact.new\n  end\n\n  # Scaffold-standard create/update work UNCHANGED with the drawer:\n  # the redirect is followed INSIDE the frame → drawer swaps to the fresh show view;\n  # validation errors re-render the form inside the drawer.\n  def create\n    @contact = Contact.new(contact_params)\n    if @contact.save\n      redirect_to @contact, notice: \"Contact created.\"\n    else\n      render :new, status: :unprocessable_entity\n    end\n  end\n\n  def update\n    if @contact.update(contact_params)\n      redirect_to @contact, notice: \"Contact updated.\", status: :see_other\n    else\n      render :edit, status: :unprocessable_entity\n    end\n  end\n\n  def destroy\n    @contact.destroy!\n    redirect_to contacts_path, notice: \"Contact deleted.\", status: :see_other\n  end\n\n  private\n\n  def set_contact\n    @contact = Contact.find(params[:id])\n  end\n\n  def contact_params\n    params.require(:contact).permit(:name, :email, :company, :phone, :website, :status, :notes)\n  end\n\n  def index_filter_params\n    permitted = params.permit(:q, :start_date, :end_date, :page_size, statuses: []).to_h.symbolize_keys\n    permitted[:page_size] = 100 if permitted[:page_size].blank?\n    permitted\n  end\n\n  # Bad date strings from the URL must not 500 the page.\n  def parse_date(value)\n    Date.parse(value.to_s)\n  rescue ArgumentError, TypeError\n    nil\n  end\nend\n```\n\n---\n\n## Layer 6 — show \u0026 edit: normal views that land in the drawer\n\nThe drawer shows **all** the columns, even though the table showed four.\n\n```erb\n\u003c%# app/views/contacts/show.html.erb %\u003e\n\u003c%= render \"shared/drawer_content\", title: \"Contact\", subtitle: @contact.name do %\u003e\n  \u003c% { \"Name\"    =\u003e @contact.name,    \"Email\"   =\u003e @contact.email,\n       \"Company\" =\u003e @contact.company, \"Phone\"   =\u003e @contact.phone,\n       \"Website\" =\u003e @contact.website, \"Status\"  =\u003e @contact.status.humanize,\n       \"Notes\"   =\u003e @contact.notes,\n       \"Created\" =\u003e @contact.created_at.strftime(\"%b %-d, %Y %H:%M\") }.each do |label, value| %\u003e\n    \u003cdiv\u003e\n      \u003cstrong class=\"block text-[11px] font-semibold text-gray-500 uppercase tracking-wider mb-0.5\"\u003e\u003c%= label %\u003e\u003c/strong\u003e\n      \u003cspan class=\"text-gray-800\"\u003e\u003c%= value.presence || \"—\" %\u003e\u003c/span\u003e\n    \u003c/div\u003e\n  \u003c% end %\u003e\n\n  \u003cdiv class=\"pt-4 mt-6 border-t border-gray-100 flex flex-wrap items-center gap-2\"\u003e\n    \u003c%= link_to edit_contact_path(@contact),\n          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\",\n          data: { turbo_frame: \"record_drawer\" } do %\u003e\n      \u003ci class=\"fas fa-pen text-xs\"\u003e\u003c/i\u003eEdit\n    \u003c% end %\u003e\n    \u003c%= button_to @contact, method: :delete,\n          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\",\n          form: { data: { turbo_frame: \"_top\", turbo_confirm: \"Delete this contact?\" }, class: \"inline\" } do %\u003e\n      \u003ci class=\"fas fa-trash text-xs\"\u003e\u003c/i\u003eDelete\n    \u003c% end %\u003e\n  \u003c/div\u003e\n\u003c% end %\u003e\n```\n\n```erb\n\u003c%# app/views/contacts/edit.html.erb %\u003e\n\u003c%= render \"shared/drawer_content\", title: \"Editing Contact\", subtitle: @contact.name do %\u003e\n  \u003c%= render \"form\", contact: @contact %\u003e\n  \u003cdiv class=\"pt-4 mt-2 border-t border-gray-100\"\u003e\n    \u003c%= link_to \"Cancel\", contact_path(@contact), class: \"text-sm text-gray-500 hover:text-gray-700\",\n          data: { turbo_frame: \"record_drawer\" } %\u003e\n  \u003c/div\u003e\n\u003c% end %\u003e\n```\n\n`new.html.erb` is the same shape as `edit` (title \"New Contact\", render the form).\nThe `_form` partial is a scaffold-standard `form_with model: contact` — no frame\nattributes needed on it; a form inside a frame targets that frame by default.\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **Reset the frame's `src` on close, not just its content.** Turbo won't re-navigate a\n  frame whose `src` already equals the clicked URL. If close only clears `innerHTML`,\n  clicking the SAME row again silently does nothing and the drawer never reopens. The\n  close handler must do both: `frame.removeAttribute('src'); frame.innerHTML = ''`.\n- **Open the drawer on `turbo:frame-load`, not on click.** Opening on click flashes an\n  empty white panel while the fetch is in flight. Waiting for the frame to load means\n  the drawer slides in already populated. (Corollary: if the drawer \"doesn't open\",\n  check the browser console — a failed frame fetch means no `turbo:frame-load` fires.)\n- **The row-click handler must ignore interactive descendants.** Without the\n  `e.target.closest('a, button, input, select, textarea, [data-no-drawer]')` guard, the\n  pencil link fires AND the row handler fires — double navigation. `[data-no-drawer]` is\n  the escape hatch for any future in-row widget.\n- **Scaffold redirects are followed INSIDE the frame — usually what you want.** After a\n  successful update, `redirect_to @contact` swaps the drawer to the fresh show view.\n  Trade-off: the table row behind the drawer still shows stale values until the next\n  page navigation. If that matters, break out instead: submit the form with\n  `data: { turbo_frame: \"_top\" }` to reload the whole filtered index after save.\n- **`destroy` MUST break out to `_top`.** A delete that responds inside the frame leaves\n  the dead record's row sitting in the table. `button_to ... form: { data: { turbo_frame:\n  \"_top\" } }` makes the redirect reload the index so the row disappears.\n- **Wrap the index in ONE GET form and stamp it `data: { turbo_frame: \"_top\" }`.**\n  Everything in the filter panel is a plain field of that form, so \"Apply Filters\" is a\n  normal navigation with clean, shareable URL params — and the `_top` stamp guarantees a\n  filter submit never gets captured by the drawer frame.\n- **Permit array params explicitly.** `params.permit(:q, statuses: [])` — forget the\n  `statuses: []` declaration and multi-selects silently arrive empty.\n- **Guard date parsing.** A hand-edited URL like `?start_date=garbage` will 500 the index\n  through `Date.parse`. Always rescue to `nil` (the `parse_date` helper above).\n- **Direct visits to `/contacts/1` still work, but look bare.** The frame renders inline\n  in the main content area (the drawer shell stays closed). Fine for an admin tool; if it\n  bothers you, have `show` redirect HTML requests without a `Turbo-Frame` header to the\n  index.\n- **Sticky header needs the right scroll container.** `sticky top-0` on `\u003cthead\u003e` only\n  works because the `overflow-auto` div is the table's nearest scroll ancestor. Put\n  `overflow-x-auto` on a different wrapper and the header scrolls away.\n- **Flash inside a frame response is invisible.** A `notice:` on the redirect renders in\n  the layout, which frame responses strip — so nothing shows. Either live without toasts\n  for drawer actions, or embed the flash inside the frame content in a hidden\n  `[data-drawer-flash]` div and have the layout JS move it to a fixed toast host on\n  `turbo:frame-load` (the source project does exactly this).\n- **Font Awesome isn't guaranteed on every box.** The icons here (`fa-filter`,\n  `fa-times`, `fa-search`, `fa-pen`, `fa-plus`, `fa-trash`) need Font Awesome in the\n  layout. No FA? Use inline SVGs or text glyphs (✕, ✎) — the pattern doesn't care.\n- **Collapse the panel with `w-0 px-0 opacity-0`, keep a fixed-width inner div.** Animating\n  the outer width while an inner `w-64` div holds the content is what makes the slide\n  smooth — animate the padding/width of the content itself and it reflows mid-animation.\n\n---\n\n## Files this pattern touches\n\n```\napp/views/layouts/application.html.erb          # drawer shell + CSS + JS (once, global)\napp/views/shared/_drawer_content.html.erb       # frame wrapper for show/edit/new\napp/views/shared/_filter_select.html.erb        # reusable multi-select filter\napp/views/contacts/index.html.erb               # trigger strip + filter panel + table\napp/views/contacts/show.html.erb                # all columns, in the drawer\napp/views/contacts/edit.html.erb                # form, in the drawer\napp/views/contacts/new.html.erb                 # form, in the drawer\napp/javascript/controllers/filter_drawer_controller.js\napp/controllers/contacts_controller.rb          # filter params + scopes\n```\n\n## How to adapt to your schema\n\n1. **Swap the model.** Replace `Contact` and its routes. Pick the 4–6 columns humans\n   scan for the table; everything else lives only in the drawer's show/edit views.\n2. **Pick your filters.** Text search + date range + one or two multi-selects covers most\n   admin tables. For an association filter (e.g. by category), render another\n   `shared/filter_select` with `options: Category.order(:name).pluck(:name, :id)` and add\n   `category_ids: []` to the permitted params + a `where(category_id: ...)` scope.\n3. **No pagy?** Replace the pagy line with\n   `@contacts = scope.limit(per_page).offset(([params[:page].to_i, 1].max - 1) * per_page)`\n   and simple prev/next links that carry the filter params. Keep the 500 cap — an\n   unbounded index on a big table can eat a small box's RAM.\n4. **Second resource?** Reuse the layout drawer, both shared partials, and the Stimulus\n   controller as-is. You only write the new index markup and the show/edit bodies.\n5. **Drawer too narrow/wide?** It's one class on the `\u003caside\u003e`: `max-w-2xl`. Forms with\n   many columns like `max-w-3xl`; a read-mostly log viewer is fine at `max-w-xl`.\n6. **Want the filter panel open by default?** Change `connect()` to call `this.open()`\n   when any filter param is present (pass them via a Stimulus value), so a shared\n   filtered URL shows its filters.\n"}