Bump dashboards: bar, icon, forecast and clock widget types
Plumbing only: the widget-type literal and its dtype table on both sides, the regenerated client, a curated lucide map and four stubs the renderers are wired to. Also a latching switch and a segmented dropdown, both a `style` on the control that already publishes and reads back, plus the option editor a dropdown never had. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
This commit is contained in:
@@ -19,7 +19,11 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BarWidget } from "./BarWidget"
|
||||
import { ChartWidget } from "./ChartWidget"
|
||||
import { ClockWidget } from "./ClockWidget"
|
||||
import { ForecastWidget } from "./ForecastWidget"
|
||||
import { IconWidget } from "./IconWidget"
|
||||
import { usePublishMessage } from "./queries"
|
||||
|
||||
/** Widget types that put a value into the graph rather than read one. */
|
||||
@@ -50,6 +54,10 @@ export const WIDGET_DTYPES: Partial<Record<WidgetKind, string[]>> = {
|
||||
switch: ["bool"],
|
||||
agenda: ["list"],
|
||||
notification: ["record"],
|
||||
bar: ["float", "int"],
|
||||
forecast: ["list"],
|
||||
// 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. */
|
||||
@@ -65,6 +73,10 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
|
||||
markdown: "Text",
|
||||
agenda: "Agenda",
|
||||
notification: "Notification",
|
||||
bar: "Bar",
|
||||
icon: "Icon",
|
||||
forecast: "Forecast",
|
||||
clock: "Clock",
|
||||
button: "Button",
|
||||
switch: "Switch",
|
||||
slider: "Slider",
|
||||
@@ -80,6 +92,10 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
|
||||
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 },
|
||||
button: { w: 3, h: 2 },
|
||||
switch: { w: 3, h: 2 },
|
||||
slider: { w: 4, h: 2 },
|
||||
@@ -125,7 +141,8 @@ export const seriesOf = (widget: WidgetDef): Series[] =>
|
||||
* fetching the message catalogue first.
|
||||
*/
|
||||
export function widgetIssue(widget: WidgetDef): string | null {
|
||||
if (widget.type === "markdown") return null
|
||||
// Neither 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 === "chart" && cfg.source === "query") {
|
||||
@@ -167,6 +184,13 @@ export function widgetIssue(widget: WidgetDef): string | null {
|
||||
if (!acceptsDtype(widget.type, dtype)) {
|
||||
return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.`
|
||||
}
|
||||
// Only a bar nests a second reading, and an unrecorded type binds anything.
|
||||
if (!acceptsDtype(widget.type, text(cfg.inner_dtype) || undefined)) {
|
||||
return `${text(cfg.inner)} is a ${text(cfg.inner_dtype)}; a bar nests numbers.`
|
||||
}
|
||||
if (widget.type === "icon" && !(cfg.rules as unknown[] | undefined)?.length) {
|
||||
return "This icon has nothing mapped yet."
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -550,14 +574,33 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 cfg = config(widget)
|
||||
const { target, live, send } = usePublish(widget, dashboard)
|
||||
if (!target) return <Unbound />
|
||||
return (
|
||||
|
||||
const on = live?.value === true
|
||||
return cfg.style === "button" ? (
|
||||
<Button
|
||||
variant={on ? "default" : "secondary"}
|
||||
className="w-full"
|
||||
aria-pressed={on}
|
||||
aria-label={widget.title || target}
|
||||
onClick={() => send(!on)}
|
||||
>
|
||||
{on ? "On" : "Off"}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm">{live?.value === true ? "On" : "Off"}</span>
|
||||
<span className="text-sm">{on ? "On" : "Off"}</span>
|
||||
<Switch
|
||||
checked={live?.value === true}
|
||||
checked={on}
|
||||
aria-label={widget.title || target}
|
||||
onCheckedChange={(checked) => send(checked)}
|
||||
/>
|
||||
@@ -653,12 +696,47 @@ function InputWidget({ widget, dashboard }: WidgetProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 cfg = config(widget)
|
||||
const { target, live, send } = usePublish(widget, dashboard)
|
||||
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
||||
if (!target) return <Unbound />
|
||||
|
||||
if (cfg.style === "segmented") {
|
||||
return (
|
||||
// The one segmented shape: a single border pill, no dividers,
|
||||
// transparent segments, bg-accent on the selected one.
|
||||
<fieldset className="flex w-full items-center gap-1 rounded-full border border-border p-1">
|
||||
<legend className="sr-only">{widget.title || target}</legend>
|
||||
{options.map((option) => {
|
||||
const selected = text(option.value) === text(live?.value)
|
||||
return (
|
||||
<button
|
||||
key={text(option.value)}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => send(option.value)}
|
||||
className={cn(
|
||||
"h-11 min-w-0 flex-1 truncate rounded-full px-2.5 text-sm transition-colors md:h-8",
|
||||
selected
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{option.label ?? text(option.value)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="sr-only">{widget.title || target}</Label>
|
||||
@@ -700,6 +778,10 @@ const RENDERERS: Partial<
|
||||
markdown: MarkdownWidget,
|
||||
agenda: AgendaWidget,
|
||||
notification: NotificationWidget,
|
||||
bar: BarWidget,
|
||||
icon: IconWidget,
|
||||
forecast: ForecastWidget,
|
||||
clock: ClockWidget,
|
||||
button: ButtonWidget,
|
||||
switch: SwitchWidget,
|
||||
slider: SliderWidget,
|
||||
|
||||
Reference in New Issue
Block a user