Files
app/frontend/src/components/Common/GlobalSearch.tsx
T
stroblmeandClaude Opus 5 062a2aac60 Let the search reach past the twentieth of a kind, and unbreak two specs
The global search capped each category before cmdk had matched anything, so
nothing past the twentieth node or widget could be found at all — this
instance has 28 nodes and 97 widgets. The cap now trims the candidates the
query could reach rather than the raw index.

The admin teardown asked /users/ for limit=1000, which the route stopped
accepting when its bounds went in; a 422 body has no `data` to iterate, so the
hook threw and took the tests it was attributed to with it. It asks for the
500 the route allows.

And `submit` folds every declared initial into a run's params, so an input the
run never passed can no longer read as the flow's own. That assertion is gone;
what the panel does show — the value the run actually started from, passed or
not — is what the test checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CL9zvnnvcp1mvA8o7impxk
2026-09-06 14:46:59 +02:00

231 lines
7.9 KiB
TypeScript

import { useQuery } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import {
Bell,
Box,
KeyRound,
LayoutDashboard,
LayoutGrid,
type LucideIcon,
MonitorSmartphone,
Package,
Server,
Workflow,
} from "lucide-react"
import { useState } from "react"
import { type SearchEntry, SearchService } from "@/client"
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
export const searchQueryOptions = () => ({
queryKey: ["search"] as const,
queryFn: () => SearchService.readSearchIndex(),
staleTime: 30_000,
})
/** The categories, in the order they are offered, with what to draw each as. */
//: The most entries one group shows. cmdk scores what is mounted, so the cap
//: has to come after the query has had its say, never before: capping the raw
//: index instead put everything past the twentieth node or widget beyond reach
//: of the search altogether. Twenty of what matches is already more than
//: anybody reads before typing another letter.
const GROUP_CAP = 20
const GROUPS: {
category: SearchEntry["category"]
label: string
icon: LucideIcon
}[] = [
{ category: "flow", label: "Flows", icon: Workflow },
{ category: "node", label: "Nodes", icon: Box },
{ category: "dashboard", label: "Dashboards", icon: LayoutDashboard },
{ category: "widget", label: "Widgets", icon: LayoutGrid },
{ category: "panel", label: "Panels", icon: MonitorSmartphone },
{ category: "secret", label: "Secrets", icon: KeyRound },
{ category: "module", label: "Modules", icon: Package },
{ category: "worker", label: "Workers", icon: Server },
{ category: "alert", label: "Alerts", icon: Bell },
]
/** The second line: where the thing lives, and what kind it is. */
function hint(entry: SearchEntry): string {
return [entry.parent, entry.kind].filter(Boolean).join(" · ")
}
/** Everything about an entry that is worth matching against. */
function searchValue(entry: SearchEntry): string {
return `${entry.name} ${entry.title ?? ""} ${entry.parent ?? ""} ${entry.kind ?? ""}`
}
/** cmdk lowercases and flattens whitespace and hyphens before it scores. */
function normalise(text: string): string {
return text.toLowerCase().replace(/[\s-]/g, " ")
}
/**
* Whether cmdk could score this entry at all: it needs the query's characters
* in the value, in order. Asking the cheap half of that question here is what
* lets GROUP_CAP cap the candidates rather than the index.
*/
function couldMatch(value: string, query: string): boolean {
const text = normalise(value)
let from = 0
for (const char of normalise(query)) {
from = text.indexOf(char, from) + 1
if (from === 0) return false
}
return true
}
/**
* Everything in this instance, by name, from anywhere.
*
* The whole index arrives in one fetch and `cmdk` does the matching, so results
* narrow as they are typed without a round trip per keystroke.
*
* cmdk does the matching over what is mounted, so the entries are bucketed by
* category once per fetch rather than filtered nine times per keystroke, and
* each group is capped — a list nobody can scroll to the end of is not a
* better answer than its first twenty.
*/
export function GlobalSearch({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const navigate = useNavigate()
const [query, setQuery] = useState("")
const { data } = useQuery({ ...searchQueryOptions(), enabled: open })
// Picking an item navigates, which can interrupt the dialog's exit animation
// and leave its overlay swallowing clicks — the same reason the flow canvas
// palette unmounts outright rather than fading out.
if (!open) return null
const entries = data ?? []
const typed = query.trim()
const typing = typed.length > 0
// One pass over the index rather than one per group — nine passes over
// every entry on each keystroke. Not memoised: the component returns early
// while closed, so a hook cannot go here, and one pass is already the win.
// The same pass drops what the query cannot reach, so each bucket is already
// candidates by the time GROUP_CAP trims it.
const byCategory = new Map<string, SearchEntry[]>()
for (const entry of entries) {
if (!couldMatch(searchValue(entry), typed)) continue
const bucket = byCategory.get(entry.category)
if (bucket) bucket.push(entry)
else byCategory.set(entry.category, [entry])
}
const go = (entry: SearchEntry) => {
onOpenChange(false)
setQuery("")
switch (entry.category) {
case "flow":
return navigate({
to: "/flows/$flowName",
params: { flowName: entry.name },
})
case "node":
return navigate({
to: "/flows/$flowName",
params: { flowName: entry.parent ?? "" },
search: { node: entry.name },
})
case "dashboard":
return navigate({
to: "/dashboards/$name",
params: { name: entry.name },
})
case "widget":
return navigate({
to: "/dashboards/$name",
params: { name: entry.parent ?? "" },
})
// Panels are managed in a dialog on the dashboards screen, which opens
// itself when the address says so.
case "panel":
return navigate({ to: "/dashboards", search: { panels: true } })
case "secret":
return navigate({ to: "/secrets" })
case "module":
return navigate({ to: "/modules" })
case "worker":
return navigate({ to: "/workers" })
case "alert":
return navigate({ to: "/alerts" })
}
}
return (
// Frosted chrome, a little above centre. `top-[40%]` against the dialog's
// own `-translate-y-1/2` puts the panel's middle at two fifths of the
// viewport; the inner Command paints its own surface, which has to give way
// to this one.
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Search"
description="Find a flow, node, dashboard or widget"
showCloseButton={false}
className="top-[40%] bg-popover/80 shadow-e3 backdrop-blur-md sm:max-w-xl [&_[data-slot=command]]:bg-transparent"
>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Search flows, nodes, dashboards, widgets…"
data-testid="global-search-input"
/>
<CommandList className="max-h-[min(24rem,60svh)]">
{typing ? (
<>
<CommandEmpty>Nothing matches that.</CommandEmpty>
{GROUPS.map(({ category, label, icon: Icon }) => {
const found = (byCategory.get(category) ?? []).slice(0, GROUP_CAP)
if (found.length === 0) return null
return (
<CommandGroup key={category} heading={label}>
{found.map((entry) => (
<CommandItem
key={`${category}:${entry.parent ?? ""}:${entry.name}`}
value={searchValue(entry)}
onSelect={() => go(entry)}
className="min-h-11 md:min-h-8"
>
<Icon />
<span className="flex min-w-0 flex-col">
<span className="truncate">
{entry.title || entry.name}
</span>
{hint(entry) ? (
<span className="truncate text-xs text-muted-foreground">
{hint(entry)}
</span>
) : null}
</span>
</CommandItem>
))}
</CommandGroup>
)
})}
</>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
Start typing to search this instance.
</p>
)}
</CommandList>
</CommandDialog>
)
}