Files
app/frontend/src/components/Flow/EndpointNode.tsx
T

146 lines
5.2 KiB
TypeScript

import { Handle, type NodeProps, Position } from "@xyflow/react"
import {
LayoutDashboard,
LogIn,
LogOut,
MonitorSmartphone,
Workflow,
} from "lucide-react"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { useIsMobile } from "@/hooks/useMobile"
import { cn } from "@/lib/utils"
import { type EndpointNodeData, publisherOf } from "./endpoints"
import { handleOffset, nodeHeight } from "./layout"
import { useLiveValues } from "./liveStore"
const KIND_ICONS = {
dashboard: LayoutDashboard,
// The same glyph the dashboards screen opens its panels with.
panel: MonitorSmartphone,
flow: Workflow,
input: LogIn,
output: LogOut,
} as const
/**
* A thing wired into this flow that is not a node in it.
*
* Drawn as a label rather than a card on purpose: a dashboard with twenty
* tiles would otherwise bury the logic the canvas exists to show, which is
* the same reason dashboards are their own documents. It is here to account
* for a value, not to compete with the nodes for attention.
*/
function EndpointNodeComponent({ id, data, selected }: NodeProps) {
const { label, kind, detail, provides, requires } = data as EndpointNodeData
const Icon = KIND_ICONS[kind as keyof typeof KIND_ICONS] ?? Workflow
const messages = useMemo(
() => [...provides, ...requires],
[provides, requires],
)
const values = useLiveValues(messages)
// Which way the last value went, and a count whose change remounts the sweep
// — remounting is what restarts it. See `.endpoint-sweep` in flow.css.
const [sweep, setSweep] = useState<{ dir: "in" | "out"; n: number }>()
// When each message last carried a value, as far as this label has seen.
// The first look only takes notes: mounting is not a value passing, and
// neither is a snapshot catching the page up.
const seen = useRef<Map<string, number>>(null)
// Follows the graph's own direction; see DESIGN-GUIDELINES.md → Responsive.
const vertical = useIsMobile()
// A dashboard reading six messages has six edges, and stacking their dots on
// one point is what made them leave in a knot. Spread and sized the way a
// node's ports are, so the layout can reserve exactly this much.
const ports = Math.max(provides.length, requires.length)
useEffect(() => {
const first = seen.current === null
seen.current ??= new Map()
let dir: "in" | "out" | undefined
for (const [index, name] of messages.entries()) {
const live = values[index]
// An empty store is a reconnect on its way, not a value.
if (!live?.ts || seen.current.get(name) === live.ts) continue
seen.current.set(name, live.ts)
if (first || live.prior) continue
// Published by this label, by the rule its own edges pulse on (LiveEdge).
if (
provides.includes(name) &&
(!live.source || live.source.id === publisherOf(id))
) {
dir = "in"
} else if (requires.includes(name)) {
dir ??= "out"
}
}
if (!dir) return
const next = dir
setSweep((last) => ({ dir: next, n: (last?.n ?? 0) + 1 }))
}, [values, messages, provides, requires, id])
return (
<div
style={vertical ? undefined : { minHeight: nodeHeight(ports) }}
className={cn(
// Padding keeps the text off the connector dot, which sits on the edge.
// Isolated so the sweep can sit behind the text without leaving it.
"relative isolate flex max-w-48 cursor-pointer items-center gap-2 px-3 py-1",
// Quiet at rest so the logic reads first; legible when reached for.
"text-muted-foreground/55 transition-colors",
"hover:text-foreground",
selected && "text-foreground",
)}
title={messages.join("\n")}
>
{/* Both sides always exist so an edge can attach; only one is used. */}
{requires.map((message, index) => {
const offset = handleOffset(index, requires.length)
return (
<Handle
key={`in-${message}`}
type="target"
id={message}
position={vertical ? Position.Top : Position.Left}
className="!border-border !bg-card"
style={vertical ? { left: offset } : { top: offset }}
/>
)
})}
{provides.map((message, index) => {
const offset = handleOffset(index, provides.length)
return (
<Handle
key={`out-${message}`}
type="source"
id={message}
position={vertical ? Position.Bottom : Position.Right}
className="!border-border !bg-card"
style={vertical ? { left: offset } : { top: offset }}
/>
)
})}
{sweep ? (
<span
key={sweep.n}
className={cn(
"endpoint-sweep rounded-sm",
sweep.dir === "out" && "endpoint-sweep-out",
)}
/>
) : null}
<Icon className="size-4 shrink-0" />
<span className="grid min-w-0">
<span className="truncate text-sm leading-tight">{label}</span>
<span className="truncate text-xs tracking-wide uppercase opacity-70">
{detail}
</span>
</span>
</div>
)
}
export const EndpointNode = memo(EndpointNodeComponent)
export default EndpointNode