Cmd+K Global Search Across Many Tables | LlamaPress Cookbook
LlamaPress
Cookbook
Navigation Stable

Cmd+K Global Search (search across many tables at once)

A Cmd+K / click-the-magnifying-glass command palette that opens a modal, queries several tables at once, and streams grouped, clickable results back via Turbo.

Proven on llamapress.ai admin view · stimulus_js · controller

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

  1. A <dialog> modal lives once in your layout (in the header, next to the search icon).
  2. A global-search Stimulus controller listens for Cmd/Ctrl+K globally to open it, Escape to close, and focuses the input on open.
  3. Typing submits the form; the controller fetches the search endpoint asking for a Turbo Stream (not a full page).
  4. A tiny SearchController#index runs one ILIKE query per table (users, instances, …), each .limit(10).
  5. 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 both metaKey (Mac) and ctrlKey (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) to removeEventListener the 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 call showModal(); 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 ::backdrop for 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.html is what makes it a stream. Drop that header and Rails serves the full HTML page, Turbo.renderStreamMessage gets a whole document, and nothing swaps. It pairs with format.turbo_stream in the controller.
  • ALWAYS ActiveRecord::Base.sanitize_sql_like the query before building the ILIKE pattern. 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.html fallback. The same action doubles as a real /admin/search page for no-JS / deep-link / bookmark cases. It costs one respond_to branch.
  • Font Awesome isn't guaranteed on every Leo box. This recipe uses fa-search etc. 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_admin here) 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

  1. Pick the tables users hunt for most (5–7 max — the palette is for "jump to a record," not analytics). For each, add one ILIKE block in SearchController#index and set an @ivar.
  2. Choose the searchable columns per table — the human-recognizable ones (name, email, number, reference, title). Widen with OR col ILIKE :q as needed; keep it to a handful.
  3. Add a group in _results.html.erb: copy an existing <% if @things.any? %> block, give it a header + icon, and make each row a link_to <thing>_path(thing) so it navigates on click.
  4. Wire the new ivars through index.turbo_stream.erb's locals:.
  5. Drop the parts you don't need: the format.html fallback 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.
  6. 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 / the pg_search gem). The palette, modal, and Turbo plumbing don't change.