diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py index ced1df8..ce0f034 100644 --- a/backend/fluksio/flow/dashboards.py +++ b/backend/fluksio/flow/dashboards.py @@ -69,6 +69,7 @@ WidgetType = Literal[ "embed", # Input "button", + "buttons", "switch", "slider", "input", @@ -79,7 +80,15 @@ WidgetType = Literal[ #: Widgets whose only binding is the message they publish. A player is not one: #: it publishes transport commands *and* reads what is playing, so its reading #: is its binding and its ``target`` is checked separately. -INPUT_WIDGETS = {"button", "switch", "slider", "input", "dropdown", "color"} +INPUT_WIDGETS = { + "button", + "buttons", + "switch", + "slider", + "input", + "dropdown", + "color", +} #: What a colour widget puts on the wire, by the format it was configured for. #: The default is what the Node-RED instance this ports already sends its diff --git a/backend/tests/api/routes/test_panels.py b/backend/tests/api/routes/test_panels.py index bc07d77..e4f29cd 100644 --- a/backend/tests/api/routes/test_panels.py +++ b/backend/tests/api/routes/test_panels.py @@ -255,6 +255,14 @@ PANEL_WIDGETS = [ }, {"id": "w_input", "type": "input", "config": {"target": "demo.input"}}, {"id": "w_dropdown", "type": "dropdown", "config": {"target": "demo.dropdown"}}, + { + "id": "w_buttons", + "type": "buttons", + "config": { + "target": "demo.buttons", + "buttons": [{"label": "One", "value": 1}], + }, + }, { "id": "w_query", "type": "chart", @@ -313,6 +321,7 @@ def test_a_panel_speaks_only_its_own_widgets_messages( "demo.slider", "demo.input", "demo.dropdown", + "demo.buttons", "demo.query_request", ): assert ( diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 3bf8e38..ec3cbfa 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -3819,7 +3819,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: { @@ -3894,7 +3894,7 @@ export const WidgetFootprintSchema = { }, 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' } }, diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 0776ba7..4095aee 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1306,7 +1306,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; @@ -1316,7 +1316,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'; /** * A widget reduced to the shape it draws: what it is, and where it sits. @@ -1331,7 +1331,7 @@ export type WidgetFootprint = { y?: number; w?: number; h?: number; - 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'; }; export type WorkerInfo = { diff --git a/frontend/src/components/Dashboard/dashboard.css b/frontend/src/components/Dashboard/dashboard.css index af4229d..e4496d0 100644 --- a/frontend/src/components/Dashboard/dashboard.css +++ b/frontend/src/components/Dashboard/dashboard.css @@ -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; +} diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index a95dab1..2200526 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -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({ ) : null} + {widget.type === "buttons" ? ( +
+ 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. + + {buttons.map((button, index) => ( +
+ + setButtons( + buttons.map((other, at) => + at === index + ? { ...other, label: event.target.value } + : other, + ), + ) + } + /> + + setButtons( + buttons.map((other, at) => + at === index + ? { ...other, value: coerce(event.target.value) } + : other, + ), + ) + } + /> + +
+ ))} + +
+ ) : null} + {widget.type === "dropdown" ? (
Options diff --git a/frontend/src/components/Dashboard/ui/core/config.ts b/frontend/src/components/Dashboard/ui/core/config.ts index 202ec24..5d2197a 100644 --- a/frontend/src/components/Dashboard/ui/core/config.ts +++ b/frontend/src/components/Dashboard/ui/core/config.ts @@ -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. * diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx index a87abcb..c588305 100644 --- a/frontend/src/components/Dashboard/widgets.tsx +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -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 = { player: "Player", embed: "Website", button: "Button", + buttons: "Buttons", switch: "Switch", slider: "Slider", input: "Input", @@ -111,6 +113,7 @@ export const WIDGET_SIZES: Record = { 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 + + return ( + <> + {pulse} +
+ {buttons.map((button, index) => ( + + ))} +
+ + ) +} + /** * 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, diff --git a/frontend/tests/widgets.spec.ts b/frontend/tests/widgets.spec.ts index e506ee5..cab717b 100644 --- a/frontend/tests/widgets.spec.ts +++ b/frontend/tests/widgets.spec.ts @@ -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")