- Added e2e tests for agents, insights, maintenance windows, maps, help pages - Added e2e tests for organization settings, config timeline, MikroTik backups - Added comprehensive search functionality tests - Added dashboard and sites navigation tests - Updated existing tests to handle sudo verification redirects - Fixed navigation tests to be more defensive about missing data - All 301 tests passing across chromium, firefox, and webkit
198 lines
7.3 KiB
Elixir
198 lines
7.3 KiB
Elixir
defmodule ToweropsWeb.Live.Components.GlobalSearchComponent do
|
|
@moduledoc """
|
|
Global search overlay (Cmd+K / Ctrl+K) that searches across
|
|
devices, sites, accounts, and alerts.
|
|
"""
|
|
use ToweropsWeb, :live_component
|
|
|
|
alias Towerops.Search
|
|
|
|
@impl true
|
|
def mount(socket) do
|
|
{:ok, assign(socket, query: "", results: %{}, selected_index: 0, open: false)}
|
|
end
|
|
|
|
@impl true
|
|
def update(assigns, socket) do
|
|
{:ok, assign(socket, assigns)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("open", _params, socket) do
|
|
{:noreply, assign(socket, open: true, query: "", results: %{}, selected_index: 0)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("close", _params, socket) do
|
|
{:noreply, assign(socket, open: false, query: "", results: %{}, selected_index: 0)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("search", %{"value" => query}, socket) do
|
|
results =
|
|
if String.length(String.trim(query)) >= 2 do
|
|
Search.search(socket.assigns.organization_id, String.trim(query))
|
|
else
|
|
%{}
|
|
end
|
|
|
|
{:noreply, assign(socket, query: query, results: results, selected_index: 0)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("keydown", %{"key" => "ArrowDown"}, socket) do
|
|
max = flat_results_count(socket.assigns.results) - 1
|
|
idx = min(socket.assigns.selected_index + 1, max(max, 0))
|
|
{:noreply, assign(socket, selected_index: idx)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("keydown", %{"key" => "ArrowUp"}, socket) do
|
|
idx = max(socket.assigns.selected_index - 1, 0)
|
|
{:noreply, assign(socket, selected_index: idx)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("keydown", %{"key" => "Enter"}, socket) do
|
|
flat = flat_results(socket.assigns.results)
|
|
|
|
case Enum.at(flat, socket.assigns.selected_index) do
|
|
nil -> {:noreply, socket}
|
|
result -> {:noreply, push_navigate(socket, to: result.url)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("keydown", _params, socket) do
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
flat = flat_results(assigns.results)
|
|
assigns = assign(assigns, flat_results: flat)
|
|
|
|
~H"""
|
|
<div id="global-search" phx-hook="GlobalSearch" phx-target={@myself}>
|
|
<%= if @open do %>
|
|
<div class="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
|
|
<div
|
|
class="fixed inset-0 bg-black/50 transition-opacity"
|
|
phx-click="close"
|
|
phx-target={@myself}
|
|
>
|
|
</div>
|
|
<div class="flex min-h-full items-start justify-center p-4 pt-[15vh]">
|
|
<div class="relative w-full max-w-lg transform rounded-xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10">
|
|
<div class="flex items-center gap-3 border-b border-gray-200 px-4 dark:border-white/10">
|
|
<.icon name="hero-magnifying-glass" class="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
|
<input
|
|
type="text"
|
|
id="global-search-input"
|
|
placeholder="Search devices, sites, accounts, alerts..."
|
|
value={@query}
|
|
phx-keydown="keydown"
|
|
phx-keyup="search"
|
|
phx-debounce="200"
|
|
phx-target={@myself}
|
|
autocomplete="off"
|
|
class="flex-1 border-0 bg-transparent py-3.5 text-sm text-gray-900 placeholder:text-gray-400 focus:ring-0 dark:text-white dark:placeholder:text-gray-500"
|
|
/>
|
|
<kbd class="hidden sm:inline-flex items-center rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-400 dark:border-gray-600 dark:text-gray-500">
|
|
esc
|
|
</kbd>
|
|
</div>
|
|
|
|
<div class="max-h-80 overflow-y-auto px-2 py-2">
|
|
<%= if @query != "" and map_size(@results) == 0 do %>
|
|
<p class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
No results found.
|
|
</p>
|
|
<% end %>
|
|
|
|
<%= for {category, items} <- @results do %>
|
|
<div class="mb-2">
|
|
<p class="px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
{category_label(category)}
|
|
</p>
|
|
<%= for {item, idx} <- Enum.with_index(items) do %>
|
|
<% global_idx = global_index(@results, category, idx) %>
|
|
<.link
|
|
navigate={item.url}
|
|
class={[
|
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm cursor-pointer",
|
|
if(global_idx == @selected_index,
|
|
do: "bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
|
else:
|
|
"text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
|
)
|
|
]}
|
|
>
|
|
<.icon
|
|
name={category_icon(category)}
|
|
class="h-4 w-4 flex-shrink-0 opacity-60"
|
|
/>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate font-medium">{item.label}</p>
|
|
<p :if={item.sublabel} class="truncate text-xs opacity-60">
|
|
{item.sublabel}
|
|
</p>
|
|
</div>
|
|
</.link>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
|
|
<%= if @query == "" do %>
|
|
<p class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
Start typing to search...
|
|
</p>
|
|
<% end %>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between border-t border-gray-200 px-4 py-2 text-xs text-gray-400 dark:border-white/10 dark:text-gray-500">
|
|
<div class="flex gap-2">
|
|
<span><kbd class="font-semibold">↑↓</kbd> navigate</span>
|
|
<span><kbd class="font-semibold">↵</kbd> open</span>
|
|
<span><kbd class="font-semibold">esc</kbd> close</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
defp category_label(:devices), do: "Devices"
|
|
defp category_label(:sites), do: "Sites"
|
|
defp category_label(:accounts), do: "Accounts"
|
|
defp category_label(:alerts), do: "Alerts"
|
|
defp category_label(other), do: to_string(other)
|
|
|
|
defp category_icon(:devices), do: "hero-server"
|
|
defp category_icon(:sites), do: "hero-building-office"
|
|
defp category_icon(:accounts), do: "hero-user-group"
|
|
defp category_icon(:alerts), do: "hero-bell-alert"
|
|
defp category_icon(_), do: "hero-magnifying-glass"
|
|
|
|
defp flat_results(results) do
|
|
Enum.flat_map([:devices, :sites, :accounts, :alerts], fn cat ->
|
|
Map.get(results, cat, [])
|
|
end)
|
|
end
|
|
|
|
defp flat_results_count(results), do: length(flat_results(results))
|
|
|
|
defp global_index(results, category, local_idx) do
|
|
order = [:devices, :sites, :accounts, :alerts]
|
|
|
|
offset =
|
|
order
|
|
|> Enum.take_while(&(&1 != category))
|
|
|> Enum.reduce(0, fn cat, acc -> acc + length(Map.get(results, cat, [])) end)
|
|
|
|
offset + local_idx
|
|
end
|
|
end
|