Files
app/frontend/src/components/Flow/FlowDock.tsx
T
stroblmeandClaude Opus 5 4215e057d1
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
Add a global search, and stop the sidebar logo squeezing
`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
2026-08-28 22:19:08 +02:00

442 lines
14 KiB
TypeScript

import { useReactFlow } from "@xyflow/react"
import {
AlertCircle,
Check,
Maximize2,
Pause,
Pencil,
Play,
PlayCircle,
Plus,
StepForward,
WifiOff,
X,
} from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useState } from "react"
import type { ValidationIssue } from "@/client"
import { Button } from "@/components/ui/button"
import { FluksioLoader } from "@/components/ui/fluksio-loader"
import { Separator } from "@/components/ui/separator"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { slideUp, transitions } from "@/lib/motion"
import { cn } from "@/lib/utils"
import { type LogsFilter, LogsPanel, LogsTrigger, PANEL } from "./LogsPanel"
import { useLiveConnection } from "./liveStore"
/**
* What "fit" means on this canvas: the view a flow opens with, and the one the
* fit button returns to. The generous padding keeps the graph clear of the
* chrome floating over it, and the cap stops a two-node flow from being blown
* up past legibility.
*/
export const FIT_VIEW = { padding: 0.25, maxZoom: 1.2 }
/**
* The same fit with the settings panel's lane held clear, so an opening panel
* never lands on the node you are looking at. 432px is the lane the chrome is
* pushed out of by `md:right-[27rem]`; xyflow's padding parser takes px and %,
* so it cannot be spelled in rem. 10% per side is what `padding: 0.25` above
* resolves to, so the two fits are equally generous.
*/
export const FIT_VIEW_PANEL = {
maxZoom: FIT_VIEW.maxZoom,
// `as const` because xyflow types each side as `${number}px | ${number}%`,
// which a widened `string` does not satisfy.
padding: { top: "10%", bottom: "10%", left: "10%", right: "432px" } as const,
}
/**
* The action bar, floating bottom-centre. Run is the one brand-secondary
* affordance on this view; everything else stays quiet.
*
* Everything the flow bar used to carry is here too — whether the work is
* saved, the flow's own settings, and putting it live or throwing it away —
* so the top of the canvas is left to say which flow this is.
*
* It wraps rather than overflows, and drops the fit control on a phone; see
* DESIGN-GUIDELINES.md → Responsive.
*/
export function FlowDock({
flow,
issues,
running,
enabled,
paused,
saving,
hasDraft,
publishing,
logs,
onAddNode,
onRun,
onTogglePause,
onStep,
stepping,
onFocusNode,
onEditFlow,
onPublish,
onDiscard,
className,
}: {
flow: string
issues: ValidationIssue[]
running: boolean
enabled: boolean
paused: boolean
saving: boolean
hasDraft: boolean
publishing: boolean
/** Owned by the editor, because a failing node can open it too. */
logs: LogsFilter
onAddNode: () => void
onRun: () => void
onTogglePause: () => void
onStep: () => void
stepping: boolean
onFocusNode: (nodeId: string) => void
onEditFlow: () => void
onPublish: () => void
onDiscard: () => void
className?: string
}) {
const { fitView } = useReactFlow()
const connected = useLiveConnection()
const [issuesOpen, setIssuesOpen] = useState(false)
// The engine marks the issues it does not count against a flow. They are
// still worth saying, so they stay in the list — but in the muted tone, and
// without turning the summary red, since nothing here is actually broken.
const faults = issues.filter((issue) => !issue.advisory)
return (
// The panels and the bar are siblings in one column, so neither nests its
// half-pixel centring translate or its backdrop filter inside the other —
// which is what Firefox was drawing the console's text through. It also
// gives the two panels a row to share, instead of one landing on the other.
<div
className={cn(
"pointer-events-none absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 flex-col items-center",
className,
)}
>
<div className="mb-3 flex max-w-[calc(100vw-2rem)] flex-wrap items-end justify-center gap-3">
<IssuesPanel
open={issuesOpen}
issues={issues}
onClose={() => setIssuesOpen(false)}
onFocusNode={onFocusNode}
/>
<LogsPanel flow={flow} {...logs} />
</div>
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
// Capped and wrapping: the canvas shell clips, so an uncapped row would
// put the buttons at its ends out of reach on a phone rather than merely
// look wrong. See DESIGN-GUIDELINES.md → Responsive.
className="pointer-events-auto flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onAddNode}
aria-label="Add node"
data-testid="add-node"
>
<Plus />
</Button>
</TooltipTrigger>
<TooltipContent>Add a node (⌘P)</TooltipContent>
</Tooltip>
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
{/* A phone pinches to zoom and the graph fits itself, so this would only
be taking room the rest of the bar needs. */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hidden text-muted-foreground md:inline-flex md:size-8"
onClick={() => fitView({ ...FIT_VIEW, duration: 300 })}
aria-label="Fit the flow to the screen"
>
<Maximize2 />
</Button>
</TooltipTrigger>
<TooltipContent>Fit to screen</TooltipContent>
</Tooltip>
{issues.length > 0 ? (
<>
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<Button
variant="ghost"
size="sm"
className={cn(
"h-11 gap-1.5 md:h-8",
faults.length > 0
? "text-destructive"
: issuesOpen
? "text-primary"
: "text-muted-foreground",
)}
onClick={() => setIssuesOpen(!issuesOpen)}
aria-pressed={issuesOpen}
data-testid="validation-summary"
>
<AlertCircle className="size-4" />
{issues.length}
</Button>
</>
) : null}
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<LogsTrigger open={logs.open} onOpenChange={logs.onOpenChange} />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"size-11 text-muted-foreground md:size-8",
paused && "text-primary",
)}
onClick={onTogglePause}
disabled={!enabled}
aria-label={paused ? "Resume flow" : "Pause flow"}
data-testid={paused ? "resume-flow" : "pause-flow"}
>
{paused ? <PlayCircle /> : <Pause />}
</Button>
</TooltipTrigger>
<TooltipContent>
{!enabled
? "This flow is stopped"
: paused
? "Let the flow carry on"
: "Hold the nodes; values still arrive"}
</TooltipContent>
</Tooltip>
{paused && enabled ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onStep}
disabled={stepping}
aria-label="Step the flow"
data-testid="step-flow"
>
<StepForward />
</Button>
</TooltipTrigger>
<TooltipContent>Let one held-back item through</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="brand"
size="sm"
className="h-11 gap-1.5 md:h-8"
onClick={onRun}
disabled={running || !enabled}
data-testid="run-flow"
>
{running ? (
<FluksioLoader className="size-4" />
) : (
<Play className="size-4" />
)}
Run
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{enabled ? "Run every node once" : "Start the flow to run it"}
</TooltipContent>
</Tooltip>
<Separator
orientation="vertical"
className="mx-0.5 !h-5 hidden md:block"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onEditFlow}
aria-label="Flow settings"
data-testid="edit-flow"
>
<Pencil />
</Button>
</TooltipTrigger>
<TooltipContent>Flow settings</TooltipContent>
</Tooltip>
{/* Throwing the edit away sits next to putting it live, and only exists
while there is something to throw away. */}
{hasDraft ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onDiscard}
aria-label="Discard the unpublished changes"
data-testid="discard-draft"
>
<X />
</Button>
</TooltipTrigger>
<TooltipContent>Discard the unpublished changes</TooltipContent>
</Tooltip>
) : null}
{/*
* Saved state and publish are one control: the glyph never moves, it
* simply stops being something you can press once there is nothing left
* to put live. A button that appears and disappears moved everything
* beside it just as the work was finished.
*/}
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="icon"
className="size-11 text-muted-foreground md:size-8"
onClick={onPublish}
disabled={!hasDraft || publishing || saving || !connected}
aria-label="Publish this flow"
data-testid="publish-flow"
>
{!connected ? (
<WifiOff className="size-3.5" />
) : saving || publishing ? (
<FluksioLoader className="size-3.5" />
) : (
<Check className="size-3.5" />
)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{!connected
? "Reconnecting to the engine"
: publishing
? "Publishing"
: saving
? "Saving"
: hasDraft
? "Saved — publish to put it live"
: "All changes saved"}
</TooltipContent>
</Tooltip>
</motion.div>
</div>
)
}
/**
* What stops this flow running, or is merely worth knowing — the same chrome as
* the logs, so a canvas with both open shows one pair rather than a panel and a
* popover disagreeing about what a panel looks like.
*
* Not a popover for the same reason the logs are not: clicking a node is what
* you do while reading this, since each line points at one.
*/
function IssuesPanel({
open,
issues,
onClose,
onFocusNode,
}: {
open: boolean
issues: ValidationIssue[]
onClose: () => void
onFocusNode: (nodeId: string) => void
}) {
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose()
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [open, onClose])
return (
<AnimatePresence>
{open && issues.length > 0 ? (
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
className={PANEL}
data-testid="issues-panel"
>
<div className="border-b border-border px-3 py-2">
<p className="text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
Needs attention
</p>
</div>
<ul className="grid gap-0.5 p-2">
{issues.map((issue) => (
<li key={`${issue.code}-${issue.node}-${issue.message_name}`}>
<button
type="button"
className={cn(
"w-full rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/50 disabled:cursor-default disabled:hover:bg-transparent",
issue.advisory && "text-muted-foreground",
)}
onClick={() => issue.node && onFocusNode(issue.node)}
disabled={!issue.node}
>
{issue.message}
</button>
</li>
))}
</ul>
</motion.div>
) : null}
</AnimatePresence>
)
}