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

This commit is contained in:
2026-10-15 16:49:26 +02:00
parent 9836f3ff5f
commit fa8b178866
6 changed files with 150 additions and 34 deletions
+19
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import json
import math
import re
from enum import Enum
from typing import Any
@@ -320,6 +321,24 @@ class MessageSpec(BaseModel):
return hash((self.name, self.port))
#: A message named with its flow: the flow's own name, then the name inside it.
#: The one rule for anywhere a name has to arrive qualified; the frontend holds
#: a copy (``QUALIFIED_MESSAGE`` in ``deriveEdges.ts``).
QUALIFIED_PATTERN = re.compile(r"[a-z][a-z0-9_]*(\.[A-Za-z0-9_]+)+")
QUALIFIED_RULE = (
"Name the message as flow.name: a lowercase flow name, a dot, "
"then letters, digits and underscores"
)
def validate_qualified(value: str) -> str:
"""A qualified message name, or a ``ValueError`` saying what one looks like."""
if not QUALIFIED_PATTERN.fullmatch(value):
raise ValueError(QUALIFIED_RULE)
return value
def qualify(flow: str, name: str) -> str:
"""Resolve a message name against its flow namespace."""
if not name:
+2 -8
View File
@@ -21,6 +21,7 @@ from pydantic import BaseModel, Field, field_validator
from fluksio.core.config import settings
from fluksio.flow.dashboards import DashboardDef, DashboardNotFound, DashboardStore
from fluksio.flow.messages import validate_qualified
from fluksio.flow.schemas import _validate_name
#: Free text the device interprets: ``gpio.4``, ``backlight``, ``mic``.
@@ -51,14 +52,7 @@ class PanelPort(BaseModel):
raise ValueError("Use lowercase letters, digits, underscores and dots")
return value
@field_validator("message")
@classmethod
def _check_message(cls, value: str) -> str:
flow, _, name = value.partition(".")
if not name:
raise ValueError("Name the message with its flow, as flow.name")
_validate_name(flow)
return value
_check_message = field_validator("message")(validate_qualified)
class PanelDef(BaseModel):
+18 -1
View File
@@ -2,7 +2,13 @@ import json
import pytest
from fluksio.flow.messages import DType, MessageSpec, flow_of, qualify
from fluksio.flow.messages import (
DType,
MessageSpec,
flow_of,
qualify,
validate_qualified,
)
def test_port_defaults_to_last_name_segment():
@@ -201,3 +207,14 @@ def test_a_value_that_refers_to_itself_does_not_hang_the_check():
loop: dict = {}
loop["self"] = loop
spec.check(loop)
@pytest.mark.parametrize("name", ["home.doorbell", "house.kitchen.temperature"])
def test_a_qualified_name_names_its_flow(name):
assert validate_qualified(name) == name
@pytest.mark.parametrize("name", ["doorbell", "Home.x", "home.", "home.a-b", "home..x"])
def test_anything_else_is_refused_with_the_rule(name):
with pytest.raises(ValueError, match="flow.name"):
validate_qualified(name)
+3 -2
View File
@@ -425,13 +425,14 @@ screen serves none of them.
### The device's own I/O
A panel can also act as a node. Under **I/O**, wire a port on the device to a
message:
message. A new port saves once it has a port name and a message, at the latest
when the dialog closes:
| Field | Meaning |
|---|---|
| `port` | The device's name for it, such as `gpio.4`, `backlight`, `touch`, `mic` or `speaker`. Lowercase letters, digits, `_` and `.` |
| `direction` | `in`: the device publishes to the message. `out`: the device follows it |
| `message` | A qualified `flow.name`. A flow must declare it, as for a control |
| `message` | A qualified `flow.name`, a lowercase flow name, a dot, then letters, digits and underscores. A flow must declare it, as for a control |
| `config` | A JSON object passed to the device unread, such as `{"pull": "up", "debounce_ms": 50}` |
The credential may publish to its `in` messages even when a dashboard is
@@ -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]