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
231 lines
7.9 KiB
TypeScript
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>
|
|
)
|
|
}
|