From 2bea71db7f07316855e41cd323f21fdeede29ef9 Mon Sep 17 00:00:00 2001 From: stroblme Date: Thu, 15 Oct 2026 16:49:26 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx --- backend/fluksio/flow/messages.py | 19 +++ backend/fluksio/flow/panels.py | 10 +- backend/tests/flow/test_messages.py | 19 ++- docs/interface/dashboards.md | 5 +- .../src/components/Dashboard/PanelsDialog.tsx | 118 ++++++++++++++---- frontend/src/components/Flow/deriveEdges.ts | 13 ++ 6 files changed, 150 insertions(+), 34 deletions(-) diff --git a/backend/fluksio/flow/messages.py b/backend/fluksio/flow/messages.py index 3629e87..fc09483 100644 --- a/backend/fluksio/flow/messages.py +++ b/backend/fluksio/flow/messages.py @@ -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: diff --git a/backend/fluksio/flow/panels.py b/backend/fluksio/flow/panels.py index b573c99..26d4703 100644 --- a/backend/fluksio/flow/panels.py +++ b/backend/fluksio/flow/panels.py @@ -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): diff --git a/backend/tests/flow/test_messages.py b/backend/tests/flow/test_messages.py index 8081dd7..2e11862 100644 --- a/backend/tests/flow/test_messages.py +++ b/backend/tests/flow/test_messages.py @@ -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) diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 3c99957..86053db 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -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 diff --git a/frontend/src/components/Dashboard/PanelsDialog.tsx b/frontend/src/components/Dashboard/PanelsDialog.tsx index 4750035..373209e 100644 --- a/frontend/src/components/Dashboard/PanelsDialog.tsx +++ b/frontend/src/components/Dashboard/PanelsDialog.tsx @@ -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>({}) + 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 ( - + Panels @@ -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({ )} - +
void onChange: (next: PanelDef) => void }) { const io = panel.io ?? [] - const [draft, setDraft] = useState(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" })} > 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 (
- { - const port = portOf(event.currentTarget.value) - event.currentTarget.value = port || row.port - if (port && port !== row.port) onChange({ ...row, port }) - }} - /> + {onType ? ( + onType(portOf(event.currentTarget.value))} + onBlur={() => onChange(row)} + /> + ) : ( + { + const port = portOf(event.currentTarget.value) + event.currentTarget.value = port || row.port + if (port && port !== row.port) onChange({ ...row, port }) + }} + /> + )}
+ {problem ?

{problem}

: null}
) } diff --git a/frontend/src/components/Flow/deriveEdges.ts b/frontend/src/components/Flow/deriveEdges.ts index 6d4f028..07f5906 100644 --- a/frontend/src/components/Flow/deriveEdges.ts +++ b/frontend/src/components/Flow/deriveEdges.ts @@ -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]