Cmd+K Global Search (search across many tables at once)
⚠️ 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.
Once an app has more than a couple of tables, "where do I find X?" becomes the main friction. A global search kills it: the user hits Cmd+K (or Ctrl+K) from anywhere — or clicks the magnifying-glass icon in the header — a modal pops open with the cursor already in the search box, they type a couple characters, and results from multiple tables (users, orders, invoices, whatever) appear grouped by type and clickable straight to the record. No page reload, no dedicated search page.
The whole thing is one Stimulus controller (keyboard + modal + fetch), one skinny
controller action that runs a few ILIKE queries, and a Turbo Stream that swaps grouped
results into the modal. ~90 lines total.
When to use: any admin/dashboard app with several browseable tables — the moment a nav menu can't hold every "find a ___" path. When not to: a single-table app (just put a filter box on the index), or when you need real full-text relevance ranking across huge tables (reach for Postgres
tsvector/ pg_search / Elasticsearch instead — this recipe is deliberately a simple substring match).
The 80/20 in one breath
- A
<dialog>modal lives once in your layout (in the header, next to the search icon). - A
global-searchStimulus controller listens for Cmd/Ctrl+K globally to open it, Escape to close, and focuses the input on open. - Typing submits the form; the controller
fetches the search endpoint asking for a Turbo Stream (not a full page). - A tiny
SearchController#indexruns oneILIKEquery per table (users, instances, …), each.limit(10). - The Turbo Stream replaces the results div with a partial that renders each table's hits as its own labeled group of links.
That's it — steps 1–2 are the palette, 3–5 are the multi-table search.
Layer 1 — The route
# config/routes.rb
# One GET endpoint. Namespace/authorize it however your app does (admin-only here).
namespace :admin do
get "search", to: "search#index" # -> admin_search_path
end
Layer 2 — The controller (this is where "many tables" lives)
Each table you want searchable = one more ILIKE query. Keep every result set small
(.limit) so the modal stays fast and the payload stays tiny.
# app/controllers/admin/search_controller.rb
class Admin::SearchController < ApplicationController
before_action :authenticate_user!
before_action :ensure_operator_or_admin # your own authz — don't leak all rows
def index
@query = params[:q]
if @query.present? && @query.length >= 2
# ALWAYS sanitize before interpolating into an ILIKE pattern.
sanitized = ActiveRecord::Base.sanitize_sql_like(@query)
pattern = "%#{sanitized}%"
# --- one block per table you want in the palette ---
@users = User
.where("email ILIKE :q OR first_name ILIKE :q OR last_name ILIKE :q", q: pattern)
.order(:email)
.limit(10)
@user_instances = UserInstance.includes(:user) # includes(:user) avoids N+1 in the view
.where("dns_name ILIKE :q", q: pattern)
.order(:dns_name)
.limit(10)
# @orders = Order.where("number ILIKE :q", q: pattern).limit(10)
# @invoices = Invoice.where("reference ILIKE :q", q: pattern).limit(10)
else
@users = []
@user_instances = []
end
respond_to do |format|
format.html # standalone /admin/search page (graceful fallback, no JS)
format.turbo_stream # what the palette fetches
end
end
end
Layer 3 — The Stimulus controller (keyboard + modal + fetch)
// app/javascript/controllers/global_search_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal", "input", "results", "form"]
connect() {
// Bind once so add/removeEventListener reference the SAME function.
this.boundKeydown = this.keydown.bind(this)
window.addEventListener("keydown", this.boundKeydown)
}
disconnect() {
window.removeEventListener("keydown", this.boundKeydown) // clean up on Turbo nav
}
keydown(event) {
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
event.preventDefault() // stop the browser's own Cmd+K (focus address bar)
this.open()
}
if (event.key === "Escape") {
this.close()
}
}
open() {
this.modalTarget.showModal() // native <dialog> — focus trap + backdrop free
setTimeout(() => this.inputTarget.focus(), 100) // wait for the dialog to paint
}
close() {
this.modalTarget.close()
}
search(event) {
event.preventDefault()
const query = this.inputTarget.value.trim()
if (query.length < 2) return // mirror the controller's 2-char floor
fetch(`${this.formTarget.action}?q=${encodeURIComponent(query)}`, {
headers: { "Accept": "text/vnd.turbo-stream.html" } // ask for a stream, not HTML
})
.then(response => response.text())
.then(html => Turbo.renderStreamMessage(html)) // Turbo swaps the results div
}
}
Layer 4 — The modal markup (icon + Cmd+K trigger, lives once in the layout)
Uses a native <dialog> (free focus-trap, Escape, and backdrop) plus DaisyUI/Tailwind
classes. The whole block is wrapped in one data-controller="global-search".
<%# app/views/layouts/application.html.erb — put this in the header/navbar %>
<div data-controller="global-search">
<%# The magnifying-glass button — click to open %>
<button data-action="click->global-search#open"
class="btn btn-ghost btn-circle btn-sm tooltip tooltip-bottom"
data-tip="Search (Cmd+K)">
<i class="fas fa-search text-sm"></i>
</button>
<dialog data-global-search-target="modal" class="modal">
<div class="modal-box max-w-2xl bg-white p-0 overflow-hidden">
<%= form_with url: admin_search_path, method: :get,
data: { action: "submit->global-search#search", global_search_target: "form" },
class: "relative" do |f| %>
<div class="flex items-center border-b border-slate-100 px-4 py-3">
<i class="fas fa-search text-slate-400 mr-3"></i>
<%= f.text_field :q,
placeholder: "Search users or DNS names...",
class: "input input-ghost w-full focus:outline-none px-0 text-lg",
data: { global_search_target: "input" },
autocomplete: "off" %>
<kbd class="kbd kbd-sm">ESC</kbd>
</div>
<% end %>
<%# Turbo replaces the INNER content of this id; the wrapper id must be stable. %>
<div id="global_search_results" data-global-search-target="results"
class="max-h-[60vh] overflow-y-auto">
<div class="p-8 text-center text-slate-400">
<i class="fas fa-search text-4xl mb-3 opacity-20"></i>
<p>Start typing to search...</p>
</div>
</div>
</div>
<%# Native <dialog> backdrop — click outside to close %>
<form method="dialog" class="modal-backdrop"><button>close</button></form>
</dialog>
</div>
Layer 5 — The Turbo Stream + grouped results partial
The stream targets the same id the input listens against, and re-renders one group
per table. Each row is a link_to straight to the record — that's what makes it a
navigation tool, not just a lookup.
<%# app/views/admin/search/index.turbo_stream.erb %>
<%= turbo_stream.update "global_search_results" do %>
<%= render partial: "admin/search/results",
locals: { users: @users, user_instances: @user_instances, query: @query } %>
<% end %>
<%# app/views/admin/search/_results.html.erb %>
<div class="py-2">
<% if users.any? || user_instances.any? %>
<%# --- Group 1: Users --- %>
<% if users.any? %>
<div class="px-4 py-2 bg-slate-50 text-[10px] font-bold uppercase tracking-widest text-slate-500 border-y flex items-center gap-2">
<i class="fas fa-users"></i> Users
</div>
<div class="divide-y divide-slate-50">
<% users.each do |user| %>
<%= link_to admin_user_path(user), class: "flex items-center px-4 py-3 hover:bg-indigo-50 group" do %>
<div class="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center font-bold text-xs mr-3">
<%= user.email[0].upcase %>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-slate-800 truncate"><%= user.email %></div>
<div class="text-xs text-slate-500 truncate"><%= user.full_name %></div>
</div>
<i class="fas fa-chevron-right text-[10px] text-slate-300"></i>
<% end %>
<% end %>
</div>
<% end %>
<%# --- Group 2: another table (copy this block per table) --- %>
<% if user_instances.any? %>
<div class="px-4 py-2 bg-slate-50 text-[10px] font-bold uppercase tracking-widest text-slate-500 border-y mt-2 flex items-center gap-2">
<i class="fas fa-server"></i> Instances
</div>
<div class="divide-y divide-slate-50">
<% user_instances.each do |instance| %>
<%= link_to admin_user_instance_path(instance), class: "flex items-center px-4 py-3 hover:bg-indigo-50 group" do %>
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-slate-800 truncate"><%= instance.dns_name %></div>
<div class="text-xs text-slate-500 truncate">Owner: <%= instance.user.email %></div>
</div>
<% end %>
<% end %>
</div>
<% end %>
<%# --- Empty vs. prompt states --- %>
<% elsif query.present? && query.length >= 2 %>
<div class="p-12 text-center text-slate-400">
<i class="fas fa-search-minus text-4xl mb-3 opacity-20"></i>
<p>No results for "<span class="font-semibold text-slate-600"><%= query %></span>"</p>
</div>
<% else %>
<div class="p-12 text-center text-slate-400">
<p>Type at least 2 characters to search...</p>
</div>
<% end %>
</div>
Gotchas (the hard-won stuff)
event.preventDefault()on Cmd+K is mandatory. Browsers bind Cmd/Ctrl+K to "focus the address bar / search." Without the preventDefault your modal fights the browser and often loses. Handle bothmetaKey(Mac) andctrlKey(Win/Linux).- Bind the keydown handler once and remove it on
disconnect.this.keydown.bind(this)returns a new function each call, so you must stash it (this.boundKeydown) toremoveEventListenerthe same reference. Skip this and every Turbo navigation stacks another live listener → Cmd+K fires N times, N modals, leaks. - Focus after a
setTimeout, not synchronously. A native<dialog>isn't focusable the instant you callshowModal();inputTarget.focus()on the same tick silently no-ops. The ~100ms delay lets it paint first. - Use a native
<dialog>+showModal(). You get a focus-trap, Escape-to-close, and a::backdropfor free — no custom overlay z-index wars. The<form method="dialog">backdrop closes on outside-click with zero JS. - The Turbo target id must be stable and match. The controller renders into
#global_search_results; the layout div carries that id. Rename one and results silently vanish (the stream targets a node that isn't there). Accept: text/vnd.turbo-stream.htmlis what makes it a stream. Drop that header and Rails serves the full HTML page,Turbo.renderStreamMessagegets a whole document, and nothing swaps. It pairs withformat.turbo_streamin the controller.- ALWAYS
ActiveRecord::Base.sanitize_sql_likethe query before building theILIKEpattern. A raw%or_from the user otherwise becomes a wildcard, and unsanitized interpolation is an injection vector. Bind with:q, never string-interpolate the value. .limit(10)every table and enforce a 2-char floor — in both the JS and the controller. A 1-char search matches half your DB; unbounded results make the payload huge and the modal janky. The floor lives in two places on purpose (fast client bail + server guard).includes(:user)(or your association) on any table whose row renders an association, or the results partial fires an N+1 across up to 10 rows per group.- Keep the
format.htmlfallback. The same action doubles as a real/admin/searchpage for no-JS / deep-link / bookmark cases. It costs onerespond_tobranch. - Font Awesome isn't guaranteed on every Leo box. This recipe uses
fa-searchetc. If FA isn't loaded, swap in an inline SVG magnifier — don't ship a blank button. - Authorize the endpoint. It reaches across tables, so it can leak everything if
unguarded. Gate it (
ensure_operator_or_adminhere) exactly like the records it exposes.
Files this pattern touches
config/routes.rb # one GET endpoint
app/controllers/admin/search_controller.rb # one ILIKE query per table
app/javascript/controllers/global_search_controller.js # keyboard + modal + fetch
app/views/layouts/application.html.erb # the icon + <dialog> modal (once)
app/views/admin/search/index.turbo_stream.erb # swaps the results div
app/views/admin/search/_results.html.erb # grouped, clickable results
app/views/admin/search/index.html.erb # (optional) no-JS fallback page
How to adapt to your schema
- Pick the tables users hunt for most (5–7 max — the palette is for "jump to a
record," not analytics). For each, add one
ILIKEblock inSearchController#indexand set an@ivar. - Choose the searchable columns per table — the human-recognizable ones (name, email,
number, reference, title). Widen with
OR col ILIKE :qas needed; keep it to a handful. - Add a group in
_results.html.erb: copy an existing<% if @things.any? %>block, give it a header + icon, and make each row alink_to <thing>_path(thing)so it navigates on click. - Wire the new ivars through
index.turbo_stream.erb'slocals:. - Drop the parts you don't need: the
format.htmlfallback page is optional; the avatar/badge chrome is cosmetic. The four load-bearing pieces are the Stimulus controller, the<dialog>markup, the controller queries, and the stream+partial. - Outgrown substring match? When tables get big or you need relevance ranking, keep
this exact UI and swap only the controller's queries for Postgres full-text
(
tsvector/ thepg_searchgem). The palette, modal, and Turbo plumbing don't change.