A buttons widget: a grid of presses on one message
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m9s
Playwright Tests / test-playwright (2, 2) (push) Failing after 11s
pre-commit / pre-commit (push) Failing after 2m6s
Test Backend / test-backend (push) Failing after 2m27s
Compose Smoke Test / test-compose (push) Failing after 1m54s
Playwright Tests / merge-reports (push) Failing after 2m16s
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m9s
Playwright Tests / test-playwright (2, 2) (push) Failing after 11s
pre-commit / pre-commit (push) Failing after 2m6s
Test Backend / test-backend (push) Failing after 2m27s
Compose Smoke Test / test-compose (push) Failing after 1m54s
Playwright Tests / merge-reports (push) Failing after 2m16s
Several stateless instructions where a single button is one — six presets are otherwise six tiles to place, six titles to read, and the message they share repeated six times. Nothing is read back, as for a single button: what these send is an instruction, and the last one sent is not a state to draw. Auto-fit columns rather than a configured count: a tile is resized in the editor and scaled again to whatever panel it hangs on, so how many fit is not something the document can know. The editor reads `cfg.buttons` raw rather than through `buttonsOf`, which drops the blanks — a row being typed into is blank until the first keystroke. Also carried along by the hooks: the generated SDK was stale (it had no `RunsReadMetricNames`) and one pre-existing block in `test_panels.py` was unformatted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bm2HwnRsTDpeWjSPNJxQd
This commit is contained in:
@@ -48,7 +48,7 @@ export const OpenAPI: OpenAPIConfig = {
|
||||
PASSWORD: undefined,
|
||||
TOKEN: undefined,
|
||||
USERNAME: undefined,
|
||||
VERSION: '0.1.4+dev',
|
||||
VERSION: '0.1.5+dev',
|
||||
WITH_CREDENTIALS: false,
|
||||
interceptors: {
|
||||
request: new Interceptors(),
|
||||
|
||||
@@ -3783,7 +3783,7 @@ export const WidgetDefSchema = {
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'media', 'player', 'embed', 'button', 'switch', 'slider', 'input', 'dropdown', 'color'],
|
||||
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'media', 'player', 'embed', 'button', 'buttons', 'switch', 'slider', 'input', 'dropdown', 'color'],
|
||||
title: 'Type'
|
||||
},
|
||||
title: {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1302,7 +1302,7 @@ export type WebPushKey = {
|
||||
*/
|
||||
export type WidgetDef = {
|
||||
id: string;
|
||||
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
|
||||
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'buttons' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
|
||||
title?: string;
|
||||
layout?: {
|
||||
[key: string]: Placement;
|
||||
@@ -1312,7 +1312,7 @@ export type WidgetDef = {
|
||||
};
|
||||
};
|
||||
|
||||
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
|
||||
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'buttons' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
|
||||
|
||||
export type WorkerInfo = {
|
||||
name: string;
|
||||
@@ -1860,6 +1860,17 @@ export type RunsExportRunsData = {
|
||||
|
||||
export type RunsExportRunsResponse = (unknown);
|
||||
|
||||
export type RunsReadMetricNamesData = {
|
||||
flow?: (string | null);
|
||||
group?: (string | null);
|
||||
ids?: string;
|
||||
since?: (string | null);
|
||||
status?: (string | null);
|
||||
until?: (string | null);
|
||||
};
|
||||
|
||||
export type RunsReadMetricNamesResponse = (Array<(string)>);
|
||||
|
||||
export type RunsReadRunData = {
|
||||
runId: string;
|
||||
};
|
||||
|
||||
@@ -101,3 +101,21 @@
|
||||
border-right-color: var(--muted-foreground);
|
||||
border-bottom-color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/*
|
||||
* A grid of presses, inside one tile.
|
||||
*
|
||||
* Auto-fit rather than a configured column count: the tile is resized in the
|
||||
* editor and scaled again to whatever panel it hangs on, so the number of
|
||||
* columns that fits is not something the document can know. `1fr` rows so the
|
||||
* presses share the tile's height however many rows they wrap into — a target
|
||||
* on a wall panel is pressed without looking at it.
|
||||
*/
|
||||
.widget-buttons {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,13 @@ import {
|
||||
settingOf,
|
||||
THEME_CHOICES,
|
||||
} from "./settings"
|
||||
import { type BarRow, MAX_ROWS, rowsOf, showTitle } from "./ui/core/config"
|
||||
import {
|
||||
type BarRow,
|
||||
type ButtonEntry,
|
||||
MAX_ROWS,
|
||||
rowsOf,
|
||||
showTitle,
|
||||
} from "./ui/core/config"
|
||||
import { parsePalette, roleLabel } from "./ui/core/theme"
|
||||
import {
|
||||
acceptsDtype,
|
||||
@@ -482,6 +488,12 @@ export function WidgetPanel({
|
||||
// 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 })
|
||||
// A grid's presses, read raw rather than through `buttonsOf`: that drops the
|
||||
// blanks, and a row being typed into is blank until the first keystroke. An
|
||||
// empty row stands in for none, so a fresh grid offers the fields.
|
||||
const written = (cfg.buttons ?? []) as ButtonEntry[]
|
||||
const buttons: ButtonEntry[] = written.length ? written : [{}]
|
||||
const setButtons = (next: ButtonEntry[]) => set({ buttons: next })
|
||||
const querying = cfg.source === "query"
|
||||
const pinnedRuns = cfg.source === "runs"
|
||||
// What this chart would refresh at with nothing configured. The viewer can
|
||||
@@ -1054,6 +1066,76 @@ export function WidgetPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{widget.type === "buttons" ? (
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Buttons</span>
|
||||
<InfoTip label="Buttons">
|
||||
One press per row, all publishing to the message above. The grid
|
||||
fits as many columns as the tile is wide; a row with no value
|
||||
sends its own label.
|
||||
</InfoTip>
|
||||
{buttons.map((button, index) => (
|
||||
<div
|
||||
// Position is the only identity a press has.
|
||||
key={`press-${index}`}
|
||||
className="flex items-end gap-1.5"
|
||||
>
|
||||
<Input
|
||||
className="min-w-0 flex-1"
|
||||
value={str(button.label)}
|
||||
placeholder="Label"
|
||||
aria-label="Button label"
|
||||
onChange={(event) =>
|
||||
setButtons(
|
||||
buttons.map((other, at) =>
|
||||
at === index
|
||||
? { ...other, label: event.target.value }
|
||||
: other,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="w-28"
|
||||
value={str(button.value)}
|
||||
placeholder="Value"
|
||||
aria-label="Button value"
|
||||
onChange={(event) =>
|
||||
setButtons(
|
||||
buttons.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 button"
|
||||
onClick={() =>
|
||||
setButtons(buttons.filter((_, at) => at !== index))
|
||||
}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 justify-self-start"
|
||||
onClick={() => setButtons([...buttons, {}])}
|
||||
data-testid="add-button"
|
||||
>
|
||||
<Plus />
|
||||
Add button
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{widget.type === "dropdown" ? (
|
||||
<div className="grid gap-2">
|
||||
<span className={PANEL_SECTION}>Options</span>
|
||||
|
||||
@@ -89,6 +89,15 @@ export function asOriginal(
|
||||
return match ? match.value : selected
|
||||
}
|
||||
|
||||
/** One press a button grid draws, as the document stores it. */
|
||||
export type ButtonEntry = { label?: string; value?: unknown }
|
||||
|
||||
/** The presses a grid draws. A row with neither half is not one. */
|
||||
export const buttonsOf = (widget: WidgetDef): ButtonEntry[] =>
|
||||
((config(widget).buttons ?? []) as ButtonEntry[]).filter(
|
||||
(entry) => entry.label || entry.value !== undefined,
|
||||
)
|
||||
|
||||
/**
|
||||
* How many readings one bar draws, and a hard ceiling.
|
||||
*
|
||||
|
||||
@@ -19,11 +19,12 @@ import { PlayerWidget } from "./PlayerWidget"
|
||||
import { usePublish } from "./publish"
|
||||
import { useUi } from "./ui"
|
||||
import { COLOR_DTYPES, colorFormatOf } from "./ui/core/color"
|
||||
import { config, num, rowsOf, text } from "./ui/core/config"
|
||||
import { buttonsOf, config, 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",
|
||||
@@ -88,6 +89,7 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
|
||||
player: "Player",
|
||||
embed: "Website",
|
||||
button: "Button",
|
||||
buttons: "Buttons",
|
||||
switch: "Switch",
|
||||
slider: "Slider",
|
||||
input: "Input",
|
||||
@@ -111,6 +113,7 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
|
||||
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 },
|
||||
@@ -210,6 +213,11 @@ export function widgetIssue(widget: WidgetDef): string | null {
|
||||
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) {
|
||||
@@ -501,6 +509,42 @@ function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -697,6 +741,7 @@ const RENDERERS: Partial<
|
||||
player: PlayerWidget,
|
||||
embed: EmbedWidget,
|
||||
button: ButtonWidget,
|
||||
buttons: ButtonsWidget,
|
||||
switch: SwitchWidget,
|
||||
slider: SliderWidget,
|
||||
input: InputWidget,
|
||||
|
||||
@@ -151,6 +151,20 @@ test.beforeAll(async ({ browser }) => {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "scenes",
|
||||
type: "buttons",
|
||||
title: "Scenes",
|
||||
layout: { lg: { x: 9, y: 0, w: 3, h: 2 } },
|
||||
config: {
|
||||
target: w("mode"),
|
||||
dtype: "str",
|
||||
buttons: [
|
||||
{ label: "Night", value: "night" },
|
||||
{ label: "Away", value: "away" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lamp",
|
||||
type: "switch",
|
||||
@@ -488,6 +502,12 @@ test("a control reads back what it published", async ({ page }) => {
|
||||
await boost.click()
|
||||
await expect(boost).toHaveAttribute("aria-pressed", "true")
|
||||
|
||||
// A grid draws one press per entry and publishes the value of the one
|
||||
// pressed — nothing is read back, so the segmented control beside it, bound
|
||||
// to the same message, is what says the publish landed.
|
||||
await page.getByRole("button", { name: "Night" }).click()
|
||||
await expect(boost).toHaveAttribute("aria-pressed", "false")
|
||||
|
||||
// The latching button names its state; two presses are a round trip.
|
||||
const lamp = page.getByRole("button", { name: "Lamp" })
|
||||
await expect(lamp).toHaveText("Off")
|
||||
|
||||
Reference in New Issue
Block a user