Files
app/frontend/src/components/Dashboard/widgets.tsx
T

937 lines
31 KiB
TypeScript

import { TriangleAlert } from "lucide-react"
import { memo, useState } from "react"
import { ErrorBoundary } from "react-error-boundary"
import type { WidgetDef } from "@/client"
import { cn } from "@/lib/utils"
import { BarWidget } from "./BarWidget"
import { ChartWidget } from "./ChartWidget"
import { ClockWidget } from "./ClockWidget"
import { ColorWidget } from "./ColorWidget"
import { CounterWidget } from "./CounterWidget"
import { useBoundValue } from "./dataContext"
// The grid's own rules live beside the components that use them; CSS is
// chunked per entry, so the sheet is pulled in wherever a widget is drawn.
import "./dashboard.css"
import { ForecastWidget } from "./ForecastWidget"
import { IconWidget } from "./IconWidget"
import { MediaWidget } from "./MediaWidget"
import { PlayerWidget } from "./PlayerWidget"
import { usePublish } from "./publish"
import { useUi } from "./ui"
import { COLOR_DTYPES, colorFormatOf } from "./ui/core/color"
import {
buttonsOf,
config,
countersOf,
decimalsOf,
num,
rowsOf,
text,
} from "./ui/core/config"
/** Widget types that put a value into the graph rather than read one. */
export const INPUT_WIDGETS = new Set([
"button",
"buttons",
"switch",
"slider",
"input",
"dropdown",
"color",
"counter",
])
export type WidgetKind = WidgetDef["type"]
/**
* What a widget can be pointed at, by payload type.
*
* A switch that reads a float has nothing to show and nothing safe to send, so
* the pairing is part of the document rather than a matter of taste. The same
* table is enforced on the server (`app/flow/dashboards.py`); a type missing
* from it takes anything.
*/
export const WIDGET_DTYPES: Partial<Record<WidgetKind, string[]>> = {
gauge: ["float", "int"],
// A chart reading the engine's ring. One that queries binds a `series`
// answer and a `record` request instead, checked in `widgetIssue`.
chart: ["float", "int"],
slider: ["float", "int"],
// One number, the digits of which its counters are.
counter: ["float", "int"],
switch: ["bool"],
agenda: ["list"],
notification: ["record"],
bar: ["float", "int"],
forecast: ["list"],
// Either shape a colour can travel as; its `format` decides which of the two
// this widget means, which `widgetIssue` holds the binding to.
color: ["list", "str"],
// A camera frame, a clip, a segment. What it draws follows the type it is
// bound to; a plain artifact is taken as well, since the media type on the
// reference is what says what the bytes are.
media: ["image", "audio", "video", "artifact"],
// What a streamer says it is playing: title, artist, status, position and
// duration in one reading, because they are one thing and a player drawn
// from five separate messages would redraw itself five times.
player: ["record"],
// An icon maps weather strings, bool hints and numbers alike, and a clock
// binds nothing at all, so neither has a row to be held to.
}
/** Whether a message of this payload type may drive this kind of widget. */
export function acceptsDtype(kind: WidgetKind, dtype: string | undefined) {
const allowed = WIDGET_DTYPES[kind]
return !allowed || !dtype || allowed.includes(dtype)
}
export const WIDGET_LABELS: Record<WidgetKind, string> = {
stat: "Value",
gauge: "Gauge",
chart: "Chart",
markdown: "Text",
agenda: "Agenda",
notification: "Notification",
bar: "Bar",
icon: "Icon",
forecast: "Forecast",
clock: "Clock",
media: "Media",
player: "Player",
embed: "Website",
button: "Button",
buttons: "Buttons",
switch: "Switch",
slider: "Slider",
input: "Input",
dropdown: "Selector",
color: "Colour",
counter: "Counter",
}
/** One line per type for the add palette, which searches these too. */
export const WIDGET_DESCRIPTIONS: Record<WidgetKind, string> = {
stat: "A single reading, large",
gauge: "A number against its range",
chart: "Readings over time",
markdown: "Formatted text, headings and links",
agenda: "Upcoming events from a calendar list",
notification: "The latest published title and message",
bar: "Several readings, a bar each",
icon: "An icon picked by the value it reads",
forecast: "What comes next, as a strip of columns",
clock: "Local time and date",
media: "A camera image, clip or audio",
player: "What a streamer is playing",
embed: "Another web page, framed",
button: "Sends a value when pressed",
buttons: "A grid of buttons, each its own value",
switch: "Turns something on or off",
slider: "Sets a number by dragging",
input: "Sends what is typed",
dropdown: "Picks one of a list of options",
color: "Picks a colour or brightness",
counter: "Sets a number by its digits, such as a time",
}
/** Default footprint per type, in grid units. */
export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
stat: { w: 3, h: 2 },
gauge: { w: 3, h: 3 },
chart: { w: 6, h: 4 },
markdown: { w: 6, h: 2 },
agenda: { w: 4, h: 4 },
notification: { w: 4, h: 2 },
bar: { w: 4, h: 2 },
icon: { w: 2, h: 2 },
forecast: { w: 6, h: 2 },
clock: { w: 3, h: 2 },
media: { w: 4, h: 4 },
player: { w: 4, h: 3 },
embed: { w: 6, h: 4 },
button: { w: 3, h: 2 },
buttons: { w: 4, h: 3 },
switch: { w: 3, h: 2 },
slider: { w: 4, h: 2 },
input: { w: 4, h: 2 },
dropdown: { w: 4, h: 2 },
color: { w: 4, h: 4 },
counter: { w: 3, h: 3 },
}
/** One line of a chart, as the document stores it. */
export type Series = { message?: string; dtype?: string; label?: string }
export const seriesOf = (widget: WidgetDef): Series[] =>
(config(widget).series ?? []) as Series[]
/**
* What is wrong with this widget's wiring, if anything.
*
* Both halves are checked from the document alone — the picker records the
* payload type it bound — so a wall panel can flag a broken tile without
* fetching the message catalogue first.
*/
export function widgetIssue(widget: WidgetDef): string | null {
// None draws a message: a clock reads the wall, markdown its own text.
if (widget.type === "markdown" || widget.type === "clock") return null
const cfg = config(widget)
if (widget.type === "embed") {
return embedUrl(widget) ? null : "This widget has no address yet."
}
if (widget.type === "chart" && cfg.source === "runs") {
const runs = (cfg.runs ?? {}) as {
metric?: string
flow?: string
group?: string
ids?: string[]
}
if (!runs.metric) return "This chart names no run metric yet."
if (!runs.ids?.length && !runs.group && !runs.flow) {
return "Say which runs: a flow, a sweep, or run ids."
}
return null
}
if (widget.type === "chart" && cfg.source === "query") {
if (!text(cfg.request)) return "This chart does not ask for anything yet."
if (!text(cfg.message)) return "This chart has no answer to draw yet."
const answer = text(cfg.dtype)
if (answer && answer !== "series") {
return `${text(cfg.message)} is a ${answer}; a chart that queries draws a series.`
}
const asked = text(cfg.request_dtype)
if (asked && asked !== "record") {
return `${text(cfg.request)} is a ${asked}; a request is a record.`
}
return null
}
if (widget.type === "chart") {
const series = seriesOf(widget)
if (series.length === 0) return "This chart has no series yet."
const wrong = series.find(
(entry) => !entry.message || !acceptsDtype("chart", entry.dtype),
)
if (wrong) {
return wrong.message
? `${wrong.message} is a ${wrong.dtype}; a chart can only draw numbers.`
: "One of the series is not bound to a message."
}
return null
}
// A bar draws a row per reading, so it is judged row by row — the same way
// a chart is judged series by series.
if (widget.type === "bar") {
const rows = rowsOf(widget)
if (rows.length === 0) return "This bar has no rows yet."
const wrong = rows.find(
(row) => !row.message || !acceptsDtype("bar", row.dtype),
)
if (wrong) {
return wrong.message
? `${wrong.message} is a ${wrong.dtype}; a bar draws numbers.`
: "One of the rows is not bound to a message."
}
return null
}
// A media tile playing a camera's own stream binds no message: the browser
// fetches it from the source, and the engine is not in the way of it.
if (widget.type === "media" && text(cfg.stream_url) && !text(cfg.message)) {
return null
}
// A player is the one widget that both reads and publishes, so it is the one
// whose wiring is only half done when a single picker is filled in.
if (widget.type === "player" && text(cfg.message) && !text(cfg.target)) {
return "This player does not publish to a message yet."
}
// A grid with nothing in it publishes nothing, however well it is bound.
if (widget.type === "buttons" && !buttonsOf(widget).length) {
return "This grid has no buttons yet."
}
const input = INPUT_WIDGETS.has(widget.type)
const bound = text(cfg[input ? "target" : "message"])
if (!bound) {
return input
? "This control does not publish to a message yet."
: "This widget is not bound to a message yet."
}
const dtype = cfg.dtype === undefined ? undefined : text(cfg.dtype)
if (!acceptsDtype(widget.type, dtype)) {
return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.`
}
if (widget.type === "color") {
const want = COLOR_DTYPES[colorFormatOf(widget)]
if (dtype && dtype !== want) {
return `${bound} is a ${dtype}; this disc sends ${colorFormatOf(widget)}, which is a ${want}.`
}
}
if (widget.type === "icon" && !(cfg.rules as unknown[] | undefined)?.length) {
return "This icon has nothing mapped yet."
}
if (widget.type === "counter") {
const counters = countersOf(widget)
const name = (index: number) =>
counters[index].label || `Counter ${index + 1}`
// A size is what carries into the counter before it; without one there
// is nothing to carry at.
const unsized = counters.findIndex(
(counter, index) => index > 0 && !(Number(counter.size) >= 1),
)
if (unsized > 0) return `${name(unsized)} needs a size.`
const stalled = counters.findIndex(
(counter) => Number(counter.step ?? 1) < 1,
)
if (stalled >= 0) return `${name(stalled)} needs a step of 1 or more.`
}
return null
}
function Unbound() {
return <p className="text-muted-foreground">Pick a message.</p>
}
// ---------------------------------------------------------------------------
// Display
// ---------------------------------------------------------------------------
function StatWidget({ widget }: WidgetProps) {
const { Readout } = useUi()
const cfg = config(widget)
const message = text(cfg.message)
const live = useBoundValue(message || undefined)
if (!message) return <Unbound />
return (
<Readout
value={live?.value}
precision={cfg.precision === undefined ? null : num(cfg.precision, 1)}
unit={cfg.unit ? text(cfg.unit) : undefined}
/>
)
}
/**
* A dial, drawn as an arc.
*
* The number is always written out as well: a reading that only exists as an
* angle is unreadable to anyone who cannot judge one.
*/
function GaugeWidget({ widget }: WidgetProps) {
const { Gauge } = useUi()
const cfg = config(widget)
const message = text(cfg.message)
const live = useBoundValue(message || undefined)
if (!message) return <Unbound />
return (
<Gauge
value={typeof live?.value === "number" ? live.value : null}
min={num(cfg.min, 0)}
max={num(cfg.max, 100)}
precision={cfg.precision === undefined ? 1 : num(cfg.precision, 1)}
unit={cfg.unit ? text(cfg.unit) : undefined}
label={widget.title || message}
/>
)
}
/**
* A very small markdown subset, read a line at a time: headings and list items.
* Inline spans — bold, code, links — are not parsed and read as written.
*
* Enough for the labels and notes a dashboard carries, and not worth a parser.
*/
function MarkdownWidget({ widget }: WidgetProps) {
const content = text(config(widget).content)
const lines = content.split("\n")
return (
<div className="grid gap-1 break-words">
{lines.map((line, index) => {
const heading = /^(#{1,3})\s+(.*)$/.exec(line)
const body = heading ? heading[2] : line.replace(/^[-*]\s+/, "")
const bullet = !heading && /^[-*]\s+/.test(line)
return (
<p
// Plain text: position is the only identity a line has.
key={`line-${index}`}
className={cn(
heading?.[1] === "#" && "font-medium",
heading?.[1] === "##" && "font-medium",
heading?.[1] === "###" && "text-muted-foreground",
bullet && "pl-4",
)}
// Relative to the widget's own size, so an inline value rather
// than an arbitrary Tailwind class.
style={heading?.[1] === "#" ? { fontSize: "1.35em" } : undefined}
>
{bullet ? "• " : ""}
{body}
</p>
)
})}
</div>
)
}
/** One item of an agenda, as the `list` message declares it. */
type AgendaItem = { title: string; ts: number; all_day?: boolean }
const DAY_MS = 86_400_000
/**
* Which day something falls on, said the way a person would.
*
* Today and tomorrow by name, the rest of the week by weekday, and anything
* further out by date — past a week "Thursday" stops telling you which one.
*/
function dayLabel(when: Date, now: Date): string {
const midnight = new Date(now).setHours(0, 0, 0, 0)
const days = Math.floor(
(new Date(when).setHours(0, 0, 0, 0) - midnight) / DAY_MS,
)
if (days === 0) return "Today"
if (days === 1) return "Tomorrow"
if (days < 7) return when.toLocaleDateString(undefined, { weekday: "long" })
return when.toLocaleDateString()
}
/**
* What is coming up, from a `list` of items the message declares.
*
* The shape is the widget's contract rather than a path per binding: every
* item is `{title, ts}` with an optional `all_day`, so a flow answering with
* a calendar decides what an entry is called and this only has to draw it.
*/
function AgendaWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useBoundValue(message || undefined)
if (!message) return <Unbound />
const now = new Date()
const today = new Date(now).setHours(0, 0, 0, 0) / 1000
const items = (Array.isArray(live?.value) ? live.value : [])
.filter(
(item): item is AgendaItem =>
typeof item?.title === "string" && Number.isFinite(item?.ts),
)
.filter((item) => item.ts >= today)
.sort((a, b) => a.ts - b.ts)
.slice(0, num(cfg.count, 5))
if (items.length === 0) {
return <p className="text-muted-foreground">Nothing coming up.</p>
}
return (
// The frame centres a single reading in its card; a list reads from the
// top, so it takes the slack below it.
<ul className="mb-auto grid gap-1.5">
{items.map((item, index) => {
const when = new Date(item.ts * 1000)
return (
<li
// Two entries can share a title and a time; position is the identity.
key={`item-${index}`}
className="flex min-w-0 items-baseline gap-2"
>
<span className="shrink-0 text-muted-foreground tabular-nums">
{dayLabel(when, now)}
{item.all_day
? ""
: ` ${when.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}`}
</span>
<span className="truncate">{item.title}</span>
</li>
)
})}
</ul>
)
}
/**
* The last thing worth saying, held until something replaces it.
*
* No state of its own: the live store already keeps the latest value of a
* message, so what was published stays on the panel until the next one lands.
*/
function NotificationWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useBoundValue(message || undefined)
if (!message) return <Unbound />
const record = (live?.value ?? null) as Record<string, unknown> | null
const title = text(record?.title)
const body = text(record?.body)
if (!title && !body) {
return <p className="text-muted-foreground">Nothing to report.</p>
}
const failed = record?.severity === "error"
return (
<div className="grid gap-1 break-words">
{title ? (
<p
className={cn(
"flex items-baseline gap-1.5 font-medium",
failed && "text-destructive",
)}
>
{/* Never colour alone: a notice that went wrong says so in a word as
well as in red. */}
{failed ? <span className="sr-only">Error: </span> : null}
{failed ? <span aria-hidden>⚠</span> : null}
{title}
</p>
) : null}
{body ? <p className="text-muted-foreground">{body}</p> : null}
</div>
)
}
/**
* The page this widget embeds, or empty if it names none we will load.
*
* Only `http(s)`: an `<iframe src>` of `javascript:` or `data:` runs in this
* app's own origin, and a dashboard is a document several people can edit.
*/
export function embedUrl(widget: WidgetDef): string {
const url = text(config(widget).url).trim()
return /^https?:\/\//i.test(url) ? url : ""
}
/**
* Whether *url* is on *origin*: the one a trusted embed must not load.
*
* `allow-same-origin` lets a page keep its own origin, which for another site
* is only what visiting it would do. For a page on the origin this app runs on
* — its own, or the hub's when it is served through one — it means scripts
* that can reach into the app and read its session. An address that does not
* parse counts as one.
*/
export function onOrigin(url: string, origin: string): boolean {
try {
return new URL(url).origin === origin
} catch {
return true
}
}
/**
* Somebody else's page, drawn in the tile.
*
* Whatever the site serves — a weather map, a camera's own web UI, a timetable
* — with no binding of its own, so nothing has to be modelled as a message
* first. A site that refuses to be framed (`X-Frame-Options`) shows its own
* refusal inside the tile; that is between the browser and the site.
*
* By default the page runs in an opaque origin, with no cookies or storage of
* its own. **Trusted** keeps it in its own origin, which most interactive
* sites need to start (the demo's weather map renders an unstyled list
* without it), and is refused for this app's own origin.
*
* **Image** draws the address as a picture scaled to the tile instead: a
* camera's MJPEG stream or snapshot, which a frame would show at its native
* size. An image runs nothing, so it needs no sandbox.
*/
function EmbedWidget({ widget }: WidgetProps) {
const url = embedUrl(widget)
const trusted = config(widget).trusted === true
if (!url) return <p className="text-muted-foreground">Give it an address.</p>
if (config(widget).image === true) {
return (
<img
src={url}
alt={widget.title || url}
referrerPolicy="no-referrer"
className="h-full w-full rounded-md object-contain"
/>
)
}
if (trusted && onOrigin(url, window.location.origin)) {
return (
<p className="text-muted-foreground" data-testid="embed-refused">
This app's own address cannot be a trusted site.
</p>
)
}
return (
<iframe
// A changed sandbox applies from the next navigation only, so a toggle
// reloads the frame rather than leaving the old one running.
key={trusted ? "trusted" : "sandboxed"}
src={url}
title={widget.title || url}
sandbox={`allow-scripts allow-popups allow-forms${trusted ? " allow-same-origin" : ""}`}
referrerPolicy="no-referrer"
loading="lazy"
className="h-full w-full rounded-md border-0"
/>
)
}
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
function ButtonWidget({ widget, dashboard }: WidgetProps) {
const { Button } = useUi()
const cfg = config(widget)
const { target, send, pending, pulse, locked } = usePublish(widget, dashboard)
if (!target) return <Unbound />
// Nothing to hold: a button carries no reading, so the pulse and a refusal
// are the whole of its feedback.
return (
<>
{pulse}
<Button
variant="tonal"
disabled={pending || locked}
onClick={() => send(cfg.value ?? true)}
>
{text(cfg.label, widget.title || "Send")}
</Button>
</>
)
}
/**
* Several stateless presses, one message.
*
* A grid rather than one tile per press: six presets are six tiles to place
* and six titles to read, and what they have in common — where they publish —
* is then repeated six times. Nothing is read back, exactly as for a single
* button: what these send is an instruction, and the last one sent is not a
* state anything can be drawn as.
*/
function ButtonsWidget({ widget, dashboard }: WidgetProps) {
const { Button } = useUi()
const { target, send, pending, locked, pulse } = usePublish(widget, dashboard)
const buttons = buttonsOf(widget)
if (!target) return <Unbound />
return (
<>
{pulse}
<div className="widget-buttons">
{buttons.map((button, index) => (
<Button
// Two presses may share a label and a value; position is the
// identity, as it is for a chart's series and a bar's rows.
key={`button-${index}`}
variant="tonal"
disabled={pending || locked}
onClick={() => send(button.value ?? button.label ?? true)}
>
{text(button.label, text(button.value, "Send"))}
</Button>
))}
</div>
</>
)
}
/**
* A bool, published and read back — a latch either way it is drawn.
*
* `style: "button"` is a control that stays in rather than a track; both name
* the state in words, because a fill alone does not say what it means.
*/
function SwitchWidget({ widget, dashboard }: WidgetProps) {
const { Button, Switch } = useUi()
const cfg = config(widget)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
if (!target) return <Unbound />
const on = value === true
return cfg.style === "button" ? (
<>
{pulse}
<Button
variant={on ? "filled" : "tonal"}
pressed={on}
label={widget.title || target}
disabled={locked}
onClick={() => send(!on)}
>
{on ? "On" : "Off"}
</Button>
</>
) : (
<div className="flex items-center justify-between gap-2">
{pulse}
<span>{on ? "On" : "Off"}</span>
<Switch
checked={on}
label={widget.title || target}
disabled={locked}
onChange={(checked) => send(checked)}
/>
</div>
)
}
function SliderWidget({ widget, dashboard }: WidgetProps) {
const { Readout, Slider } = useUi()
const cfg = config(widget)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const min = num(cfg.min, 0)
// What it sets against: a room's temperature beside its setpoint.
const beside = (cfg.reading ?? {}) as Series
const read = useBoundValue(beside.message || undefined)
if (!target) return <Unbound />
const unit = cfg.unit ? text(cfg.unit) : undefined
const vertical = cfg.orientation === "vertical"
const reading = typeof value === "number" ? value : min
// Both the value and what it is set against are written to the step's
// decimals, as a round face writes them, so the two always match.
const digits = decimalsOf(num(cfg.step, 1))
const control = (
<Slider
value={reading}
min={min}
max={num(cfg.max, 100)}
step={num(cfg.step, 1)}
unit={unit}
orientation={vertical ? "vertical" : "horizontal"}
// On by default, because a slider without them says how far along it
// is and not what that means. Off is for a short tile: they are the
// last row of a control that has three, and on a seven-inch panel
// that row is the difference between fitting and being cut off. A
// column has no room for them at all.
ticks={cfg.ticks !== false}
mark={typeof read?.value === "number" ? read.value : null}
label={widget.title || target}
disabled={locked}
onCommit={send}
/>
)
const readout = (
<Readout value={reading} precision={digits} unit={unit} size="inline" />
)
// A line under the value, smaller, so the value being set is still the one
// that reads. In the slider's unit and to its step's decimals, as a round
// face writes it: the two are read against each other. Its mark on the
// track is the same reading, placed.
const against = beside.message ? (
<span
data-testid="slider-reading"
className="flex h-4 max-w-full items-center gap-1.5 text-xs"
>
{beside.label ? (
<span className="min-w-0 max-w-24 truncate text-muted-foreground">
{beside.label}
</span>
) : null}
{/* The label gives way first: the number is what the line is for. */}
<span className="shrink-0">
<Readout
value={read?.value}
precision={digits}
unit={unit}
size="inline"
/>
</span>
</span>
) : null
// A column takes the tile's height and puts the reading under it, which is
// what a fader looks like. A volume control is the case it exists for: it is
// reached for without looking, and up is louder.
if (vertical) {
return (
<div className="flex h-full min-h-0 flex-col items-center gap-2">
{pulse}
{control}
<div className="flex max-w-full shrink-0 flex-col items-center">
{readout}
{against}
</div>
</div>
)
}
return (
// Value beside the track rather than above it, the way a bar row reads —
// one row instead of two, and the tile keeps the height for the control.
<div className="flex items-start gap-3">
{pulse}
<div className="min-w-0 flex-1">{control}</div>
{/* The reading, if any, under the value: in the ticks' row when there
is one, so it costs the tile no height. Held to half the row, so a
long label in a small tile cannot squeeze the track to nothing. */}
<div
className={cn(
"flex shrink-0 flex-col items-end",
against && "max-w-1/2",
)}
>
{/* Held to the control's own height, so the value sits on the track's
midline whether or not there is a row of ticks under it. */}
<div className="flex h-[var(--dui-control)] items-center">
{readout}
</div>
{against}
</div>
</div>
)
}
function InputWidget({ widget, dashboard }: WidgetProps) {
const { Input } = useUi()
const cfg = config(widget)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const [draft, setDraft] = useState<string | null>(null)
if (!target) return <Unbound />
const asNumber = cfg.dtype === "float" || cfg.dtype === "int"
return (
<>
{pulse}
<Input
value={draft ?? text(value)}
type={asNumber ? "number" : "text"}
label={widget.title || target}
disabled={locked}
onChange={setDraft}
onCommit={() => {
if (draft === null) return
send(asNumber ? Number(draft) || 0 : draft)
setDraft(null)
}}
/>
</>
)
}
/**
* One of N, published and read back.
*
* `style: "segmented"` shows every choice at once with the active one held —
* the same exclusive group, drawn for a panel that is looked at across a room.
*/
function DropdownWidget({ widget, dashboard }: WidgetProps) {
const { Segmented, Select } = useUi()
const cfg = config(widget)
const { target, value, send, pulse, locked } = usePublish(widget, dashboard)
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
if (!target) return <Unbound />
if (cfg.style === "segmented") {
return (
<>
{pulse}
<Segmented
value={text(value)}
options={options.map(
(option) =>
[text(option.value), option.label ?? text(option.value)] as const,
)}
label={widget.title || target}
disabled={locked}
onChange={(picked) =>
send(options.find((o) => text(o.value) === picked)?.value ?? picked)
}
/>
</>
)
}
return (
<>
{pulse}
<Select
value={text(value)}
options={options}
label={widget.title || target}
disabled={locked}
onChange={send}
/>
</>
)
}
// ---------------------------------------------------------------------------
export type WidgetProps = { widget: WidgetDef; dashboard: string }
const RENDERERS: Partial<
Record<WidgetKind, (props: WidgetProps) => React.ReactNode>
> = {
stat: StatWidget,
gauge: GaugeWidget,
chart: ChartWidget,
markdown: MarkdownWidget,
agenda: AgendaWidget,
notification: NotificationWidget,
bar: BarWidget,
icon: IconWidget,
forecast: ForecastWidget,
clock: ClockWidget,
media: MediaWidget,
player: PlayerWidget,
embed: EmbedWidget,
button: ButtonWidget,
buttons: ButtonsWidget,
switch: SwitchWidget,
slider: SliderWidget,
input: InputWidget,
dropdown: DropdownWidget,
color: ColorWidget,
counter: CounterWidget,
}
function WidgetBodyComponent({ widget, dashboard }: WidgetProps) {
const Renderer = RENDERERS[widget.type]
if (!Renderer) {
return (
<p className="text-muted-foreground">
{WIDGET_LABELS[widget.type]} widgets are not drawn yet.
</p>
)
}
return (
// Per tile, because a dashboard is often the only thing on a screen
// nobody is standing at: one widget whose config the renderer cannot make
// sense of used to throw past every ancestor and leave a wall panel
// blank, with no navigation to recover from.
<ErrorBoundary fallback={<WidgetFailed />} resetKeys={[widget.id]}>
<Renderer widget={widget} dashboard={dashboard} />
</ErrorBoundary>
)
}
/**
* What a tile draws, held across a render of the grid around it.
*
* Both props stay put while the document does — a widget keeps its identity,
* and the dashboard is a name — so a grid re-rendered for reasons of its own no
* longer rebuilds every chart with it. Nothing a tile watches is held back by
* the boundary: its bound value, the look and the palette all reach it below
* here, through its own subscriptions.
*/
export const WidgetBody = memo(WidgetBodyComponent)
function WidgetFailed() {
return (
<p className="flex h-full items-center justify-center gap-2 text-muted-foreground text-sm">
<TriangleAlert className="size-4 shrink-0" aria-hidden />
This tile could not be drawn.
</p>
)
}