Panels dialog saves a ready I/O port draft on close and keeps an unfinished one; one qualified message-name rule on both sides

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
2026-10-15 16:49:26 +02:00
co-authored by Claude Opus 5.5
parent 749393034d
commit 2bea71db7f
6 changed files with 150 additions and 34 deletions
@@ -18,6 +18,7 @@ import {
useSavePanels,
useUnpairPanel,
} from "@/components/Dashboard/queries"
import { qualifiedProblem } from "@/components/Flow/deriveEdges"
import { JsonField } from "@/components/Flow/JsonField"
import { InfoTip, PanelSection } from "@/components/Flow/SidePanel"
import { Button } from "@/components/ui/button"
@@ -60,6 +61,10 @@ const DIRECTIONS = [
const portOf = (value: string) =>
value.toLowerCase().replace(/[^a-z0-9_.]/g, "")
/** A new port the server will take: a name, and a message named with its flow. */
const ready = (port: PanelPort) =>
Boolean(port.port) && qualifiedProblem(port.message) === null
const objectOnly = (value: unknown) =>
value && typeof value === "object" && !Array.isArray(value)
? null
@@ -97,6 +102,17 @@ export function PanelsDialog({ focus }: { focus?: string } = {}) {
// Reading panels is any account's; every change to them is a superuser's.
const canEdit = Boolean(user?.is_superuser)
const [name, setName] = useState("")
// A port being added, per panel, until it is ready to save. Held here rather
// than in its row: the rows unmount when the dialog closes and this does
// not, so a port still missing its message is there again on reopening.
const [drafts, setDrafts] = useState<Record<string, PanelPort>>({})
const setDraft = (id: string, draft: PanelPort | null) =>
setDrafts((all) => {
const next = { ...all }
if (draft) next[id] = draft
else delete next[id]
return next
})
const panels = config?.panels ?? []
const known = dashboards?.data ?? []
@@ -122,6 +138,21 @@ export function PanelsDialog({ focus }: { focus?: string } = {}) {
const replace = (id: string, panel: PanelDef) =>
write({ panels: panels.map((p) => (p.id === id ? panel : p)) })
// Closing saves every new port that is ready, in one write. Its port field
// only commits on blur, and Escape closes the dialog without one.
const saveDrafts = () => {
const done = Object.keys(drafts).filter((id) => ready(drafts[id]))
if (!done.length) return
for (const id of done) setDraft(id, null)
write({
panels: panels.map((panel) =>
done.includes(panel.id)
? { ...panel, io: [...(panel.io ?? []), drafts[panel.id]] }
: panel,
),
})
}
const newId = slugify(name)
const taken = panels.some((panel) => panel.id === newId)
@@ -135,7 +166,10 @@ export function PanelsDialog({ focus }: { focus?: string } = {}) {
}, [focusLoaded, focus])
return (
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg">
<DialogContent
className="max-h-[80vh] overflow-y-auto sm:max-w-lg"
onCloseAutoFocus={saveDrafts}
>
<DialogHeader>
<DialogTitle>Panels</DialogTitle>
<DialogDescription>
@@ -156,6 +190,8 @@ export function PanelsDialog({ focus }: { focus?: string } = {}) {
key={panel.id}
panel={panel}
focused={panel.id === focus}
draft={drafts[panel.id] ?? null}
onDraft={(draft) => setDraft(panel.id, draft)}
host={host}
remoteHost={remoteHost}
canEdit={canEdit}
@@ -222,6 +258,8 @@ export function PanelsDialog({ focus }: { focus?: string } = {}) {
function PanelRow({
panel,
focused,
draft,
onDraft,
host,
remoteHost,
canEdit,
@@ -232,6 +270,9 @@ function PanelRow({
panel: PanelDef
/** The one a link arrived about, marked so it is found at a glance. */
focused: boolean
/** A port being added to it, held by the dialog. */
draft: PanelPort | null
onDraft: (draft: PanelPort | null) => void
/** Where this instance answers, as it knows itself. */
host: string
/** Where the portal serves this instance, when it is enrolled. */
@@ -411,7 +452,13 @@ function PanelRow({
</div>
)}
<PanelIo panel={panel} canEdit={canEdit} onChange={onChange} />
<PanelIo
panel={panel}
canEdit={canEdit}
draft={draft}
onDraft={onDraft}
onChange={onChange}
/>
<div className="grid gap-2">
<Input
@@ -517,26 +564,30 @@ function PanelRow({
* The device's own inputs and outputs, each wired to a message.
*
* Every change saves as it is made, like the rest of the dialog. A new port
* is held here until it has both a name and a message, since the server
* refuses a port without either.
* is held by the dialog until it has a name and a qualified message, since
* the server refuses a port without either, and closing the dialog saves one
* that has both.
*/
function PanelIo({
panel,
canEdit,
draft,
onDraft,
onChange,
}: {
panel: PanelDef
canEdit: boolean
draft: PanelPort | null
onDraft: (draft: PanelPort | null) => void
onChange: (next: PanelDef) => void
}) {
const io = panel.io ?? []
const [draft, setDraft] = useState<PanelPort | null>(null)
const listId = `ports-${panel.id}`
const write = (next: PanelPort[]) => onChange({ ...panel, io: next })
const keepDraft = (next: PanelPort) => {
if (!next.port || !next.message) return setDraft(next)
setDraft(null)
if (!ready(next)) return onDraft(next)
onDraft(null)
write([...io, next])
}
@@ -574,7 +625,8 @@ function PanelIo({
listId={listId}
testId={`io-${panel.id}-new`}
onChange={keepDraft}
onRemove={() => setDraft(null)}
onType={(port) => onDraft({ ...draft, port })}
onRemove={() => onDraft(null)}
/>
) : null}
{canEdit && !draft ? (
@@ -583,7 +635,7 @@ function PanelIo({
size="sm-touch"
className="justify-self-start"
data-testid={`add-port-${panel.id}`}
onClick={() => setDraft({ port: "", message: "", direction: "in" })}
onClick={() => onDraft({ port: "", message: "", direction: "in" })}
>
<Plus />
Add port
@@ -599,6 +651,7 @@ function IoRow({
listId,
testId,
onChange,
onType,
onRemove,
}: {
row: PanelPort
@@ -606,26 +659,44 @@ function IoRow({
listId: string
testId: string
onChange: (next: PanelPort) => void
/**
* A new port's name as it is typed, so closing the dialog before the field
* blurs still finds it. A saved port commits on blur instead.
*/
onType?: (port: string) => void
onRemove: () => void
}) {
const name = row.port || "new port"
const problem = row.message ? qualifiedProblem(row.message) : null
return (
<div className="grid gap-2" data-testid={testId}>
<div className="flex items-center gap-2">
<Input
// Remounted with whatever was saved, so a refused name snaps back.
key={row.port}
defaultValue={row.port}
placeholder="Port, e.g. gpio.4"
aria-label={`Port of ${name}`}
autoComplete="off"
list={listId}
onBlur={(event) => {
const port = portOf(event.currentTarget.value)
event.currentTarget.value = port || row.port
if (port && port !== row.port) onChange({ ...row, port })
}}
/>
{onType ? (
<Input
value={row.port}
placeholder="Port, e.g. gpio.4"
aria-label={`Port of ${name}`}
autoComplete="off"
list={listId}
onChange={(event) => onType(portOf(event.currentTarget.value))}
onBlur={() => onChange(row)}
/>
) : (
<Input
// Remounted with whatever was saved, so a refused name snaps back.
key={row.port}
defaultValue={row.port}
placeholder="Port, e.g. gpio.4"
aria-label={`Port of ${name}`}
autoComplete="off"
list={listId}
onBlur={(event) => {
const port = portOf(event.currentTarget.value)
event.currentTarget.value = port || row.port
if (port && port !== row.port) onChange({ ...row, port })
}}
/>
)}
<div className="shrink-0">
<Segmented
value={row.direction}
@@ -675,6 +746,7 @@ function IoRow({
}
/>
</div>
{problem ? <p className="text-xs text-destructive">{problem}</p> : null}
</div>
)
}
@@ -8,6 +8,19 @@ export function qualify(flow: string, name: string): string {
return name.includes(".") ? name : `${flow}.${name}`
}
/**
* A message named with its flow, as the server checks it wherever a name has
* to arrive qualified (`QUALIFIED_PATTERN` in `flow/messages.py`).
*/
const QUALIFIED_MESSAGE = /^[a-z][a-z0-9_]*(\.[A-Za-z0-9_]+)+$/
/** What is wrong with a name that has to be qualified, or null. */
export function qualifiedProblem(name: string): string | null {
return QUALIFIED_MESSAGE.test(name)
? null
: "Name the message as flow.name: a lowercase flow name, a dot, then letters, digits and underscores"
}
/** The flow a qualified message belongs to. */
export function flowOf(message: string): string {
return message.split(".", 1)[0]