diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py index b1e1188..68355ff 100644 --- a/backend/fluksio/flow/dashboards.py +++ b/backend/fluksio/flow/dashboards.py @@ -68,6 +68,7 @@ WidgetType = Literal[ "embed", # Input "button", + "buttons", "switch", "slider", "input", @@ -78,7 +79,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 0efdda0..8c1c606 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 ( @@ -854,8 +863,9 @@ def test_a_socket_pushes_only_the_frames_it_was_asked_for(tmp_path) -> None: store = ArtifactStore(tmp_path / "artifacts") store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000) - frame = store.put([b"\x89PNG..."], name="f.png", media_type="image/png", - volatile=True) + frame = store.put( + [b"\x89PNG..."], name="f.png", media_type="image/png", volatile=True + ) kept = store.put([b"checkpoint"], name="w.pt") asking = orjson.dumps({"type": "media", "names": ["cam.frame", "other.frame"]}) diff --git a/frontend/src/client/core/OpenAPI.ts b/frontend/src/client/core/OpenAPI.ts index 327a9ad..979d72b 100644 --- a/frontend/src/client/core/OpenAPI.ts +++ b/frontend/src/client/core/OpenAPI.ts @@ -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(), diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index ec8bf58..56fa8d5 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -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: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index c51d483..e6da100 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; +import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; export class AlertsService { /** @@ -2085,6 +2085,44 @@ export class RunsService { }); } + /** + * Read Metric Names + * Every metric name the selected runs recorded. + * + * A name is flow-qualified — a node of `train` writing `train_loss` records + * `train.train_loss` — so this is the answer to "what would match". Exact + * over the whole selection: the client used to read the newest run that had + * measured anything and take its names for the vocabulary, which missed a + * name only an older run ever wrote. Declared before `/{run_id}`, or that + * route would take "metrics" for an id. + * @param data The data for the request. + * @param data.flow + * @param data.status + * @param data.group + * @param data.ids + * @param data.since + * @param data.until + * @returns string Successful Response + * @throws ApiError + */ + public static readMetricNames(data: RunsReadMetricNamesData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/runs/metrics/names', + query: { + flow: data.flow, + status: data.status, + group: data.group, + ids: data.ids, + since: data.since, + until: data.until + }, + errors: { + 422: 'Validation Error' + } + }); + } + /** * Read Run * One run in full: what it was asked, what each node did, what it made. diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 9b55317..1fd9c98 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -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; }; 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")