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" ? ( +