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