123 lines
4.9 KiB
TypeScript
123 lines
4.9 KiB
TypeScript
import { useEffect, useState } from "react"
|
|
|
|
import type { ApiError, WidgetDef } from "@/client"
|
|
import { useLiveValue } from "@/components/Flow/liveStore"
|
|
import useCustomToast from "@/hooks/useCustomToast"
|
|
import { handleError } from "@/utils"
|
|
// The transmit ring's rule lives with the dashboard's own geometry, and CSS is
|
|
// chunked per entry — so the sheet is pulled in wherever the pulse is drawn.
|
|
import "./ui/core/core.css"
|
|
import { useDataContext } from "./dataContext"
|
|
import { usePublishMessage } from "./queries"
|
|
import { useLocked } from "./settings"
|
|
|
|
/**
|
|
* How long a control shows what it sent before falling back to the engine.
|
|
*
|
|
* ponytail: a flat 3 s rather than anything the engine tells us. A publish the
|
|
* server takes but nothing ever echoes — a message no flow consumes — would
|
|
* otherwise leave the tile holding a value that is not the truth, forever.
|
|
*/
|
|
const HOLD_MS = 3000
|
|
|
|
/** Whether what came back over the socket is what this control sent. */
|
|
function confirms(live: unknown, sent: unknown): boolean {
|
|
// Readings are scalars nearly always; a dropdown may carry a record and a
|
|
// colour wheel an array, and comparing those as text is cheaper than a deep
|
|
// walk for the same answer.
|
|
return live === sent || JSON.stringify(live) === JSON.stringify(sent)
|
|
}
|
|
|
|
/**
|
|
* Publishing, with the value shown as sent until the engine confirms it.
|
|
*
|
|
* A control publishes over HTTP and reads the result back over the socket, so
|
|
* between the two the live value is still the old one — a handle let go of
|
|
* would snap back to it. The hold ends when the echo matches, when the publish
|
|
* is refused, or on `HOLD_MS`; success is silent, because the echo is the
|
|
* confirmation.
|
|
*
|
|
* A locked dashboard is gated here and only here: every control publishes
|
|
* through this hook, so one check covers all of them and a control added later
|
|
* inherits it. `locked` is handed back so each control can also *read* as
|
|
* disabled — a dashboard that silently swallows a press looks broken rather
|
|
* than locked.
|
|
*
|
|
* ponytail: this is a read-only surface, not an authorisation boundary. The
|
|
* server still takes a publish from a panel credential whose dashboard says
|
|
* locked, because the credential's own allowlist is what bounds it. Making it
|
|
* a real lock means carrying the flag into `_panel_may`.
|
|
*
|
|
* Its own module rather than `widgets.tsx`, which every widget file is
|
|
* imported *by*: a control drawn in a file of its own can only reach this
|
|
* without closing that circle if it does not sit there.
|
|
*/
|
|
export function usePublish(widget: WidgetDef, dashboard: string) {
|
|
const cfg = (widget.config ?? {}) as Record<string, unknown>
|
|
const target = cfg.target == null ? "" : String(cfg.target)
|
|
// A dashboard opened against a run or a replay is a picture of something
|
|
// that already happened; there is nothing for a control to publish to.
|
|
const context = useDataContext()
|
|
const locked = useLocked() || Boolean(context?.readOnly)
|
|
const publish = usePublishMessage()
|
|
const live = useLiveValue(target || undefined)
|
|
const { showErrorToast } = useCustomToast()
|
|
// Boxed: holding `false` or `null` is not the same as holding nothing.
|
|
const [held, setHeld] = useState<{ value: unknown } | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!held) return
|
|
const timer = setTimeout(() => setHeld(null), HOLD_MS)
|
|
return () => clearTimeout(timer)
|
|
}, [held])
|
|
|
|
useEffect(() => {
|
|
if (held && confirms(live?.value, held.value)) setHeld(null)
|
|
}, [held, live])
|
|
|
|
return {
|
|
target,
|
|
/** What the control draws: what it sent, until the engine answers. */
|
|
value: held ? held.value : live?.value,
|
|
/** Whether this dashboard is read-only; controls draw themselves disabled. */
|
|
locked,
|
|
send: (value: unknown) => {
|
|
if (!target || locked) return
|
|
setHeld({ value })
|
|
publish.mutate(
|
|
{
|
|
name: target,
|
|
value,
|
|
dashboard,
|
|
widget: widget.id,
|
|
label: widget.title || widget.id,
|
|
kind: widget.type,
|
|
},
|
|
{
|
|
onError: (error) => {
|
|
// Back to the engine's own value, and say which message refused it
|
|
// — a panel showing several controls cannot tell them apart.
|
|
setHeld(null)
|
|
handleError.call(
|
|
(detail: string) => showErrorToast(`${target}: ${detail}`),
|
|
error as ApiError,
|
|
)
|
|
},
|
|
},
|
|
)
|
|
},
|
|
/**
|
|
* The in-flight pulse, drawn as a ring around the whole tile.
|
|
*
|
|
* A marker rather than the ring itself: the body a control sits in is a
|
|
* query container, which is a containing block for anything absolute
|
|
* inside it and clips besides — so the frame draws the ring when it sees
|
|
* this, and a control need only say that it is publishing.
|
|
*/
|
|
pulse: publish.isPending ? (
|
|
<span aria-hidden className="widget-transmit" />
|
|
) : null,
|
|
pending: publish.isPending,
|
|
}
|
|
}
|