Fix the MQTT node, suggest message names, and drop Items

- MQTT nodes failed to build: the topic map was renamed to talk in ports, but
  __slots__ still declared the old name, so every MQTT node raised
  AttributeError. Building one of each node type is now a test, since __slots__
  makes this failure invisible until someone places the node.
- Port names offer the messages already in play: everything published is worth
  reading, and an input nobody provides yet is worth publishing. A message only
  connects when both ends spell it the same way, so choosing beats typing.
- Adding a port focuses its name field.
- Dragging onto an input that already reads something offers the extra port as
  well as the replacement — an MQTT or InfluxDB node usually wants both.
- The template's Item model, its routes, screens and table are gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i
This commit is contained in:
Melvin Strobl
2026-08-15 18:43:17 +02:00
co-authored by Claude Fable 5
parent 8c82549cf6
commit 01af7787c1
27 changed files with 365 additions and 1433 deletions
+88 -16
View File
@@ -20,7 +20,12 @@ import { useNavigate } from "@tanstack/react-router"
import { Workflow } from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { type FlowDef_Input, FlowsService, type NodeDef_Input } from "@/client"
import {
type FlowDef_Input,
FlowsService,
type MessageSpec,
type NodeDef_Input,
} from "@/client"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -54,9 +59,11 @@ const edgeTypes = { live: LiveEdge }
type Rebind = {
nodeId: string
nodeLabel: string
port: string
from: string
to: string
dtype: MessageSpec["dtype"]
}
/** Step a new node off any node already sitting at that spot. */
@@ -167,8 +174,31 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
[canvasNodes, definitions, flowName, issuesByNode, selectedId, typeLabels],
)
// Edges follow from the name bindings, so they are derived, never stored.
// A cheap fingerprint of the wiring: it changes when a name does, but not
// when a node merely moves.
const key = bindingsKey(definitions)
// Offer the names already in play: everything published is worth reading,
// and an input nobody provides yet is worth publishing.
// biome-ignore lint/correctness/useExhaustiveDependencies: the bindings key is what changes names.
const suggestions = useMemo(() => {
const provided = new Set<string>()
const consumed = new Set<string>()
for (const node of definitions) {
for (const spec of node.provides ?? []) {
if (spec.name) provided.add(spec.name)
}
for (const spec of node.requires ?? []) {
if (spec.name) consumed.add(spec.name)
}
}
return {
consumes: [...provided].sort(),
provides: [...consumed].filter((name) => !provided.has(name)).sort(),
}
}, [key])
// Edges follow from the name bindings, so they are derived, never stored.
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame.
const edges = useMemo(
() => deriveEdges(definitions, flowName),
@@ -297,12 +327,16 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
)
if (!outSpec?.name || !inSpec) return
// Already reading something else: the user may want either message, so
// offer the extra port rather than assuming a replacement.
if (inSpec.name && inSpec.name !== outSpec.name) {
setRebind({
nodeId: consumer.id,
nodeLabel: consumer.title || consumer.id,
port: portOf(inSpec),
from: inSpec.name,
to: outSpec.name,
dtype: outSpec.dtype,
})
return
}
@@ -312,6 +346,26 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
[definitions, applyBinding],
)
/** Give the consumer a second input, bound to the producer's message. */
const addInputPort = useCallback(
(nodeId: string, message: string, dtype: MessageSpec["dtype"]) => {
commit(
definitions.map((node) =>
node.id === nodeId
? {
...node,
requires: [
...(node.requires ?? []),
{ name: message, port: "", dtype },
],
}
: node,
),
)
},
[commit, definitions],
)
const unbind = useCallback(
(message: string) => {
const qualified = qualify(flowName, message)
@@ -420,6 +474,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
node={selected}
flow={flowName}
nodeTypes={nodeTypeInfo ?? []}
suggestions={suggestions}
onChange={updateNode}
onSaveSource={(code) => {
if (selected) sourceMutation.mutate({ nodeId: selected.id, code })
@@ -453,24 +508,41 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
>
<DialogContent>
<DialogHeader>
<DialogTitle>Change what this input reads?</DialogTitle>
<DialogTitle>How should {rebind?.nodeLabel} read this?</DialogTitle>
<DialogDescription>
"{rebind?.port}" currently reads {rebind?.from}. Point it at{" "}
{rebind?.to} instead?
Its "{rebind?.port}" input already reads{" "}
<span className="font-mono">{rebind?.from}</span>. It can take{" "}
<span className="font-mono">{rebind?.to}</span> as well, or
instead.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRebind(null)}>
Keep {rebind?.from}
</Button>
<Button
onClick={() => {
if (rebind) applyBinding(rebind.nodeId, rebind.port, rebind.to)
setRebind(null)
}}
>
Read {rebind?.to}
<DialogFooter className="sm:justify-between">
<Button variant="ghost" onClick={() => setRebind(null)}>
Cancel
</Button>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
if (rebind) {
applyBinding(rebind.nodeId, rebind.port, rebind.to)
}
setRebind(null)
}}
>
Replace
</Button>
<Button
onClick={() => {
if (rebind) {
addInputPort(rebind.nodeId, rebind.to, rebind.dtype)
}
setRebind(null)
}}
>
Add as another input
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
+115 -7
View File
@@ -5,8 +5,16 @@ import { lazy, Suspense, useEffect, useRef, useState } from "react"
import type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
import {
Select,
SelectContent,
@@ -42,19 +50,107 @@ const panelSlide = {
},
}
/**
* A message name, typed freely or picked from the names already in play.
*
* The suggestions are the point: a message only connects when both ends spell
* it the same way, so choosing beats typing.
*/
function MessageNameInput({
value,
suggestions,
placeholder,
autoFocus,
onChange,
}: {
value: string
suggestions: string[]
placeholder: string
autoFocus: boolean
onChange: (next: string) => void
}) {
const [open, setOpen] = useState(false)
const matches = suggestions.filter(
(name) =>
name !== value && name.toLowerCase().includes(value.toLowerCase()),
)
return (
<Popover open={open && matches.length > 0} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<Input
value={value}
placeholder={placeholder}
aria-label="Message name"
autoComplete="off"
// A port added by hand is meant to be named right away.
autoFocus={autoFocus}
className="h-8 flex-1 font-mono text-sm"
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onChange={(event) => {
onChange(event.target.value)
setOpen(true)
}}
onKeyDown={(event) => {
if (event.key === "Escape") setOpen(false)
}}
/>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-[--radix-popover-trigger-width] p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Command shouldFilter={false}>
<CommandList>
<CommandEmpty>No matching message.</CommandEmpty>
<CommandGroup>
{matches.map((name) => (
<CommandItem
key={name}
value={name}
className="font-mono text-sm"
// Blur fires before click, so commit on mousedown.
onMouseDown={(event) => {
event.preventDefault()
onChange(name)
setOpen(false)
}}
onSelect={() => {
onChange(name)
setOpen(false)
}}
>
{name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
function PortList({
title,
specs,
flow,
emptyHint,
suggestions,
onChange,
}: {
title: string
specs: MessageSpec[]
flow: string
emptyHint: string
suggestions: string[]
onChange: (next: MessageSpec[]) => void
}) {
// The port just added, so its name field can take focus.
const [freshIndex, setFreshIndex] = useState<number | null>(null)
const update = (index: number, patch: Partial<MessageSpec>) => {
const next = specs.map((spec, i) =>
i === index ? { ...spec, ...patch } : spec,
@@ -70,7 +166,10 @@ function PortList({
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
onClick={() => onChange([...specs, { name: "", dtype: "float" }])}
onClick={() => {
setFreshIndex(specs.length)
onChange([...specs, { name: "", dtype: "float" }])
}}
>
Add
</Button>
@@ -82,14 +181,12 @@ function PortList({
{specs.map((spec, index) => (
<div key={`port-${index}`} className="flex items-center gap-1.5">
<Input
<MessageNameInput
value={spec.name ?? ""}
suggestions={suggestions}
placeholder={`name in ${flow}`}
aria-label="Message name"
className="h-8 flex-1 font-mono text-sm"
onChange={(event) =>
update(index, { name: event.target.value, port: "" })
}
autoFocus={index === freshIndex}
onChange={(name) => update(index, { name, port: "" })}
/>
<Select
value={spec.dtype ?? "float"}
@@ -197,6 +294,7 @@ function PanelBody({
node,
flow,
nodeType,
suggestions,
onChange,
onSaveSource,
onClose,
@@ -205,6 +303,7 @@ function PanelBody({
node: NodeDef_Input
flow: string
nodeType: NodeTypeInfo | undefined
suggestions: PortSuggestions
onChange: (next: NodeDef_Input) => void
onSaveSource: (code: string) => void
onClose: () => void
@@ -269,6 +368,7 @@ function PanelBody({
specs={node.requires ?? []}
flow={flow}
emptyHint="Nothing yet. Add a message this node reads."
suggestions={suggestions.consumes}
onChange={(requires) => onChange({ ...node, requires })}
/>
<PortList
@@ -276,6 +376,7 @@ function PanelBody({
specs={node.provides ?? []}
flow={flow}
emptyHint="Nothing yet. Add a message this node publishes."
suggestions={suggestions.provides}
onChange={(provides) => onChange({ ...node, provides })}
/>
<ParamsForm
@@ -322,10 +423,14 @@ function PanelBody({
* Node settings, floating over the canvas so the graph stays visible and live.
* On a phone there is no room for that, so it becomes a full-screen sheet.
*/
/** Message names worth offering on each side of a node. */
export type PortSuggestions = { consumes: string[]; provides: string[] }
export function NodePanel({
node,
flow,
nodeTypes,
suggestions,
onChange,
onSaveSource,
onClose,
@@ -334,6 +439,7 @@ export function NodePanel({
node: NodeDef_Input | null
flow: string
nodeTypes: NodeTypeInfo[]
suggestions: PortSuggestions
onChange: (next: NodeDef_Input) => void
onSaveSource: (code: string) => void
onClose: () => void
@@ -368,6 +474,7 @@ export function NodePanel({
node={node}
flow={flow}
nodeType={nodeType}
suggestions={suggestions}
onChange={onChange}
onSaveSource={onSaveSource}
onClose={onClose}
@@ -397,6 +504,7 @@ export function NodePanel({
node={node}
flow={flow}
nodeType={nodeType}
suggestions={suggestions}
onChange={onChange}
onSaveSource={onSaveSource}
onClose={onClose}