Add a global search, and stop the sidebar logo squeezing
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m14s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m53s
pre-commit / pre-commit (push) Failing after 2m13s
Test Backend / test-backend (push) Successful in 2m38s
Compose Smoke Test / test-compose (push) Successful in 38s
Playwright Tests / merge-reports (push) Successful in 1m8s
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m14s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m53s
pre-commit / pre-commit (push) Failing after 2m13s
Test Backend / test-backend (push) Successful in 2m38s
Compose Smoke Test / test-compose (push) Successful in 38s
Playwright Tests / merge-reports (push) Successful in 1m8s
`GET /api/v1/search/` hands the client one flat index of everything worth jumping to — flows and the nodes inside them, dashboards and the widgets on them, panels, secrets, modules, workers and alert channels — and cmdk matches it in the browser, so results narrow while typing without a round trip per keystroke. A node hit is the one thing no list endpoint could answer: it opens its flow with that node in focus. The panel is reached from **Search** above Documentation in the sidebar, or ⌘K anywhere. The flow canvas palette moves to ⌘P, being the narrower of the two. The panels dialog gains an address (`/dashboards?panels`) so a panel hit has somewhere to land, and the sidebar logo gets `shrink-0`: the rail's width animates while the logo is already back, and a flex item short of room is squeezed rather than clipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016vGH7jqcXxWKP9wZFPyVdU
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
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. */
|
||||
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 in this installation, 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.
|
||||
*
|
||||
* ponytail: every entry is rendered and cmdk hides the ones that do not match.
|
||||
* Cap the groups if an installation ever grows big enough to feel it.
|
||||
*/
|
||||
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 typing = query.trim().length > 0
|
||||
|
||||
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 = entries.filter(
|
||||
(entry) => entry.category === category,
|
||||
)
|
||||
if (found.length === 0) return null
|
||||
return (
|
||||
<CommandGroup key={category} heading={label}>
|
||||
{found.map((entry) => (
|
||||
<CommandItem
|
||||
key={`${category}:${entry.parent ?? ""}:${entry.name}`}
|
||||
value={`${entry.name} ${entry.title ?? ""} ${entry.parent ?? ""} ${entry.kind ?? ""}`}
|
||||
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 installation.
|
||||
</p>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user