322 lines
9.1 KiB
TypeScript
322 lines
9.1 KiB
TypeScript
import { useReactFlow } from "@xyflow/react"
|
|
import {
|
|
AlertCircle,
|
|
Check,
|
|
Loader2,
|
|
Maximize2,
|
|
Pause,
|
|
Pencil,
|
|
Play,
|
|
PlayCircle,
|
|
Plus,
|
|
StepForward,
|
|
WifiOff,
|
|
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"
|
|
import { cn } from "@/lib/utils"
|
|
import { type LogsFilter, LogsPanel } 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 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 — so the top of the
|
|
* canvas is left to say which flow this is.
|
|
*/
|
|
export function FlowDock({
|
|
flow,
|
|
issues,
|
|
running,
|
|
enabled,
|
|
paused,
|
|
saving,
|
|
hasDraft,
|
|
publishing,
|
|
logs,
|
|
onAddNode,
|
|
onRun,
|
|
onTogglePause,
|
|
onStep,
|
|
stepping,
|
|
onFocusNode,
|
|
onEditFlow,
|
|
onPublish,
|
|
}: {
|
|
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
|
|
}) {
|
|
const { zoomIn, zoomOut, fitView } = useReactFlow()
|
|
const connected = useLiveConnection()
|
|
|
|
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" />
|
|
|
|
<LogsPanel flow={flow} {...logs} />
|
|
|
|
<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 ? (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
) : (
|
|
<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" />
|
|
|
|
<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>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
|
|
{!connected ? (
|
|
<WifiOff className="size-3.5" />
|
|
) : saving ? (
|
|
<Loader2 className="size-3.5 animate-spin" />
|
|
) : (
|
|
<Check className="size-3.5" />
|
|
)}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{!connected
|
|
? "Reconnecting to the engine"
|
|
: saving
|
|
? "Saving"
|
|
: hasDraft
|
|
? "Saved — publish to put it live"
|
|
: "All changes saved"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
|
|
{hasDraft ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-11 shrink-0 rounded-full md:h-8"
|
|
onClick={onPublish}
|
|
disabled={publishing}
|
|
data-testid="publish-flow"
|
|
>
|
|
{publishing ? "Publishing…" : "Publish"}
|
|
</Button>
|
|
) : null}
|
|
</motion.div>
|
|
)
|
|
}
|