Opening a node or the flow settings now takes the flowbar and the dock away, leaving the graph and the panel; they come back on close, leaving by the same slideUp they arrive with. The panel header names the flow before the node, `/heating/`, which is how messages are qualified everywhere else. Opening a flow already fitted, and pressing Fit to screen, disagreed: one used a 0.25 padding capped at 1x, the other xyflow's defaults, so the button visibly jumped and a small flow opened at 100% in an empty canvas. Both read one constant now.
173 lines
5.0 KiB
TypeScript
173 lines
5.0 KiB
TypeScript
import { useReactFlow } from "@xyflow/react"
|
|
import {
|
|
AlertCircle,
|
|
Loader2,
|
|
Maximize2,
|
|
Play,
|
|
Plus,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
} from "lucide-react"
|
|
import { motion } from "motion/react"
|
|
|
|
import type { ValidationIssue } from "@/client"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover"
|
|
import { Separator } from "@/components/ui/separator"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip"
|
|
import { slideUp, transitions } from "@/lib/motion"
|
|
|
|
/**
|
|
* 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 action bar, floating bottom-centre. Run is the one brand-secondary
|
|
* affordance on this view; everything else stays quiet.
|
|
*/
|
|
export function FlowDock({
|
|
issues,
|
|
running,
|
|
onAddNode,
|
|
onRun,
|
|
onFocusNode,
|
|
}: {
|
|
issues: ValidationIssue[]
|
|
running: boolean
|
|
onAddNode: () => void
|
|
onRun: () => void
|
|
onFocusNode: (nodeId: string) => void
|
|
}) {
|
|
const { zoomIn, zoomOut, fitView } = useReactFlow()
|
|
|
|
return (
|
|
<motion.div
|
|
variants={slideUp}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit="exit"
|
|
transition={transitions.emphasized}
|
|
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-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 (⌘K)</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-11 text-muted-foreground md:size-8"
|
|
onClick={() => zoomOut()}
|
|
aria-label="Zoom out"
|
|
>
|
|
<ZoomOut />
|
|
</Button>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-11 text-muted-foreground 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>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-11 text-muted-foreground md:size-8"
|
|
onClick={() => zoomIn()}
|
|
aria-label="Zoom in"
|
|
>
|
|
<ZoomIn />
|
|
</Button>
|
|
|
|
{issues.length > 0 ? (
|
|
<>
|
|
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-11 gap-1.5 text-destructive md:h-8"
|
|
data-testid="validation-summary"
|
|
>
|
|
<AlertCircle className="size-4" />
|
|
{issues.length}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="center" className="w-80 p-2">
|
|
<p className="px-2 py-1.5 text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground">
|
|
Needs attention
|
|
</p>
|
|
<ul className="mt-1 grid gap-0.5">
|
|
{issues.map((issue) => (
|
|
<li key={`${issue.code}-${issue.node}-${issue.message_name}`}>
|
|
<button
|
|
type="button"
|
|
className="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"
|
|
onClick={() => issue.node && onFocusNode(issue.node)}
|
|
disabled={!issue.node}
|
|
>
|
|
{issue.message}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</>
|
|
) : null}
|
|
|
|
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
|
|
|
<Button
|
|
variant="brand"
|
|
size="sm"
|
|
className="h-11 gap-1.5 md:h-8"
|
|
onClick={onRun}
|
|
disabled={running}
|
|
data-testid="run-flow"
|
|
>
|
|
{running ? (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
) : (
|
|
<Play className="size-4" />
|
|
)}
|
|
Run
|
|
</Button>
|
|
</motion.div>
|
|
)
|
|
}
|