Panel I/O: PanelDef.io ports, scoped publish and upload, I/O section in the Panels dialog
This commit is contained in:
@@ -112,10 +112,15 @@ def _panel_writable(panel_id: str, request: Request) -> set[str]:
|
||||
panel displays and another controls stays writable, because the unlocked
|
||||
one is what entitles the screen to it. Reads are untouched — read-only is
|
||||
not blind, and a locked dashboard has to keep drawing live data.
|
||||
|
||||
The panel's own ``in`` ports are added outside the lock: it covers the
|
||||
controls on a dashboard, not the sensors on the device. Its ``out`` ports
|
||||
are read, not written, unless a widget targets them too.
|
||||
"""
|
||||
store: DashboardStore | None = getattr(request.app.state, "dashboard_store", None)
|
||||
if store is None:
|
||||
return set()
|
||||
writable = panels.io_in(panel_id)
|
||||
defns = panels.dashboards_for(panel_id, store)
|
||||
bound = sorted(
|
||||
{s.message for d in defns if (s := d.settings.get("locked")) and s.message}
|
||||
@@ -124,7 +129,6 @@ def _panel_writable(panel_id: str, request: Request) -> set[str]:
|
||||
request.app.state, "flow_controller", None
|
||||
)
|
||||
live = controller.state.get_present(bound) if bound and controller else {}
|
||||
writable: set[str] = set()
|
||||
for defn in defns:
|
||||
writable |= (
|
||||
panels.requests_of(defn)
|
||||
@@ -162,8 +166,10 @@ def _panel_may(payload: dict[str, Any], request: Request) -> None:
|
||||
A panel credential names the account that approved the pairing, so without
|
||||
this it would be that person's session hanging on a wall. What a panel
|
||||
genuinely needs is small and worth writing out: the dashboards it was
|
||||
assigned, its own definition, the messages its own widgets bind to, and the
|
||||
bytes those messages currently point at, for a tile drawing a camera frame.
|
||||
assigned, its own definition, the messages its own widgets and ports bind
|
||||
to, and the bytes those messages currently point at, for a tile drawing a
|
||||
camera frame — plus an upload named after one of its ``in`` ports, for a
|
||||
microphone whose recording is the value it publishes.
|
||||
|
||||
Publishing is in the list because a panel cannot be strictly read-only — a
|
||||
control on a panel is the point of putting one there, and a querying chart
|
||||
@@ -223,6 +229,10 @@ def _panel_may(payload: dict[str, Any], request: Request) -> None:
|
||||
# off an old run because it happens to know the digest.
|
||||
digest = unquote(path[len(f"{api}/artifacts/") :])
|
||||
allowed = "/" not in digest and digest in _panel_digests(panel.id, request)
|
||||
elif method == "PUT" and path == f"{api}/artifacts":
|
||||
# What the device's own sensor recorded — a microphone's utterance —
|
||||
# uploaded under the ``in`` message it is about to publish it to.
|
||||
allowed = request.query_params.get("name", "") in panels.io_in(panel.id)
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -12,8 +12,10 @@ which screen hangs where is the deployment's concern, not any one dashboard's.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
@@ -21,6 +23,43 @@ from fluksio.core.config import settings
|
||||
from fluksio.flow.dashboards import DashboardDef, DashboardNotFound, DashboardStore
|
||||
from fluksio.flow.schemas import _validate_name
|
||||
|
||||
#: Free text the device interprets: ``gpio.4``, ``backlight``, ``mic``.
|
||||
PORT_PATTERN = re.compile(r"^[a-z0-9_.]+$")
|
||||
|
||||
|
||||
class PanelPort(BaseModel):
|
||||
"""One piece of the device's own hardware, wired to a message.
|
||||
|
||||
A wall panel is a node as well as a screen: a button on its GPIO, a
|
||||
microphone, a speaker. Which ports exist is the device's business, so the
|
||||
name is free text and ``config`` is passed through to it unread.
|
||||
"""
|
||||
|
||||
port: str
|
||||
#: Qualified, ``flow.name`` — a flow still has to declare it, as for any
|
||||
#: widget, before a publish to it lands.
|
||||
message: str
|
||||
#: ``in`` is the device publishing into fluksio; ``out`` is the device
|
||||
#: following the message.
|
||||
direction: Literal["in", "out"]
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("port")
|
||||
@classmethod
|
||||
def _check_port(cls, value: str) -> str:
|
||||
if not PORT_PATTERN.fullmatch(value):
|
||||
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
|
||||
|
||||
|
||||
class PanelDef(BaseModel):
|
||||
"""One device, and what it shows."""
|
||||
@@ -36,6 +75,10 @@ class PanelDef(BaseModel):
|
||||
#: any dashboard: the same dashboard may hang on a hallway tablet and on a
|
||||
#: desk browser, and only one of them has fingers on it.
|
||||
touch: bool = False
|
||||
#: The device's own inputs and outputs. An ``in`` port may publish whether
|
||||
#: or not a dashboard is locked — a lock covers the controls on a
|
||||
#: dashboard, not the sensors on the wall — and an ``out`` port only reads.
|
||||
io: list[PanelPort] = Field(default_factory=list)
|
||||
#: Which generation of credential this panel honours. A token names the
|
||||
#: nonce it was minted at, so bumping this refuses the screen currently
|
||||
#: hanging here and leaves the panel, its dashboards and their arrangement
|
||||
@@ -151,12 +194,22 @@ def requests_of(defn: DashboardDef) -> set[str]:
|
||||
return {w.target for w in defn.widgets if w.type == "chart" and w.target}
|
||||
|
||||
|
||||
def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
|
||||
"""Every message this panel's dashboards read or write.
|
||||
def io_in(panel_id: str) -> set[str]:
|
||||
"""The messages this panel's own hardware publishes to."""
|
||||
panel = find(panel_id)
|
||||
if panel is None:
|
||||
return set()
|
||||
return {port.message for port in panel.io if port.direction == "in"}
|
||||
|
||||
What a screen is entitled to see, as its own dashboards define it, and
|
||||
empty for a panel that is gone — which is the same answer as "nothing".
|
||||
|
||||
def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
|
||||
"""Every message this panel's dashboards and its own I/O read or write.
|
||||
|
||||
What a screen is entitled to see, as its dashboards and ports define it,
|
||||
and empty for a panel that is gone — which is the same answer as "nothing".
|
||||
"""
|
||||
return {
|
||||
panel = find(panel_id)
|
||||
own = {port.message for port in panel.io} if panel else set()
|
||||
return own | {
|
||||
name for defn in dashboards_for(panel_id, store) for name in messages_of(defn)
|
||||
}
|
||||
|
||||
@@ -437,6 +437,84 @@ def test_a_locked_dashboard_entitles_a_panel_to_read_and_not_to_publish(
|
||||
)
|
||||
|
||||
|
||||
PANEL_IO = [
|
||||
{
|
||||
"port": "gpio.4",
|
||||
"message": "demo.doorbell",
|
||||
"direction": "in",
|
||||
"config": {"pull": "up", "debounce_ms": 50},
|
||||
},
|
||||
{"port": "backlight", "message": "demo.backlight", "direction": "out"},
|
||||
]
|
||||
|
||||
|
||||
def test_a_panels_io_is_saved_and_read_back(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
_panels(
|
||||
client,
|
||||
superuser_token_headers,
|
||||
{"panels": [{"id": "porch_io", "io": PANEL_IO}]},
|
||||
)
|
||||
read = client.get(f"{PREFIX}/porch_io", headers=superuser_token_headers).json()
|
||||
assert read["io"] == [PANEL_IO[0], {**PANEL_IO[1], "config": {}}]
|
||||
|
||||
|
||||
def test_a_panels_own_ports_publish_through_a_lock(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
"""An ``in`` port is the device's own sensor, which a dashboard lock is not about.
|
||||
|
||||
An ``out`` port is the device following a message, so it reads and does
|
||||
not write — and only an ``in`` port's name opens the artifact upload.
|
||||
"""
|
||||
_dashboard_with(client, superuser_token_headers, "panel_io_locked", PANEL_WIDGETS)
|
||||
_publish_settings(
|
||||
client, superuser_token_headers, "panel_io_locked", {"locked": {"value": True}}
|
||||
)
|
||||
_panels(
|
||||
client,
|
||||
superuser_token_headers,
|
||||
{"panels": [{"id": "door", "dashboards": ["panel_io_locked"], "io": PANEL_IO}]},
|
||||
)
|
||||
panel_headers = _pair(client, superuser_token_headers, "door")
|
||||
messages = f"{settings.API_V1_STR}/messages"
|
||||
|
||||
# 404: past the gate, refused by an engine no flow here declares it in.
|
||||
assert (
|
||||
client.post(
|
||||
f"{messages}/demo.doorbell", headers=panel_headers, json={"value": True}
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"{messages}/demo.backlight", headers=panel_headers, json={"value": 50}
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
client.get(
|
||||
f"{messages}/demo.backlight/history", headers=panel_headers
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
artifacts = f"{settings.API_V1_STR}/artifacts"
|
||||
for name, status in (
|
||||
("demo.doorbell", 200),
|
||||
("demo.backlight", 403),
|
||||
("demo.unrelated", 403),
|
||||
):
|
||||
uploaded = client.put(
|
||||
artifacts,
|
||||
headers=panel_headers,
|
||||
params={"name": name, "media_type": "audio/wav", "volatile": True},
|
||||
content=b"RIFF",
|
||||
)
|
||||
assert uploaded.status_code == status, name
|
||||
|
||||
|
||||
def test_a_panel_may_read_its_dashboards_own_settings(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
|
||||
@@ -188,12 +188,29 @@ Several dashboards get a navigation rail.
|
||||
|
||||
A panel credential permits its assigned dashboards, their bound messages and
|
||||
the artifacts they show. It also permits publishing from its controls and query
|
||||
charts. **Lock** changes the viewing surface; it does not narrow the credential.
|
||||
charts. On a locked dashboard only the query charts may still publish.
|
||||
|
||||
**Unpair screen** revokes a locally paired credential and keeps the panel's
|
||||
dashboard assignments. Deleting the panel removes the assignment and its
|
||||
access.
|
||||
|
||||
### 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:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `port` | The device's name for it, such as `gpio.4`, `backlight`, `screen`, `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 |
|
||||
| `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
|
||||
locked, and may upload an artifact named after one
|
||||
(`PUT /artifacts?name=<message>`), which is how a microphone sends a recording.
|
||||
`out` messages are read only.
|
||||
|
||||
!!! note "If the local link is wrong"
|
||||
|
||||
Pairing links use `FRONTEND_HOST`. Set it to the app address reachable
|
||||
|
||||
@@ -89,13 +89,14 @@ for (const theme of ["light", "dark"]) {
|
||||
|
||||
await captureFlows(page, dir)
|
||||
await captureDashboards(page, dir)
|
||||
await capturePanels(page, dir)
|
||||
await captureMedia(page, dir)
|
||||
await captureRuns(page, dir)
|
||||
await captureWorkers(page, dir)
|
||||
|
||||
await context.close()
|
||||
console.log(
|
||||
` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel,app-runs,app-run,app-run-context,app-workers}.png`,
|
||||
` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel,app-panels,app-runs,app-run,app-run-context,app-workers}.png`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -202,6 +203,17 @@ async function captureDashboards(page, dir) {
|
||||
await page.screenshot({ path: `${dir}/app-panel.png` })
|
||||
}
|
||||
|
||||
/** The panels dialog, scrolled to the first panel's I/O when there is one. */
|
||||
async function capturePanels(page, dir) {
|
||||
await page.goto(`${APP_URL}/dashboards?panels=true`, {
|
||||
waitUntil: "networkidle",
|
||||
})
|
||||
const io = page.locator("[data-testid^=add-port-]").first()
|
||||
if (await io.count()) await io.scrollIntoViewIfNeeded()
|
||||
await page.waitForTimeout(1000)
|
||||
await page.screenshot({ path: `${dir}/app-panels.png` })
|
||||
}
|
||||
|
||||
/**
|
||||
* A media tile drawing what a camera published, where there is one.
|
||||
*
|
||||
|
||||
@@ -2863,6 +2863,13 @@ export const PanelDefSchema = {
|
||||
title: 'Touch',
|
||||
default: false
|
||||
},
|
||||
io: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/PanelPort'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Io'
|
||||
},
|
||||
nonce: {
|
||||
type: 'integer',
|
||||
title: 'Nonce',
|
||||
@@ -2875,6 +2882,37 @@ export const PanelDefSchema = {
|
||||
description: 'One device, and what it shows.'
|
||||
} as const;
|
||||
|
||||
export const PanelPortSchema = {
|
||||
properties: {
|
||||
port: {
|
||||
type: 'string',
|
||||
title: 'Port'
|
||||
},
|
||||
message: {
|
||||
type: 'string',
|
||||
title: 'Message'
|
||||
},
|
||||
direction: {
|
||||
type: 'string',
|
||||
enum: ['in', 'out'],
|
||||
title: 'Direction'
|
||||
},
|
||||
config: {
|
||||
additionalProperties: true,
|
||||
type: 'object',
|
||||
title: 'Config'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['port', 'message', 'direction'],
|
||||
title: 'PanelPort',
|
||||
description: `One piece of the device's own hardware, wired to a message.
|
||||
|
||||
A wall panel is a node as well as a screen: a button on its GPIO, a
|
||||
microphone, a speaker. Which ports exist is the device's business, so the
|
||||
name is free text and \`\`config\`\` is passed through to it unread.`
|
||||
} as const;
|
||||
|
||||
export const PanelsConfigSchema = {
|
||||
properties: {
|
||||
panels: {
|
||||
|
||||
@@ -962,9 +962,28 @@ export type PanelDef = {
|
||||
title?: string;
|
||||
dashboards?: Array<(string)>;
|
||||
touch?: boolean;
|
||||
io?: Array<PanelPort>;
|
||||
nonce?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* One piece of the device's own hardware, wired to a message.
|
||||
*
|
||||
* A wall panel is a node as well as a screen: a button on its GPIO, a
|
||||
* microphone, a speaker. Which ports exist is the device's business, so the
|
||||
* name is free text and ``config`` is passed through to it unread.
|
||||
*/
|
||||
export type PanelPort = {
|
||||
port: string;
|
||||
message: string;
|
||||
direction: 'in' | 'out';
|
||||
config?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type direction = 'in' | 'out';
|
||||
|
||||
/**
|
||||
* Every panel this instance knows about.
|
||||
*/
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { Plus, Trash2, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
type ApiError,
|
||||
CloudService,
|
||||
type PanelDef,
|
||||
type PanelPort,
|
||||
type PanelsConfig,
|
||||
PanelsService,
|
||||
} from "@/client"
|
||||
import { initials, resolveIcon } from "@/components/Dashboard/icons"
|
||||
import { MessagePicker } from "@/components/Dashboard/panels"
|
||||
import {
|
||||
dashboardsQueryOptions,
|
||||
panelsQueryOptions,
|
||||
useSavePanels,
|
||||
useUnpairPanel,
|
||||
} from "@/components/Dashboard/queries"
|
||||
import { InfoTip } from "@/components/Flow/SidePanel"
|
||||
import { JsonField } from "@/components/Flow/JsonField"
|
||||
import { InfoTip, PanelSection } from "@/components/Flow/SidePanel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Segmented } from "@/components/ui/segmented"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
@@ -43,6 +47,31 @@ const slugify = (value: string) =>
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
|
||||
/** Port names the panel firmware knows; `gpio.` takes a pin number. */
|
||||
// ponytail: free-text ports, no device capability report; add PUT /panels/{id}/device when a second device kind exists
|
||||
const PORT_SUGGESTIONS = [
|
||||
"gpio.",
|
||||
"backlight",
|
||||
"screen",
|
||||
"touch",
|
||||
"mic",
|
||||
"speaker",
|
||||
]
|
||||
|
||||
const DIRECTIONS = [
|
||||
["in", "In"],
|
||||
["out", "Out"],
|
||||
] as const
|
||||
|
||||
/** What the server accepts as a port name. */
|
||||
const portOf = (value: string) =>
|
||||
value.toLowerCase().replace(/[^a-z0-9_.]/g, "")
|
||||
|
||||
const objectOnly = (value: unknown) =>
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? null
|
||||
: "Config is a JSON object"
|
||||
|
||||
/**
|
||||
* Which dashboards hang on which screen, and adopting the screens themselves.
|
||||
*
|
||||
@@ -361,6 +390,8 @@ function PanelRow({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PanelIo panel={panel} canEdit={canEdit} onChange={onChange} />
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
@@ -460,3 +491,169 @@ function PanelRow({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function PanelIo({
|
||||
panel,
|
||||
canEdit,
|
||||
onChange,
|
||||
}: {
|
||||
panel: PanelDef
|
||||
canEdit: boolean
|
||||
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)
|
||||
write([...io, next])
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelSection
|
||||
title="I/O"
|
||||
help="Hardware on the device itself, such as a GPIO pin or its microphone. In publishes to the message, Out follows it, and Config goes to the device as it is."
|
||||
>
|
||||
<datalist id={listId}>
|
||||
{PORT_SUGGESTIONS.map((port) => (
|
||||
<option key={port} value={port} />
|
||||
))}
|
||||
</datalist>
|
||||
{/* `disabled` on a fieldset reaches every control inside it. */}
|
||||
<fieldset disabled={!canEdit} className="grid min-w-0 gap-4">
|
||||
{io.length === 0 && !draft ? (
|
||||
<p className="text-sm text-muted-foreground">No ports yet.</p>
|
||||
) : null}
|
||||
{io.map((row, index) => (
|
||||
<IoRow
|
||||
// Ports have no id, and two may share a name until one is renamed.
|
||||
key={index}
|
||||
row={row}
|
||||
listId={listId}
|
||||
testId={`io-${panel.id}-${index}`}
|
||||
onChange={(next) =>
|
||||
write(io.map((one, at) => (at === index ? next : one)))
|
||||
}
|
||||
onRemove={() => write(io.filter((_, at) => at !== index))}
|
||||
/>
|
||||
))}
|
||||
{draft ? (
|
||||
<IoRow
|
||||
row={draft}
|
||||
listId={listId}
|
||||
testId={`io-${panel.id}-new`}
|
||||
onChange={keepDraft}
|
||||
onRemove={() => setDraft(null)}
|
||||
/>
|
||||
) : null}
|
||||
{canEdit && !draft ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm-touch"
|
||||
className="justify-self-start"
|
||||
data-testid={`add-port-${panel.id}`}
|
||||
onClick={() => setDraft({ port: "", message: "", direction: "in" })}
|
||||
>
|
||||
<Plus />
|
||||
Add port
|
||||
</Button>
|
||||
) : null}
|
||||
</fieldset>
|
||||
</PanelSection>
|
||||
)
|
||||
}
|
||||
|
||||
function IoRow({
|
||||
row,
|
||||
listId,
|
||||
testId,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
row: PanelPort
|
||||
/** The datalist of port names to suggest. */
|
||||
listId: string
|
||||
testId: string
|
||||
onChange: (next: PanelPort) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const name = row.port || "new port"
|
||||
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 })
|
||||
}}
|
||||
/>
|
||||
<div className="shrink-0">
|
||||
<Segmented
|
||||
value={row.direction}
|
||||
options={DIRECTIONS}
|
||||
label={`Direction of ${name}`}
|
||||
onChange={(direction) =>
|
||||
onChange({
|
||||
...row,
|
||||
direction: direction as PanelPort["direction"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-touch"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label={`Remove ${name}`}
|
||||
title={`Remove ${name}`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<MessagePicker
|
||||
value={row.message}
|
||||
label={`Message of ${name}`}
|
||||
hideLabel
|
||||
// A port the device publishes from needs a message a flow takes in.
|
||||
filter={
|
||||
row.direction === "in"
|
||||
? (message) => message.writable !== false
|
||||
: undefined
|
||||
}
|
||||
onPick={(message) => onChange({ ...row, message })}
|
||||
/>
|
||||
</div>
|
||||
<JsonField
|
||||
value={row.config ?? {}}
|
||||
label={`Config of ${name}`}
|
||||
check={objectOnly}
|
||||
className="h-9 w-28 shrink-0"
|
||||
onChange={(config) =>
|
||||
onChange({ ...row, config: config as PanelPort["config"] })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -111,10 +111,11 @@ function choicesFor(kind: WidgetKind, catalog: MessageInfo[]): MessageInfo[] {
|
||||
* Searchable, as the flow editor's message fields are: the catalog is every
|
||||
* message every flow declares.
|
||||
*/
|
||||
function MessagePicker({
|
||||
export function MessagePicker({
|
||||
kind,
|
||||
value,
|
||||
label,
|
||||
hideLabel = false,
|
||||
testId,
|
||||
placeholder = "Pick a message",
|
||||
filter,
|
||||
@@ -125,6 +126,8 @@ function MessagePicker({
|
||||
kind?: WidgetKind
|
||||
value: string
|
||||
label: string
|
||||
/** Keeps the caption for screen readers only, in a row that is its own caption. */
|
||||
hideLabel?: boolean
|
||||
testId?: string
|
||||
placeholder?: string
|
||||
/** What this slot takes, when the widget's own type does not decide it —
|
||||
@@ -143,7 +146,9 @@ function MessagePicker({
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-sm font-normal">{label}</Label>
|
||||
<Label className={cn("text-sm font-normal", hideLabel && "sr-only")}>
|
||||
{label}
|
||||
</Label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user