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

`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:
2026-08-28 22:19:08 +02:00
co-authored by Claude Opus 5
parent 971bd430c7
commit 4215e057d1
17 changed files with 642 additions and 39 deletions
+2
View File
@@ -15,6 +15,7 @@ from fluksio.api.routes import (
panels,
private,
runs,
search,
secrets,
users,
utils,
@@ -38,6 +39,7 @@ api_router.include_router(observability.router)
api_router.include_router(runs.router)
api_router.include_router(artifacts.router)
api_router.include_router(workers.router)
api_router.include_router(search.router)
# Remote access through a portal. Always mounted; with no enrolment the
# endpoints only ever report that there is none.
api_router.include_router(cloud.router)
+156
View File
@@ -0,0 +1,156 @@
"""One index of everything in this installation worth jumping to by name."""
from typing import Any, Literal
from fastapi import APIRouter, Depends, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from fluksio.api.deps import (
CurrentUser,
DashboardStoreDep,
FlowControllerDep,
get_current_user,
)
from fluksio.api.routes.alerts import read_config as read_alerts_config
from fluksio.flow import modules, panels
from fluksio.flow.controller import FlowController
from fluksio.flow.dashboards import DashboardNotFound, DashboardStore
from fluksio.flow.secrets import get_secrets
from fluksio.flow.store import FlowNotFound
# A wall panel never reaches this route: ``deps._panel_may`` is a whitelist that
# ends in a 403, and a whole-installation index is the opposite of what a screen
# on a wall is allowed to read.
router = APIRouter(
prefix="/search", tags=["search"], dependencies=[Depends(get_current_user)]
)
Category = Literal[
"flow",
"node",
"dashboard",
"widget",
"panel",
"secret",
"module",
"worker",
"alert",
]
class SearchEntry(BaseModel):
"""One thing somebody might be looking for.
Deliberately not a route: where a category lands is the frontend's business,
and it already owns the router. This says what the thing is and what it is
called, which is all the matching needs.
"""
category: Category
#: The id the frontend routes on.
name: str
#: Human title, often empty — a flow is usually only its name.
title: str = ""
#: The flow a node sits in, or the dashboard a widget sits on.
parent: str = ""
#: Node type, widget type, channel kind.
kind: str = ""
def _build(
controller: FlowController, dashboards: DashboardStore, hub: Any, secrets: bool
) -> list[SearchEntry]:
"""Read every store once. Blocking: disk and git throughout.
# ponytail: rebuilt per call. Key it on ``controller.store.revision`` if a
# store large enough to feel it ever shows up in a profile.
"""
entries: list[SearchEntry] = []
for name in controller.store.list_flows():
try:
flow = controller.store.read_flow(name, draft=True)
except FlowNotFound:
continue
entries.append(SearchEntry(category="flow", name=flow.name, title=flow.title))
entries.extend(
SearchEntry(
category="node",
name=node.id,
title=node.title,
parent=flow.name,
kind=node.type,
)
for node in flow.nodes
)
for summary in dashboards.list():
try:
dashboard = dashboards.read(summary.name, draft=True)
except DashboardNotFound:
continue
entries.append(
SearchEntry(
category="dashboard", name=dashboard.name, title=dashboard.title
)
)
entries.extend(
SearchEntry(
category="widget",
name=widget.id,
title=widget.title,
parent=dashboard.name,
kind=widget.type,
)
for widget in dashboard.widgets
)
entries.extend(
SearchEntry(category="panel", name=panel.id, title=panel.title)
for panel in panels.read_config().panels
)
if secrets:
entries.extend(
SearchEntry(category="secret", name=name) for name in get_secrets().list()
)
entries.extend(
SearchEntry(category="module", name=package.name, kind=package.version)
for package in modules.info(controller.store).packages
)
entries.extend(
SearchEntry(category="worker", name=worker.name)
for worker in (hub.workers() if hub is not None else [])
)
entries.extend(
SearchEntry(category="alert", name=channel.name, kind=channel.kind)
for channel in read_alerts_config().channels
)
return entries
@router.get("/", response_model=list[SearchEntry])
async def read_search_index(
current_user: CurrentUser,
request: Request,
controller: FlowControllerDep,
dashboards: DashboardStoreDep,
) -> Any:
"""Everything searchable, for the client to match against as it is typed.
The whole index rather than a query: it is a few hundred short rows for an
installation of any ordinary size, so one fetch when the panel opens beats a
round trip per keystroke — and the client already has a matcher.
Secrets are named only to a superuser, which is who ``/secrets`` answers to.
"""
# Absent when remote workers are switched off — not a reason to fail a search.
hub = getattr(request.app.state, "worker_hub", None)
return await run_in_threadpool(
_build, controller, dashboards, hub, current_user.is_superuser
)
+80
View File
@@ -0,0 +1,80 @@
"""The one index the global search matches against."""
from fastapi.testclient import TestClient
from fluksio.core.config import settings
PREFIX = f"{settings.API_V1_STR}/search"
FLOWS = f"{settings.API_V1_STR}/flows"
DASHBOARDS = f"{settings.API_V1_STR}/dashboards"
SECRETS = f"{settings.API_V1_STR}/secrets"
def test_search_requires_authentication(client: TestClient) -> None:
assert client.get(f"{PREFIX}/").status_code == 401
def test_index_reaches_inside_flows_and_dashboards(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""A node and a widget are the point: neither is on any list endpoint."""
client.put(
f"{FLOWS}/searchable",
headers=superuser_token_headers,
json={
"name": "searchable",
"title": "Searchable",
"nodes": [{"id": "sensor", "type": "python", "title": "Hall sensor"}],
},
)
client.put(
f"{DASHBOARDS}/hall",
headers=superuser_token_headers,
json={
"name": "hall",
"title": "Hall",
"widgets": [
{
"id": "temperature",
"type": "stat",
"title": "Temperature",
"layout": {"lg": {"x": 0, "y": 0, "w": 3, "h": 2}},
"config": {"message": "hall.temperature", "dtype": "float"},
}
],
"version": 0,
},
)
entries = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
# Keyed on the parent too: an id is only unique within the document it is
# in, and the other suites seed their own `sensor` and `temperature`.
found = {
(entry["category"], entry["parent"], entry["name"]): entry for entry in entries
}
assert found[("flow", "", "searchable")]["title"] == "Searchable"
assert found[("node", "searchable", "sensor")]["title"] == "Hall sensor"
assert found[("node", "searchable", "sensor")]["kind"] == "python"
assert found[("dashboard", "", "hall")]["title"] == "Hall"
assert found[("widget", "hall", "temperature")]["title"] == "Temperature"
assert found[("widget", "hall", "temperature")]["kind"] == "stat"
def test_secrets_are_named_only_to_a_superuser(
client: TestClient,
superuser_token_headers: dict[str, str],
normal_user_token_headers: dict[str, str],
) -> None:
client.put(
f"{SECRETS}/broker_password",
headers=superuser_token_headers,
json={"value": "hunter2"},
)
def secrets(headers: dict[str, str]) -> set[str]:
entries = client.get(f"{PREFIX}/", headers=headers).json()
return {e["name"] for e in entries if e["category"] == "secret"}
assert "broker_password" in secrets(superuser_token_headers)
assert secrets(normal_user_token_headers) == set()
+1 -1
View File
@@ -54,7 +54,7 @@ Two things to know about this:
make dev-frontend # Vite on :5173
```
Restart the backend and your node type appears in the add-node palette (⌘K),
Restart the backend and your node type appears in the add-node palette (⌘P),
labelled with the package it came from.
## Write the node
+1 -1
View File
@@ -134,7 +134,7 @@ safe, fan-in is free, and two flows can share a value by naming it.
### Read a sensor
Press **Add node** (or ⌘K / Ctrl-K, which opens the command palette) and pick
Press **Add node** (or ⌘P / Ctrl-P, which opens the command palette) and pick
**MQTT**. In its panel on the right:
- **Broker host** — your broker's hostname, `mosquitto` if you are using the
+2 -2
View File
@@ -29,7 +29,7 @@ banner tells you when it is not.
## Adding a node
**Add node** on the dock, or ⌘K / Ctrl-K for the command palette, which also
**Add node** on the dock, or ⌘P / Ctrl-P for the command palette, which also
jumps between flows and offers your shared nodes. Pick a type and it appears on
the canvas with its panel open.
@@ -122,7 +122,7 @@ the edges, then publish.
| Chord | Action |
|---|---|
| ⌘K / Ctrl-K | command palette |
| ⌘P / Ctrl-P | command palette |
| ⌘S / Ctrl-S | publish the flow — or, with focus in the code editor, apply the code |
| ⌘Z / ⌘⇧Z | undo / redo (the flow; the code editor has its own) |
| ⌘C / ⌘V | copy and paste nodes, including between flows |
+12
View File
@@ -22,8 +22,20 @@ phone the sidebar collapses to a sheet.
| **Modules** | the Python packages your node code may import |
| **Alerts** | where failures get sent |
| **Admin** | users (superusers only) |
| **Search** | anything in this installation, by name |
| **Settings** | your account, appearance, and remote access |
### Search
**Search** at the foot of the sidebar, or ⌘K / Ctrl-K from anywhere, opens a
panel that finds things by name as you type: flows and the nodes inside them,
dashboards and the widgets on them, panels, secrets, modules, workers and alert
channels. Picking a node opens its flow with that node in focus; picking a
widget opens its dashboard.
It searches this installation. Reached through a portal, other installations
are behind **All installations** at the top of the sidebar.
## Home
The one screen you leave open. Four things share it.
+37
View File
@@ -2960,6 +2960,43 @@ export const RunRequestSchema = {
title: 'RunRequest'
} as const;
export const SearchEntrySchema = {
properties: {
category: {
type: 'string',
enum: ['flow', 'node', 'dashboard', 'widget', 'panel', 'secret', 'module', 'worker', 'alert'],
title: 'Category'
},
name: {
type: 'string',
title: 'Name'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
parent: {
type: 'string',
title: 'Parent',
default: ''
},
kind: {
type: 'string',
title: 'Kind',
default: ''
}
},
type: 'object',
required: ['category', 'name'],
title: 'SearchEntry',
description: `One thing somebody might be looking for.
Deliberately not a route: where a category lands is the frontend's business,
and it already owns the router. This says what the thing is and what it is
called, which is all the matching needs.`
} as const;
export const SecretNamesSchema = {
properties: {
data: {
File diff suppressed because one or more lines are too long
+19
View File
@@ -1052,6 +1052,23 @@ export type RunRequest = {
};
};
/**
* One thing somebody might be looking for.
*
* Deliberately not a route: where a category lands is the frontend's business,
* and it already owns the router. This says what the thing is and what it is
* called, which is all the matching needs.
*/
export type SearchEntry = {
category: 'flow' | 'node' | 'dashboard' | 'widget' | 'panel' | 'secret' | 'module' | 'worker' | 'alert';
name: string;
title?: string;
parent?: string;
kind?: string;
};
export type category = 'flow' | 'node' | 'dashboard' | 'widget' | 'panel' | 'secret' | 'module' | 'worker' | 'alert';
export type SecretNames = {
data: Array<(string)>;
count: number;
@@ -1830,6 +1847,8 @@ export type RunsCompareMetricData = {
export type RunsCompareMetricResponse = (SeriesAnswer);
export type SearchReadSearchIndexResponse = (Array<SearchEntry>);
export type SecretsReadSecretsResponse = (SecretNames);
export type SecretsSaveSecretData = {
@@ -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>
)
}
@@ -13,7 +13,7 @@ import {
import { libraryQueryOptions } from "./queries"
/**
* K: add a node, jump to another flow, or run the current one, without
* P: add a node, jump to another flow, or run the current one, without
* reaching for the dock.
*/
export function CommandPalette({
+1 -1
View File
@@ -157,7 +157,7 @@ export function FlowDock({
<Plus />
</Button>
</TooltipTrigger>
<TooltipContent>Add a node (K)</TooltipContent>
<TooltipContent>Add a node (P)</TooltipContent>
</Tooltip>
<Separator
+5 -3
View File
@@ -1055,7 +1055,9 @@ function FlowEditorInner({
"mod+shift+z": () => step(false),
"mod+c": () => void copyNodes(),
"mod+v": pasteNodes,
"mod+k": () => setPaletteOpen((open) => !open),
// ⌘P, not ⌘K: the sidebar's global search owns that everywhere, and this
// palette is the canvas's own, narrower thing.
"mod+p": () => setPaletteOpen((open) => !open),
// Inside the code editor ⌘S applies that code, which the node panel
// owns; anywhere else on the canvas it puts the flow live.
"mod+s": (event) => {
@@ -1064,7 +1066,7 @@ function FlowEditorInner({
},
// Both stay reachable while typing: one is the editor's own save, the
// other is how you reach anything at all.
["mod+s", "mod+k"],
["mod+s", "mod+p"],
)
return (
@@ -1243,7 +1245,7 @@ function FlowEditorInner({
</span>
<p className="text-lg font-medium">This flow is empty</p>
<p className="max-w-xs text-sm text-muted-foreground">
Add a node to get started. Press K, or use the plus in the bar
Add a node to get started. Press P, or use the plus in the bar
below.
</p>
</div>
+52 -27
View File
@@ -8,12 +8,15 @@ import {
LayoutDashboard,
LogOut,
Package,
Search,
Server,
Settings,
Users,
Workflow,
} from "lucide-react"
import { useState } from "react"
import { GlobalSearch } from "@/components/Common/GlobalSearch"
import { Logo } from "@/components/Common/Logo"
import {
Sidebar,
@@ -24,6 +27,7 @@ import {
} from "@/components/ui/sidebar"
import useAuth from "@/hooks/useAuth"
import { portalConfig } from "@/lib/portal"
import { useShortcuts } from "@/lib/shortcuts"
import { type Item, Main } from "./Main"
/**
@@ -55,6 +59,12 @@ const baseItems: Item[] = [
export function AppSidebar() {
const { user: currentUser, logout } = useAuth()
const portal = portalConfig()
const [searchOpen, setSearchOpen] = useState(false)
// Mounted by both shells, so this one binding covers every screen. Listed as
// firing inside text entry too, because reaching anything at all should not
// depend on where the caret happens to be.
useShortcuts({ "mod+k": () => setSearchOpen((open) => !open) }, ["mod+k"])
const withAdmin = currentUser?.is_superuser
? [...baseItems, { icon: Users, title: "Admin", path: "/admin" }]
@@ -81,41 +91,56 @@ export function AppSidebar() {
onClick: () => window.open(DOCS_URL, "_blank", "noopener"),
}
const search: Item = {
icon: Search,
title: "Search",
onClick: () => setSearchOpen(true),
}
const footerItems: Item[] = portal
? [docs, { icon: Settings, title: "Settings", path: "/settings" }]
? [search, docs, { icon: Settings, title: "Settings", path: "/settings" }]
: [
search,
docs,
{ icon: Settings, title: "Settings", path: "/settings" },
{ icon: LogOut, title: "Log Out", onClick: logout },
]
return (
// Floating frosted chrome over whatever surface the shell paints; see the
// root DESIGN-GUIDELINES.md → Shells and → Overlay surfaces & content chips.
<Sidebar
collapsible="icon"
variant="floating"
className="[&>[data-sidebar=sidebar]]:bg-card/80 [&>[data-sidebar=sidebar]]:backdrop-blur-md [&>[data-sidebar=sidebar]]:shadow-e2"
>
<SidebarHeader className="px-4 py-6 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center">
<div className="flex w-full items-center justify-between gap-2 group-data-[collapsible=icon]:justify-center">
{/* Collapsed, the rail has room for one thing, and that is the way
back out. */}
<span className="group-data-[collapsible=icon]:hidden">
<Logo variant="responsive" />
</span>
{/* On a phone the sidebar is a sheet with its own way in and out. */}
<SidebarTrigger className="hidden shrink-0 text-muted-foreground md:inline-flex" />
</div>
</SidebarHeader>
<SidebarContent>
<Main items={items} />
</SidebarContent>
{/* Main already pads horizontally; the footer only adds the bottom gap. */}
<SidebarFooter className="px-0">
<Main items={footerItems} />
</SidebarFooter>
</Sidebar>
<>
{/* Floating frosted chrome over whatever surface the shell paints; see the
root DESIGN-GUIDELINES.md Shells and Overlay surfaces & content
chips. */}
<Sidebar
collapsible="icon"
variant="floating"
className="[&>[data-sidebar=sidebar]]:bg-card/80 [&>[data-sidebar=sidebar]]:backdrop-blur-md [&>[data-sidebar=sidebar]]:shadow-e2"
>
<SidebarHeader className="px-4 py-6 group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:items-center">
<div className="flex w-full items-center justify-between gap-2 group-data-[collapsible=icon]:justify-center">
{/* Collapsed, the rail has room for one thing, and that is the way
back out. `shrink-0` because the rail's width animates while the
logo is already back: a flex item short of room is squeezed, and
a wordmark would rather be clipped than squashed. */}
<span className="shrink-0 group-data-[collapsible=icon]:hidden">
<Logo variant="responsive" />
</span>
{/* On a phone the sidebar is a sheet with its own way in and out. */}
<SidebarTrigger className="hidden shrink-0 text-muted-foreground md:inline-flex" />
</div>
</SidebarHeader>
<SidebarContent>
<Main items={items} />
</SidebarContent>
{/* Main already pads horizontally; the footer only adds the bottom gap. */}
<SidebarFooter className="px-0">
<Main items={footerItems} />
</SidebarFooter>
</Sidebar>
{/* Outside the sidebar: on a phone that is a sheet, and a dialog is not
one of its children. */}
<GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} />
</>
)
}
@@ -35,8 +35,15 @@ import {
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
type Search = { panels?: boolean }
export const Route = createFileRoute("/_layout/dashboards/")({
component: Dashboards,
// The panels dialog has no route of its own, so the address is how anything
// else — the global search among them — arrives at it.
validateSearch: (search: Record<string, unknown>): Search => ({
panels: search.panels === true || search.panels === "true" || undefined,
}),
})
function Dashboards() {
@@ -47,8 +54,12 @@ function Dashboards() {
const [name, setName] = useState("")
const [search, setSearch] = useState("")
const [dialogOpen, setDialogOpen] = useState(false)
const [panelsOpen, setPanelsOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
// Which screens exist is a question with an address, so anything can link to
// it — the global search lands a panel here.
const { panels: panelsOpen } = Route.useSearch()
const setPanelsOpen = (open: boolean) =>
navigate({ to: "/dashboards", search: open ? { panels: true } : {} })
const create = useMutation({
mutationFn: (dashboard: string) =>
@@ -110,7 +121,7 @@ function Dashboards() {
{/* Its own root rather than a nested one: which screens exist is a
different question from which dashboards do. */}
<Dialog open={panelsOpen} onOpenChange={setPanelsOpen}>
<Dialog open={panelsOpen ?? false} onOpenChange={setPanelsOpen}>
<PanelsDialog />
</Dialog>
+53
View File
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test"
import { api, apiPage, deleteAll } from "./utils/api"
/**
* The global search: the only way to reach a node without knowing its flow.
*
* The load-bearing part is what the flat index turns back into a node hit is
* a flow address with that node in focus, which no list endpoint could have
* answered.
*/
const flowName = `test_search_${Date.now().toString(36)}`
test.use({ storageState: "playwright/.auth/user.json" })
test.describe.configure({ mode: "serial" })
test.beforeAll(async ({ browser }) => {
const page = await apiPage(browser)
await api(page, `/flows/${flowName}`, {
method: "PUT",
data: {
name: flowName,
title: "Searchable flow",
nodes: [{ id: "findme", type: "python", title: "Find me" }],
},
})
await page.close()
})
test.afterAll(async ({ browser }) => {
await deleteAll(browser, [`/flows/${flowName}`])
})
test("finds a node by name and opens its flow on it", async ({ page }) => {
await page.goto("/")
await page.getByRole("button", { name: "Search", exact: true }).click()
await page.getByTestId("global-search-input").fill("findme")
await page.getByRole("option", { name: /Find me/ }).click()
await expect(page).toHaveURL(new RegExp(`/flows/${flowName}\\?node=findme$`))
})
test("opens on the keyboard from anywhere", async ({ page }) => {
await page.goto("/dashboards")
// The binding lives in the sidebar, so wait for it to be there to press at.
await expect(
page.getByRole("button", { name: "Search", exact: true }),
).toBeVisible()
await page.keyboard.press("ControlOrMeta+k")
await expect(page.getByTestId("global-search-input")).toBeFocused()
})