Merge branch 'main' of git.stroblme.de:Fluksio/app
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m10s
Playwright Tests / test-playwright (2, 2) (push) Failing after 11s
pre-commit / pre-commit (push) Failing after 1m56s
Test Backend / test-backend (push) Failing after 2m35s
Compose Smoke Test / test-compose (push) Failing after 11s
Playwright Tests / merge-reports (push) Failing after 2m19s
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m10s
Playwright Tests / test-playwright (2, 2) (push) Failing after 11s
pre-commit / pre-commit (push) Failing after 1m56s
Test Backend / test-backend (push) Failing after 2m35s
Compose Smoke Test / test-compose (push) Failing after 11s
Playwright Tests / merge-reports (push) Failing after 2m19s
# Conflicts: # frontend/src/client/sdk.gen.ts
This commit is contained in:
@@ -69,6 +69,7 @@ WidgetType = Literal[
|
|||||||
"embed",
|
"embed",
|
||||||
# Input
|
# Input
|
||||||
"button",
|
"button",
|
||||||
|
"buttons",
|
||||||
"switch",
|
"switch",
|
||||||
"slider",
|
"slider",
|
||||||
"input",
|
"input",
|
||||||
@@ -79,7 +80,15 @@ WidgetType = Literal[
|
|||||||
#: Widgets whose only binding is the message they publish. A player is not one:
|
#: 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
|
#: it publishes transport commands *and* reads what is playing, so its reading
|
||||||
#: is its binding and its ``target`` is checked separately.
|
#: 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.
|
#: 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
|
#: The default is what the Node-RED instance this ports already sends its
|
||||||
|
|||||||
@@ -255,6 +255,14 @@ PANEL_WIDGETS = [
|
|||||||
},
|
},
|
||||||
{"id": "w_input", "type": "input", "config": {"target": "demo.input"}},
|
{"id": "w_input", "type": "input", "config": {"target": "demo.input"}},
|
||||||
{"id": "w_dropdown", "type": "dropdown", "config": {"target": "demo.dropdown"}},
|
{"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",
|
"id": "w_query",
|
||||||
"type": "chart",
|
"type": "chart",
|
||||||
@@ -313,6 +321,7 @@ def test_a_panel_speaks_only_its_own_widgets_messages(
|
|||||||
"demo.slider",
|
"demo.slider",
|
||||||
"demo.input",
|
"demo.input",
|
||||||
"demo.dropdown",
|
"demo.dropdown",
|
||||||
|
"demo.buttons",
|
||||||
"demo.query_request",
|
"demo.query_request",
|
||||||
):
|
):
|
||||||
assert (
|
assert (
|
||||||
|
|||||||
@@ -3819,7 +3819,7 @@ export const WidgetDefSchema = {
|
|||||||
},
|
},
|
||||||
type: {
|
type: {
|
||||||
type: 'string',
|
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: 'Type'
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
@@ -3894,7 +3894,7 @@ export const WidgetFootprintSchema = {
|
|||||||
},
|
},
|
||||||
type: {
|
type: {
|
||||||
type: 'string',
|
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: 'Type'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1306,7 +1306,7 @@ export type WebPushKey = {
|
|||||||
*/
|
*/
|
||||||
export type WidgetDef = {
|
export type WidgetDef = {
|
||||||
id: string;
|
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;
|
title?: string;
|
||||||
layout?: {
|
layout?: {
|
||||||
[key: string]: Placement;
|
[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.
|
* A widget reduced to the shape it draws: what it is, and where it sits.
|
||||||
@@ -1331,7 +1331,7 @@ export type WidgetFootprint = {
|
|||||||
y?: number;
|
y?: number;
|
||||||
w?: number;
|
w?: number;
|
||||||
h?: 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 = {
|
export type WorkerInfo = {
|
||||||
|
|||||||
@@ -101,3 +101,21 @@
|
|||||||
border-right-color: var(--muted-foreground);
|
border-right-color: var(--muted-foreground);
|
||||||
border-bottom-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,
|
settingOf,
|
||||||
THEME_CHOICES,
|
THEME_CHOICES,
|
||||||
} from "./settings"
|
} 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 { parsePalette, roleLabel } from "./ui/core/theme"
|
||||||
import {
|
import {
|
||||||
acceptsDtype,
|
acceptsDtype,
|
||||||
@@ -482,6 +488,12 @@ export function WidgetPanel({
|
|||||||
// Nothing wrote these until now, so a dropdown's choices were uneditable.
|
// Nothing wrote these until now, so a dropdown's choices were uneditable.
|
||||||
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
||||||
const setOptions = (next: typeof options) => set({ options: next })
|
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 querying = cfg.source === "query"
|
||||||
const pinnedRuns = cfg.source === "runs"
|
const pinnedRuns = cfg.source === "runs"
|
||||||
// What this chart would refresh at with nothing configured. The viewer can
|
// What this chart would refresh at with nothing configured. The viewer can
|
||||||
@@ -1054,6 +1066,76 @@ export function WidgetPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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" ? (
|
{widget.type === "dropdown" ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<span className={PANEL_SECTION}>Options</span>
|
<span className={PANEL_SECTION}>Options</span>
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ export function asOriginal(
|
|||||||
return match ? match.value : selected
|
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.
|
* How many readings one bar draws, and a hard ceiling.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ import { PlayerWidget } from "./PlayerWidget"
|
|||||||
import { usePublish } from "./publish"
|
import { usePublish } from "./publish"
|
||||||
import { useUi } from "./ui"
|
import { useUi } from "./ui"
|
||||||
import { COLOR_DTYPES, colorFormatOf } from "./ui/core/color"
|
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. */
|
/** Widget types that put a value into the graph rather than read one. */
|
||||||
export const INPUT_WIDGETS = new Set([
|
export const INPUT_WIDGETS = new Set([
|
||||||
"button",
|
"button",
|
||||||
|
"buttons",
|
||||||
"switch",
|
"switch",
|
||||||
"slider",
|
"slider",
|
||||||
"input",
|
"input",
|
||||||
@@ -88,6 +89,7 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
|
|||||||
player: "Player",
|
player: "Player",
|
||||||
embed: "Website",
|
embed: "Website",
|
||||||
button: "Button",
|
button: "Button",
|
||||||
|
buttons: "Buttons",
|
||||||
switch: "Switch",
|
switch: "Switch",
|
||||||
slider: "Slider",
|
slider: "Slider",
|
||||||
input: "Input",
|
input: "Input",
|
||||||
@@ -111,6 +113,7 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
|
|||||||
player: { w: 4, h: 3 },
|
player: { w: 4, h: 3 },
|
||||||
embed: { w: 6, h: 4 },
|
embed: { w: 6, h: 4 },
|
||||||
button: { w: 3, h: 2 },
|
button: { w: 3, h: 2 },
|
||||||
|
buttons: { w: 4, h: 3 },
|
||||||
switch: { w: 3, h: 2 },
|
switch: { w: 3, h: 2 },
|
||||||
slider: { w: 4, h: 2 },
|
slider: { w: 4, h: 2 },
|
||||||
input: { 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."
|
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 input = INPUT_WIDGETS.has(widget.type)
|
||||||
const bound = text(cfg[input ? "target" : "message"])
|
const bound = text(cfg[input ? "target" : "message"])
|
||||||
if (!bound) {
|
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.
|
* A bool, published and read back — a latch either way it is drawn.
|
||||||
*
|
*
|
||||||
@@ -697,6 +741,7 @@ const RENDERERS: Partial<
|
|||||||
player: PlayerWidget,
|
player: PlayerWidget,
|
||||||
embed: EmbedWidget,
|
embed: EmbedWidget,
|
||||||
button: ButtonWidget,
|
button: ButtonWidget,
|
||||||
|
buttons: ButtonsWidget,
|
||||||
switch: SwitchWidget,
|
switch: SwitchWidget,
|
||||||
slider: SliderWidget,
|
slider: SliderWidget,
|
||||||
input: InputWidget,
|
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",
|
id: "lamp",
|
||||||
type: "switch",
|
type: "switch",
|
||||||
@@ -488,6 +502,12 @@ test("a control reads back what it published", async ({ page }) => {
|
|||||||
await boost.click()
|
await boost.click()
|
||||||
await expect(boost).toHaveAttribute("aria-pressed", "true")
|
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.
|
// The latching button names its state; two presses are a round trip.
|
||||||
const lamp = page.getByRole("button", { name: "Lamp" })
|
const lamp = page.getByRole("button", { name: "Lamp" })
|
||||||
await expect(lamp).toHaveText("Off")
|
await expect(lamp).toHaveText("Off")
|
||||||
|
|||||||
Reference in New Issue
Block a user