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