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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user