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>