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:
@@ -42,6 +42,10 @@ WidgetType = Literal[
|
|||||||
"markdown",
|
"markdown",
|
||||||
"agenda",
|
"agenda",
|
||||||
"notification",
|
"notification",
|
||||||
|
"bar",
|
||||||
|
"icon",
|
||||||
|
"forecast",
|
||||||
|
"clock",
|
||||||
# Input
|
# Input
|
||||||
"button",
|
"button",
|
||||||
"switch",
|
"switch",
|
||||||
@@ -66,6 +70,10 @@ WIDGET_DTYPES: dict[str, set[str]] = {
|
|||||||
"switch": {"bool"},
|
"switch": {"bool"},
|
||||||
"agenda": {"list"},
|
"agenda": {"list"},
|
||||||
"notification": {"record"},
|
"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.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -124,7 +132,8 @@ class WidgetDef(BaseModel):
|
|||||||
if series.get("message")
|
if series.get("message")
|
||||||
]
|
]
|
||||||
name = self.config.get("message")
|
name = self.config.get("message")
|
||||||
return [str(name)] if name else []
|
inner = self.config.get("inner") # only a bar nests a second reading
|
||||||
|
return [str(value) for value in (name, inner) if value]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def target(self) -> str:
|
def target(self) -> str:
|
||||||
@@ -161,7 +170,7 @@ class WidgetDef(BaseModel):
|
|||||||
str(series.get("dtype") or "")
|
str(series.get("dtype") or "")
|
||||||
for series in self.config.get("series") or []
|
for series in self.config.get("series") or []
|
||||||
]
|
]
|
||||||
return [str(self.config.get("dtype") or "")]
|
return [str(self.config.get(key) or "") for key in ("dtype", "inner_dtype")]
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _check_binding(self) -> WidgetDef:
|
def _check_binding(self) -> WidgetDef:
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ def test_a_widget_refuses_a_dtype_it_cannot_carry():
|
|||||||
def test_the_structured_widgets_bind_their_shapes():
|
def test_the_structured_widgets_bind_their_shapes():
|
||||||
WidgetDef(id="a", type="agenda", config={"message": "a.b", "dtype": "list"})
|
WidgetDef(id="a", type="agenda", config={"message": "a.b", "dtype": "list"})
|
||||||
WidgetDef(id="n", type="notification", config={"message": "a.b", "dtype": "record"})
|
WidgetDef(id="n", type="notification", config={"message": "a.b", "dtype": "record"})
|
||||||
|
WidgetDef(id="f", type="forecast", config={"message": "a.b", "dtype": "list"})
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
WidgetDef(id="a", type="agenda", config={"message": "a.b", "dtype": "json"})
|
WidgetDef(id="a", type="agenda", config={"message": "a.b", "dtype": "json"})
|
||||||
@@ -220,6 +221,41 @@ def test_the_structured_widgets_bind_their_shapes():
|
|||||||
WidgetDef(
|
WidgetDef(
|
||||||
id="n", type="notification", config={"message": "a.b", "dtype": "list"}
|
id="n", type="notification", config={"message": "a.b", "dtype": "list"}
|
||||||
)
|
)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
WidgetDef(id="f", type="forecast", config={"message": "a.b", "dtype": "json"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bar_nests_a_second_number():
|
||||||
|
WidgetDef(
|
||||||
|
id="b",
|
||||||
|
type="bar",
|
||||||
|
config={
|
||||||
|
"message": "a.in",
|
||||||
|
"dtype": "float",
|
||||||
|
"inner": "a.pv",
|
||||||
|
"inner_dtype": "int",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
WidgetDef(
|
||||||
|
id="b",
|
||||||
|
type="bar",
|
||||||
|
config={"message": "a.in", "dtype": "float", "inner_dtype": "bool"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bar_is_drawn_on_both_readings_it_nests():
|
||||||
|
widget = WidgetDef(id="b", type="bar", config={"message": "a.in", "inner": "a.pv"})
|
||||||
|
|
||||||
|
assert widget.messages == ["a.in", "a.pv"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_clock_reads_nothing_and_publishes_nothing():
|
||||||
|
widget = WidgetDef(id="c", type="clock", config={"format": "24h"})
|
||||||
|
|
||||||
|
assert widget.messages == []
|
||||||
|
assert widget.target == ""
|
||||||
|
|
||||||
|
|
||||||
def test_a_querying_chart_asks_with_a_record_and_draws_a_series():
|
def test_a_querying_chart_asks_with_a_record_and_draws_a_series():
|
||||||
|
|||||||
@@ -2907,7 +2907,7 @@ export const WidgetDefSchema = {
|
|||||||
},
|
},
|
||||||
type: {
|
type: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'button', 'switch', 'slider', 'input', 'dropdown'],
|
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'button', 'switch', 'slider', 'input', 'dropdown'],
|
||||||
title: 'Type'
|
title: 'Type'
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
|
|||||||
@@ -967,7 +967,7 @@ export type ValidationResult = {
|
|||||||
*/
|
*/
|
||||||
export type WidgetDef = {
|
export type WidgetDef = {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
|
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
|
||||||
title?: string;
|
title?: string;
|
||||||
layout?: {
|
layout?: {
|
||||||
[key: string]: Placement;
|
[key: string]: Placement;
|
||||||
@@ -977,7 +977,7 @@ export type WidgetDef = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
|
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
|
||||||
|
|
||||||
export type WorkerInfo = {
|
export type WorkerInfo = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { WidgetProps } from "./widgets"
|
||||||
|
|
||||||
|
/** A bar — a stub until the bar widget is written. */
|
||||||
|
export function BarWidget(_props: WidgetProps) {
|
||||||
|
return <p className="text-sm text-muted-foreground">—</p>
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { WidgetProps } from "./widgets"
|
||||||
|
|
||||||
|
/** A clock — a stub until the clock widget is written. */
|
||||||
|
export function ClockWidget(_props: WidgetProps) {
|
||||||
|
return <p className="text-sm text-muted-foreground">—</p>
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { WidgetProps } from "./widgets"
|
||||||
|
|
||||||
|
/** A forecast — a stub until the forecast widget is written. */
|
||||||
|
export function ForecastWidget(_props: WidgetProps) {
|
||||||
|
return <p className="text-sm text-muted-foreground">—</p>
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { WidgetProps } from "./widgets"
|
||||||
|
|
||||||
|
/** An icon — a stub until the icon widget is written. */
|
||||||
|
export function IconWidget(_props: WidgetProps) {
|
||||||
|
return <p className="text-sm text-muted-foreground">—</p>
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
BatteryCharging,
|
||||||
|
Bed,
|
||||||
|
Check,
|
||||||
|
CircleCheck,
|
||||||
|
CloudDrizzle,
|
||||||
|
CloudFog,
|
||||||
|
CloudLightning,
|
||||||
|
CloudRain,
|
||||||
|
CloudSnow,
|
||||||
|
Cloudy,
|
||||||
|
DoorOpen,
|
||||||
|
Droplets,
|
||||||
|
Fan,
|
||||||
|
Flame,
|
||||||
|
House,
|
||||||
|
Lightbulb,
|
||||||
|
type LucideIcon,
|
||||||
|
Moon,
|
||||||
|
Plug,
|
||||||
|
Snowflake,
|
||||||
|
Sun,
|
||||||
|
SunSnow,
|
||||||
|
Thermometer,
|
||||||
|
ThermometerSun,
|
||||||
|
TriangleAlert,
|
||||||
|
Umbrella,
|
||||||
|
Wind,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The icons a tile may be drawn with, by name.
|
||||||
|
*
|
||||||
|
* Curated rather than lucide's `dynamicIconImports`: the panel has to offer
|
||||||
|
* these in a list, and the dynamic map makes the bundler emit a lazy chunk per
|
||||||
|
* icon — some fifteen hundred of them — so a wall panel would fetch one request
|
||||||
|
* per tile over its LAN before it could draw anything. A few dozen weather,
|
||||||
|
* room and status glyphs cover what a dashboard says, and the map is cheap to
|
||||||
|
* extend.
|
||||||
|
*/
|
||||||
|
export const ICONS: Record<string, LucideIcon> = {
|
||||||
|
sun: Sun,
|
||||||
|
moon: Moon,
|
||||||
|
cloudy: Cloudy,
|
||||||
|
"cloud-rain": CloudRain,
|
||||||
|
"cloud-drizzle": CloudDrizzle,
|
||||||
|
"cloud-snow": CloudSnow,
|
||||||
|
"cloud-lightning": CloudLightning,
|
||||||
|
"cloud-fog": CloudFog,
|
||||||
|
wind: Wind,
|
||||||
|
umbrella: Umbrella,
|
||||||
|
snowflake: Snowflake,
|
||||||
|
droplets: Droplets,
|
||||||
|
thermometer: Thermometer,
|
||||||
|
"thermometer-sun": ThermometerSun,
|
||||||
|
flame: Flame,
|
||||||
|
fan: Fan,
|
||||||
|
"sun-snow": SunSnow,
|
||||||
|
"door-open": DoorOpen,
|
||||||
|
house: House,
|
||||||
|
bed: Bed,
|
||||||
|
lightbulb: Lightbulb,
|
||||||
|
plug: Plug,
|
||||||
|
zap: Zap,
|
||||||
|
"battery-charging": BatteryCharging,
|
||||||
|
"arrow-up": ArrowUp,
|
||||||
|
"arrow-down": ArrowDown,
|
||||||
|
"triangle-alert": TriangleAlert,
|
||||||
|
check: Check,
|
||||||
|
"circle-check": CircleCheck,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ICON_NAMES = Object.keys(ICONS)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an icon may be tinted, by name.
|
||||||
|
*
|
||||||
|
* Literal classes so Tailwind's scanner sees them. No terracotta: that is the
|
||||||
|
* one affordance a view gets, and a panel is many tiles.
|
||||||
|
*/
|
||||||
|
export const ICON_COLORS: Record<string, string> = {
|
||||||
|
default: "text-foreground",
|
||||||
|
muted: "text-muted-foreground",
|
||||||
|
primary: "text-primary",
|
||||||
|
success: "text-status-success",
|
||||||
|
danger: "text-destructive",
|
||||||
|
}
|
||||||
@@ -156,6 +156,49 @@ function ModePicker({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Which chrome an input wears, in the same segmented shape as the mode. */
|
||||||
|
function StylePicker({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
options: readonly (readonly [string, string])[]
|
||||||
|
onChange: (style: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-testid="widget-style"
|
||||||
|
className="flex w-fit items-center gap-1 rounded-full border border-border p-1"
|
||||||
|
>
|
||||||
|
<legend className="sr-only">How this control is drawn</legend>
|
||||||
|
{options.map(([style, label]) => (
|
||||||
|
<button
|
||||||
|
key={style}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={value === style}
|
||||||
|
onClick={() => onChange(style)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2.5 py-1 text-xs transition-colors",
|
||||||
|
value === style
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a text field was meant to send: a bool, a number, or the text itself. */
|
||||||
|
function coerce(raw: string): unknown {
|
||||||
|
const asNumber = Number(raw)
|
||||||
|
if (raw === "true" || raw === "false") return raw === "true"
|
||||||
|
return raw !== "" && Number.isFinite(asNumber) ? asNumber : raw
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What one widget shows or does.
|
* What one widget shows or does.
|
||||||
*
|
*
|
||||||
@@ -183,6 +226,9 @@ export function WidgetPanel({
|
|||||||
|
|
||||||
const series = seriesOf(widget)
|
const series = seriesOf(widget)
|
||||||
const setSeries = (next: Series[]) => set({ series: next })
|
const setSeries = (next: Series[]) => set({ series: next })
|
||||||
|
// Nothing wrote these until now, so a dropdown's choices were uneditable.
|
||||||
|
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
||||||
|
const setOptions = (next: typeof options) => set({ options: next })
|
||||||
const querying = cfg.source === "query"
|
const querying = cfg.source === "query"
|
||||||
// What this chart would refresh at with nothing configured. The viewer can
|
// What this chart would refresh at with nothing configured. The viewer can
|
||||||
// pick another window on the widget, which moves it.
|
// pick another window on the widget, which moves it.
|
||||||
@@ -334,7 +380,8 @@ export function WidgetPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : // A clock reads the wall; a picker would bind a message nothing reads.
|
||||||
|
widget.type === "clock" ? null : (
|
||||||
<MessagePicker
|
<MessagePicker
|
||||||
kind={widget.type}
|
kind={widget.type}
|
||||||
value={str(cfg[isInput ? "target" : "message"])}
|
value={str(cfg[isInput ? "target" : "message"])}
|
||||||
@@ -526,21 +573,100 @@ export function WidgetPanel({
|
|||||||
<Input
|
<Input
|
||||||
value={str(cfg.value)}
|
value={str(cfg.value)}
|
||||||
placeholder="true"
|
placeholder="true"
|
||||||
onChange={(event) => {
|
onChange={(event) => set({ value: coerce(event.target.value) })}
|
||||||
const raw = event.target.value
|
|
||||||
const asNumber = Number(raw)
|
|
||||||
set({
|
|
||||||
value:
|
|
||||||
raw === "true" || raw === "false"
|
|
||||||
? raw === "true"
|
|
||||||
: raw !== "" && Number.isFinite(asNumber)
|
|
||||||
? asNumber
|
|
||||||
: raw,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{widget.type === "dropdown" ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<span className={PANEL_SECTION}>Options</span>
|
||||||
|
{options.map((option, index) => (
|
||||||
|
<div
|
||||||
|
// Position is the only identity an option row has.
|
||||||
|
key={`option-${index}`}
|
||||||
|
className="flex items-end gap-1.5"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
value={str(option.label)}
|
||||||
|
placeholder="Label"
|
||||||
|
aria-label="Option label"
|
||||||
|
onChange={(event) =>
|
||||||
|
setOptions(
|
||||||
|
options.map((other, at) =>
|
||||||
|
at === index
|
||||||
|
? { ...other, label: event.target.value }
|
||||||
|
: other,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="w-28"
|
||||||
|
value={str(option.value)}
|
||||||
|
placeholder="Value"
|
||||||
|
aria-label="Option value"
|
||||||
|
onChange={(event) =>
|
||||||
|
setOptions(
|
||||||
|
options.map((other, at) =>
|
||||||
|
at === index
|
||||||
|
? { ...other, value: coerce(event.target.value) }
|
||||||
|
: other,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
aria-label="Remove option"
|
||||||
|
onClick={() =>
|
||||||
|
setOptions(options.filter((_, at) => at !== index))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 justify-self-start"
|
||||||
|
onClick={() => setOptions([...options, {}])}
|
||||||
|
data-testid="add-option"
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
Add option
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{widget.type === "switch" || widget.type === "dropdown" ? (
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<Label className="text-sm font-normal">Style</Label>
|
||||||
|
{widget.type === "switch" ? (
|
||||||
|
<StylePicker
|
||||||
|
value={str(cfg.style) || "track"}
|
||||||
|
options={[
|
||||||
|
["track", "Track"],
|
||||||
|
["button", "Button"],
|
||||||
|
]}
|
||||||
|
onChange={(style) => set({ style })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StylePicker
|
||||||
|
value={str(cfg.style) || "list"}
|
||||||
|
options={[
|
||||||
|
["list", "List"],
|
||||||
|
["segmented", "Segmented"],
|
||||||
|
]}
|
||||||
|
onChange={(style) => set({ style })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</SidePanel>
|
</SidePanel>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { BarWidget } from "./BarWidget"
|
||||||
import { ChartWidget } from "./ChartWidget"
|
import { ChartWidget } from "./ChartWidget"
|
||||||
|
import { ClockWidget } from "./ClockWidget"
|
||||||
|
import { ForecastWidget } from "./ForecastWidget"
|
||||||
|
import { IconWidget } from "./IconWidget"
|
||||||
import { usePublishMessage } from "./queries"
|
import { usePublishMessage } from "./queries"
|
||||||
|
|
||||||
/** Widget types that put a value into the graph rather than read one. */
|
/** 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"],
|
switch: ["bool"],
|
||||||
agenda: ["list"],
|
agenda: ["list"],
|
||||||
notification: ["record"],
|
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. */
|
/** 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",
|
markdown: "Text",
|
||||||
agenda: "Agenda",
|
agenda: "Agenda",
|
||||||
notification: "Notification",
|
notification: "Notification",
|
||||||
|
bar: "Bar",
|
||||||
|
icon: "Icon",
|
||||||
|
forecast: "Forecast",
|
||||||
|
clock: "Clock",
|
||||||
button: "Button",
|
button: "Button",
|
||||||
switch: "Switch",
|
switch: "Switch",
|
||||||
slider: "Slider",
|
slider: "Slider",
|
||||||
@@ -80,6 +92,10 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
|
|||||||
markdown: { w: 6, h: 2 },
|
markdown: { w: 6, h: 2 },
|
||||||
agenda: { w: 4, h: 4 },
|
agenda: { w: 4, h: 4 },
|
||||||
notification: { w: 4, h: 2 },
|
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 },
|
button: { w: 3, h: 2 },
|
||||||
switch: { w: 3, h: 2 },
|
switch: { w: 3, h: 2 },
|
||||||
slider: { w: 4, h: 2 },
|
slider: { w: 4, h: 2 },
|
||||||
@@ -125,7 +141,8 @@ export const seriesOf = (widget: WidgetDef): Series[] =>
|
|||||||
* fetching the message catalogue first.
|
* fetching the message catalogue first.
|
||||||
*/
|
*/
|
||||||
export function widgetIssue(widget: WidgetDef): string | null {
|
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)
|
const cfg = config(widget)
|
||||||
|
|
||||||
if (widget.type === "chart" && cfg.source === "query") {
|
if (widget.type === "chart" && cfg.source === "query") {
|
||||||
@@ -167,6 +184,13 @@ export function widgetIssue(widget: WidgetDef): string | null {
|
|||||||
if (!acceptsDtype(widget.type, dtype)) {
|
if (!acceptsDtype(widget.type, dtype)) {
|
||||||
return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.`
|
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
|
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) {
|
function SwitchWidget({ widget, dashboard }: WidgetProps) {
|
||||||
|
const cfg = config(widget)
|
||||||
const { target, live, send } = usePublish(widget, dashboard)
|
const { target, live, send } = usePublish(widget, dashboard)
|
||||||
if (!target) return <Unbound />
|
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">
|
<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
|
<Switch
|
||||||
checked={live?.value === true}
|
checked={on}
|
||||||
aria-label={widget.title || target}
|
aria-label={widget.title || target}
|
||||||
onCheckedChange={(checked) => send(checked)}
|
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) {
|
function DropdownWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const { target, live, send } = usePublish(widget, dashboard)
|
const { target, live, send } = usePublish(widget, dashboard)
|
||||||
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
||||||
if (!target) return <Unbound />
|
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 (
|
return (
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label className="sr-only">{widget.title || target}</Label>
|
<Label className="sr-only">{widget.title || target}</Label>
|
||||||
@@ -700,6 +778,10 @@ const RENDERERS: Partial<
|
|||||||
markdown: MarkdownWidget,
|
markdown: MarkdownWidget,
|
||||||
agenda: AgendaWidget,
|
agenda: AgendaWidget,
|
||||||
notification: NotificationWidget,
|
notification: NotificationWidget,
|
||||||
|
bar: BarWidget,
|
||||||
|
icon: IconWidget,
|
||||||
|
forecast: ForecastWidget,
|
||||||
|
clock: ClockWidget,
|
||||||
button: ButtonWidget,
|
button: ButtonWidget,
|
||||||
switch: SwitchWidget,
|
switch: SwitchWidget,
|
||||||
slider: SliderWidget,
|
slider: SliderWidget,
|
||||||
|
|||||||
Reference in New Issue
Block a user