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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -157,7 +157,7 @@ export function FlowDock({
|
||||
<Plus />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add a node (⌘K)</TooltipContent>
|
||||
<TooltipContent>Add a node (⌘P)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
Reference in New Issue
Block a user