Panel I/O: PanelDef.io ports, scoped publish and upload, I/O section in the Panels dialog
This commit is contained in:
@@ -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