diff --git a/.vscode/settings.json b/.vscode/settings.json index 0d613a1273..819eb62fb3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,5 +39,5 @@ }, "[shellscript]": { "editor.defaultFormatter": "foxundermoon.shell-format" - }, + } } diff --git a/packages/studio-base/src/i18n/en/panels.ts b/packages/studio-base/src/i18n/en/panels.ts index 4c5ea31cbd..21fb7d1323 100644 --- a/packages/studio-base/src/i18n/en/panels.ts +++ b/packages/studio-base/src/i18n/en/panels.ts @@ -50,8 +50,8 @@ export const panels = { teleopDescription: "Teleoperate a robot over a live connection.", topicGraph: "Topic Graph", topicGraphDescription: "Display a graph of active nodes, topics, and services.", - serviceButton: "Custom: Service Button", - serviceButtonDescription: "Button to call std_srvs/Trigger.", + toggleSrvButton: "Custom: Toggle Service Button", + toggleSrvButtonDescription: "Button to call services.", userScripts: "User Scripts", userScriptsDescription: "Write custom data transformations in TypeScript. Previously known as Node Playground.", diff --git a/packages/studio-base/src/panels/Battery/styles.css b/packages/studio-base/src/panels/Battery/styles.css index 6800c9d814..69e68117a6 100644 --- a/packages/studio-base/src/panels/Battery/styles.css +++ b/packages/studio-base/src/panels/Battery/styles.css @@ -74,6 +74,7 @@ body { .battery__text { margin-bottom: 0.3rem; + font-size: var(--normal-font-size); } .battery__percentage { @@ -91,7 +92,7 @@ body { } .battery__status i { - font-size: 1.25rem; + font-size: var(--normal-font-size); } .battery__pill { diff --git a/packages/studio-base/src/panels/EStop/EStop.tsx b/packages/studio-base/src/panels/EStop/EStop.tsx index f214238e5c..d316f108b7 100644 --- a/packages/studio-base/src/panels/EStop/EStop.tsx +++ b/packages/studio-base/src/panels/EStop/EStop.tsx @@ -7,10 +7,10 @@ import * as _ from "lodash-es"; import { Dispatch, SetStateAction, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useState } from "react"; import { makeStyles } from "tss-react/mui"; -import Log from "@foxglove/log"; import { parseMessagePath, MessagePath } from "@foxglove/message-path"; import { MessageEvent, PanelExtensionContext, SettingsTreeAction } from "@foxglove/studio"; import { simpleGetMessagePathDataItems } from "@foxglove/studio-base/components/MessagePathSyntax/simpleGetMessagePathDataItems"; +import NotificationModal from "@foxglove/studio-base/components/NotificationModal"; import Stack from "@foxglove/studio-base/components/Stack"; import { Config } from "@foxglove/studio-base/panels/EStop/types"; import ThemeProvider from "@foxglove/studio-base/theme/ThemeProvider"; @@ -19,18 +19,16 @@ import { defaultConfig, settingsActionReducer, useSettingsTree } from "./setting import "./styles.css"; - -const log = Log.getLogger(__dirname); - type Props = { context: PanelExtensionContext; }; type EStopState = "go" | "stop" | undefined; +type SrvResponse = { success: boolean; message: string }; -type ReqState = { +type SrvState = { status: "requesting" | "error" | "success"; - value: string; + response: SrvResponse | undefined; }; type State = { @@ -47,8 +45,8 @@ type Action = | { type: "path"; path: string } | { type: "seek" }; -const useStyles = makeStyles<{ state?: string }>()((theme, { state }) => { - const buttonColor = state === "go" ? "#090" : "#900"; +const useStyles = makeStyles<{ state: EStopState }>()((theme, { state }) => { + const buttonColor = state === "go" ? "#090" : state === "stop" ? "#900" : "#666"; const augmentedButtonColor = theme.palette.augmentColor({ color: { main: buttonColor }, }); @@ -65,26 +63,6 @@ const useStyles = makeStyles<{ state?: string }>()((theme, { state }) => { }; }); -function parseInput(value: string): { error?: string; parsedObject?: unknown } { - let parsedObject; - let error = undefined; - try { - const parsedAny: unknown = JSON.parse(value); - if (Array.isArray(parsedAny)) { - error = "Request content must be an object, not an array"; - } else if (parsedAny == undefined) { - error = "Request content must be an object, not null"; - } else if (typeof parsedAny !== "object") { - error = `Request content must be an object, not ‘${typeof parsedAny}’`; - } else { - parsedObject = parsedAny; - } - } catch (e) { - error = value.length !== 0 ? e.message : "Enter valid request content as JSON"; - } - return { error, parsedObject }; -} - function getSingleDataItem(results: unknown[]) { if (results.length <= 1) { return results[0]; @@ -181,7 +159,7 @@ function EStopContent( // panel extensions must notify when they've completed rendering // onRender will setRenderDone to a done callback which we can invoke after we've rendered const [renderDone, setRenderDone] = useState<() => void>(() => () => { }); - const [reqState, setReqState] = useState(); + const [srvState, setSrvState] = useState(); const [eStopAction, setEStopAction] = useState(); const [config, setConfig] = useState(() => ({ ...defaultConfig, @@ -206,33 +184,28 @@ function EStopContent( dispatch({ type: "path", path: config.statusTopicName }); }, [config.statusTopicName]); - useEffect(() => { - context.saveState(config); - context.setDefaultPanelTitle( - config.goServiceName ? `Unspecified` : undefined, - ); - }, [config, context]); + const handleRequestCloseNotification = () => { + setSrvState(undefined); + }; useEffect(() => { context.saveState(config); - context.setDefaultPanelTitle( - config.stopServiceName ? `Unspecified` : undefined, - ); + context.setDefaultPanelTitle(`E-Stop`); }, [config, context]); useEffect(() => { context.watch("colorScheme"); - context.onRender = (renderReqState, done) => { + context.onRender = (renderSrvState, done) => { setRenderDone(() => done); - setColorScheme(renderReqState.colorScheme ?? "light"); + setColorScheme(renderSrvState.colorScheme ?? "light"); - if (renderReqState.didSeek === true) { + if (renderSrvState.didSeek === true) { dispatch({ type: "seek" }); } - if (renderReqState.currentFrame) { - dispatch({ type: "frame", messages: renderReqState.currentFrame }); + if (renderSrvState.currentFrame) { + dispatch({ type: "frame", messages: renderSrvState.currentFrame }); } }; @@ -253,11 +226,6 @@ function EStopContent( }; }, [context, state.parsedPath?.topicName]); - const { error: requestParseError, parsedObject } = useMemo( - () => parseInput(config.requestPayload ?? ""), - [config.requestPayload], - ); - const settingsActionHandler = useCallback( (action: SettingsTreeAction) => { setConfig((prevConfig) => settingsActionReducer(prevConfig, action)); @@ -265,7 +233,7 @@ function EStopContent( [setConfig], ); - const settingsTree = useSettingsTree(config); + const settingsTree = useSettingsTree(config, state.pathParseError); useEffect(() => { context.updatePanelSettingsEditor({ actionHandler: settingsActionHandler, @@ -285,43 +253,32 @@ function EStopContent( const canEStop = Boolean( context.callService != undefined && - config.requestPayload && config.goServiceName && config.stopServiceName && eStopAction != undefined && - parsedObject != undefined && - requestParseError == undefined && - reqState?.status !== "requesting", + srvState?.status !== "requesting", ); const eStopClicked = useCallback(async () => { if (!context.callService) { - setReqState({ status: "error", value: "The data source does not allow calling services" }); + setSrvState({ status: "error", response: undefined }); return; } const serviceName = eStopAction === "go" ? config.goServiceName : config.stopServiceName; if (!serviceName) { - setReqState({ status: "error", value: "Service name is not configured" }); + setSrvState({ status: "error", response: undefined }); return; } - try { - setReqState({ status: "requesting", value: `Calling ${serviceName}...` }); - const response = await context.callService(serviceName, JSON.parse(config.requestPayload!)); - setReqState({ - status: "success", - value: JSON.stringify(response, (_key, value) => (typeof value === "bigint" ? value.toString() : value), 2) ?? "", - }); - setEStopAction(undefined); - } catch (err) { - setReqState({ status: "error", value: (err as Error).message }); - log.error(err); - } - }, [context, eStopAction, config.goServiceName, config.stopServiceName, config.requestPayload]); + setSrvState({ status: "requesting", response: undefined }); + const response = await context.callService(serviceName, {}) as SrvResponse; + setSrvState({ status: "success", response }); + + }, [context, eStopAction, config.goServiceName, config.stopServiceName]); - // Setting eStopAction based on state.latestMatchingQueriedData + // Setting eStopAction based on received state useEffect(() => { if (state.latestMatchingQueriedData != undefined) { const data = state.latestMatchingQueriedData as boolean; @@ -335,42 +292,55 @@ function EStopContent( }, [renderDone]); return ( - - -
- - {statusMessage && ( - - {statusMessage} - - )} - - - - -
+ <> + + +
+ + {statusMessage && ( + + {statusMessage} + + )} + + + + +
+
-
+ {srvState?.response?.success === false && ( + + )} + ); } diff --git a/packages/studio-base/src/panels/EStop/index.stories.tsx b/packages/studio-base/src/panels/EStop/index.stories.tsx index 699dd46777..4f62703f5c 100644 --- a/packages/studio-base/src/panels/EStop/index.stories.tsx +++ b/packages/studio-base/src/panels/EStop/index.stories.tsx @@ -15,12 +15,13 @@ import { Config } from "./types"; const successResponseJson = JSON.stringify({ success: true }, undefined, 2); const baseConfig: Config = { - goServiceName: "/set_bool", - requestPayload: `{\n "data": true\n}`, + goServiceName: "/trigger", + stopServiceName: "/reset", + statusTopicName: "/status", }; -const getFixture = ({ allowEStop }: { allowEStop: boolean }): Fixture => { - const eStop = async (service: string, _request: unknown) => { +const getFixture = ({ allowCallService }: { allowCallService: boolean }): Fixture => { + const callService = async (service: string, _request: unknown) => { if (service !== baseConfig.goServiceName) { throw new Error(`Service "${service}" does not exist`); } @@ -35,8 +36,8 @@ const getFixture = ({ allowEStop }: { allowEStop: boolean }): Fixture => { }), ), frame: {}, - capabilities: allowEStop ? [PlayerCapabilities.eStops] : [], - eStop, + capabilities: allowCallService ? [PlayerCapabilities.callServices] : [], + callService, }; }; @@ -68,7 +69,7 @@ export const EStopEnabled: StoryObj = { return ; }, - parameters: { panelSetup: { fixture: getFixture({ allowEStop: true }) } }, + parameters: { panelSetup: { fixture: getFixture({ allowCallService: true }) } }, }; export const EStopEnabledServiceName: StoryObj = { @@ -88,7 +89,7 @@ export const EStopEnabledServiceName: StoryObj = { } }, - parameters: { panelSetup: { fixture: getFixture({ allowEStop: true }) } }, + parameters: { panelSetup: { fixture: getFixture({ allowCallService: true }) } }, }; export const EStopEnabledWithCustomButtonSettings: StoryObj = { @@ -107,23 +108,7 @@ export const EStopEnabledWithCustomButtonSettings: StoryObj = { }); }, - parameters: { panelSetup: { fixture: getFixture({ allowEStop: true }) } }, -}; - -const validJSON = `{\n "a": 1,\n "b": 2,\n "c": 3\n}`; - -export const WithValidJSON: StoryObj = { - render: () => { - return ; - }, -}; - -const invalidJSON = `{\n "a": 1,\n 'b: 2,\n "c": 3\n}`; - -export const WithInvalidJSON: StoryObj = { - render: () => { - return ; - }, + parameters: { panelSetup: { fixture: getFixture({ allowCallService: true }) } }, }; export const CallingServiceThatDoesNotExist: StoryObj = { @@ -150,5 +135,5 @@ export const CallingServiceThatDoesNotExist: StoryObj = { } }, - parameters: { panelSetup: { fixture: getFixture({ allowEStop: true }) } }, + parameters: { panelSetup: { fixture: getFixture({ allowCallService: true }) } }, }; diff --git a/packages/studio-base/src/panels/EStop/settings.ts b/packages/studio-base/src/panels/EStop/settings.ts index 23aaca8ad3..d1982a3c63 100644 --- a/packages/studio-base/src/panels/EStop/settings.ts +++ b/packages/studio-base/src/panels/EStop/settings.ts @@ -12,7 +12,6 @@ import { SettingsTreeAction, SettingsTreeNodes } from "@foxglove/studio"; import { Config } from "./types"; export const defaultConfig: Config = { - requestPayload: "{}", goServiceName: "", stopServiceName: "", statusTopicName: "", @@ -34,7 +33,12 @@ export function settingsActionReducer(prevConfig: Config, action: SettingsTreeAc }); } -export function useSettingsTree(config: Config): SettingsTreeNodes { +const supportedDataTypes = ["bool"]; + +export function useSettingsTree( + config: Config, + pathParseError: string | undefined, +): SettingsTreeNodes { const settings = useMemo( (): SettingsTreeNodes => ({ general: { @@ -53,9 +57,10 @@ export function useSettingsTree(config: Config): SettingsTreeNodes { }, statusTopicName: { label: "EStop status topic", - input: "string", - error: serviceError(config.statusTopicName), + input: "messagepath", value: config.statusTopicName, + error: pathParseError, + validTypes: supportedDataTypes, }, }, }, diff --git a/packages/studio-base/src/panels/EStop/types.ts b/packages/studio-base/src/panels/EStop/types.ts index 34cd0673bb..6cd5e5e6cb 100644 --- a/packages/studio-base/src/panels/EStop/types.ts +++ b/packages/studio-base/src/panels/EStop/types.ts @@ -6,5 +6,4 @@ export type Config = { goServiceName: string; stopServiceName: string; statusTopicName: string; - requestPayload?: string; }; diff --git a/packages/studio-base/src/panels/Joy/JoyVisual.tsx b/packages/studio-base/src/panels/Joy/JoyVisual.tsx index 3f9af93e75..4d34d7862e 100644 --- a/packages/studio-base/src/panels/Joy/JoyVisual.tsx +++ b/packages/studio-base/src/panels/Joy/JoyVisual.tsx @@ -145,17 +145,23 @@ function JoyVisual(props: JoyVisualProps): JSX.Element { return (
- + - + + + - {advanced && (
-
({speed?.x.toFixed(2) ?? "0.00"}, {speed?.y.toFixed(2) ?? "0.00"})
-
)} + {advanced && ( +
+
+ ({speed?.x.toFixed(2) ?? "0.00"}, {speed?.y.toFixed(2) ?? "0.00"}) +
+
+ )}
{advanced && (
diff --git a/packages/studio-base/src/panels/Joy/styles.css b/packages/studio-base/src/panels/Joy/styles.css index 14a55582d9..80497b5918 100644 --- a/packages/studio-base/src/panels/Joy/styles.css +++ b/packages/studio-base/src/panels/Joy/styles.css @@ -5,8 +5,10 @@ } body { - --joystick-color: #888; - --joystick-head-color: #f64; + --joystick-color: #777; + --joystick-color-augmented: #444; + --joystick-head-color: #e52; + --joystick-head-color-augmented: #f63; } #container { @@ -19,16 +21,6 @@ body { padding: 2em; } -#toggle-editing { - position: absolute; - top: 1em; - left: 1em; - font-size: 1.5em; - border-radius: 1em; - cursor: pointer; - transition: #3498db 0.3s ease; -} - #joystick-container { display: flex; flex-direction: column; @@ -45,28 +37,24 @@ body { .joystick-background { fill: var(--joystick-color); - animation: animateCircle 2s infinite; stroke-width: 2; + stroke: #1b1; +} + +.joystick-handle-group { + transform-origin: 50% 50%; + animation: scalingAnimation 2s infinite ease-in-out; } -/* App.css */ -@keyframes animateCircle { +@keyframes scalingAnimation { 0% { - stroke: #26b355; - stroke-width: 2; - } - 25% { - stroke: #2dd565; + transform: scale(1); } 50% { - stroke: #2fde69; - stroke-width: 3; - } - 75% { - stroke: #2dd565; + transform: scale(1.1); } 100% { - stroke: #26b355; + transform: scale(1); } } @@ -76,11 +64,11 @@ body { } .joystick-handle:hover { - fill: #ff6d4d; + fill: var(--joystick-head-color-augmented); } .joystick-triangle { - fill: var(--joystick-color); + fill: var(--joystick-color-augmented); } #joystick-position { diff --git a/packages/studio-base/src/panels/ServiceButton/ServiceButton.tsx b/packages/studio-base/src/panels/ServiceButton/ServiceButton.tsx deleted file mode 100644 index 23146c12e9..0000000000 --- a/packages/studio-base/src/panels/ServiceButton/ServiceButton.tsx +++ /dev/null @@ -1,291 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import { Button, Palette, TextField, Tooltip, Typography, inputBaseClasses } from "@mui/material"; -import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; -import { makeStyles } from "tss-react/mui"; - -import Log from "@foxglove/log"; -import { PanelExtensionContext, SettingsTreeAction } from "@foxglove/studio"; -import Stack from "@foxglove/studio-base/components/Stack"; -import { Config } from "@foxglove/studio-base/panels/ServiceButton/types"; -import ThemeProvider from "@foxglove/studio-base/theme/ThemeProvider"; - -import { defaultConfig, settingsActionReducer, useSettingsTree } from "./settings"; - -import "./styles.css"; - - -const log = Log.getLogger(__dirname); - -type Props = { - context: PanelExtensionContext; -}; - -type State = { - status: "requesting" | "error" | "success"; - value: string; -}; - -const useStyles = makeStyles<{ buttonColor?: string }>()((theme, { buttonColor }) => { - const augmentedButtonColor = buttonColor - ? theme.palette.augmentColor({ - color: { main: buttonColor }, - }) - : undefined; - - return { - button: { - backgroundColor: augmentedButtonColor?.main, - color: augmentedButtonColor?.contrastText, - - "&:hover": { - backgroundColor: augmentedButtonColor?.dark, - }, - }, - textarea: { - height: "100%", - - [`.${inputBaseClasses.root}`]: { - backgroundColor: theme.palette.background.paper, - height: "100%", - overflow: "hidden", - padding: theme.spacing(1, 0.5), - textAlign: "left", - width: "100%", - - [`.${inputBaseClasses.input}`]: { - height: "100% !important", - lineHeight: 1.4, - fontFamily: theme.typography.fontMonospace, - overflow: "auto !important", - resize: "none", - }, - }, - }, - }; -}); - -function parseInput(value: string): { error?: string; parsedObject?: unknown } { - let parsedObject; - let error = undefined; - try { - const parsedAny: unknown = JSON.parse(value); - if (Array.isArray(parsedAny)) { - error = "Request content must be an object, not an array"; - } else if (parsedAny == undefined) { - error = "Request content must be an object, not null"; - } else if (typeof parsedAny !== "object") { - error = `Request content must be an object, not ‘${typeof parsedAny}’`; - } else { - parsedObject = parsedAny; - } - } catch (e) { - error = value.length !== 0 ? e.message : "Enter valid request content as JSON"; - } - return { error, parsedObject }; -} - -// Wrapper component with ThemeProvider so useStyles in the panel receives the right theme. -export function ServiceButton({ context }: Props): JSX.Element { - const [colorScheme, setColorScheme] = useState("light"); - - return ( - - - - ); -} - -function ServiceButtonContent( - props: Props & { setColorScheme: Dispatch> }, -): JSX.Element { - const { context, setColorScheme } = props; - - // panel extensions must notify when they've completed rendering - // onRender will setRenderDone to a done callback which we can invoke after we've rendered - const [renderDone, setRenderDone] = useState<() => void>(() => () => { }); - const [state, setState] = useState(); - const [config, setConfig] = useState(() => ({ - ...defaultConfig, - ...(context.initialState as Partial), - })); - const { classes } = useStyles({ buttonColor: config.buttonColor }); - - useEffect(() => { - context.saveState(config); - context.setDefaultPanelTitle( - config.serviceName ? `Call service ${config.serviceName}` : undefined, - ); - }, [config, context]); - - useEffect(() => { - context.watch("colorScheme"); - - context.onRender = (renderState, done) => { - setRenderDone(() => done); - setColorScheme(renderState.colorScheme ?? "light"); - }; - - return () => { - context.onRender = undefined; - }; - }, [context, setColorScheme]); - - const { error: requestParseError, parsedObject } = useMemo( - () => parseInput(config.requestPayload ?? ""), - [config.requestPayload], - ); - - const settingsActionHandler = useCallback( - (action: SettingsTreeAction) => { - setConfig((prevConfig) => settingsActionReducer(prevConfig, action)); - }, - [setConfig], - ); - - const settingsTree = useSettingsTree(config); - useEffect(() => { - context.updatePanelSettingsEditor({ - actionHandler: settingsActionHandler, - nodes: settingsTree, - }); - }, [context, settingsActionHandler, settingsTree]); - - const statusMessage = useMemo(() => { - if (context.callService == undefined) { - return "Connect to a data source that supports calling services"; - } - if (!config.serviceName) { - return "Configure a service in the panel settings"; - } - return undefined; - }, [context, config.serviceName]); - - const canServiceButton = Boolean( - context.callService != undefined && - config.requestPayload && - config.serviceName && - parsedObject != undefined && - state?.status !== "requesting", - ); - - const serviceButtonClicked = useCallback(async () => { - if (!context.callService) { - setState({ status: "error", value: "The data source does not allow calling services" }); - return; - } - - try { - setState({ status: "requesting", value: `Calling ${config.serviceName}...` }); - const response = await context.callService( - config.serviceName!, - JSON.parse(config.requestPayload!), - ); - setState({ - status: "success", - value: - JSON.stringify( - response, - // handle stringify BigInt correctly - (_key, value) => (typeof value === "bigint" ? value.toString() : value), - 2, - ) ?? "", - }); - } catch (err) { - setState({ status: "error", value: (err as Error).message }); - log.error(err); - } - }, [context, config.serviceName, config.requestPayload]); - - // Indicate render is complete - the effect runs after the dom is updated - useEffect(() => { - renderDone(); - }, [renderDone]); - - return ( - - {config.advancedView && ( - - - - Request - - { - setConfig({ ...config, requestPayload: event.target.value }); - }} - error={requestParseError != undefined} - /> - {requestParseError && ( - - {requestParseError} - - )} - - - - Response - - - - - )} - {!config.advancedView && ( - - -
- - {statusMessage && ( - - {statusMessage} - - )} - - - - - - -
-
- )} -
- ); -} diff --git a/packages/studio-base/src/panels/ToggleSrvButton/ToggleSrvButton.tsx b/packages/studio-base/src/panels/ToggleSrvButton/ToggleSrvButton.tsx new file mode 100644 index 0000000000..419962d7ee --- /dev/null +++ b/packages/studio-base/src/panels/ToggleSrvButton/ToggleSrvButton.tsx @@ -0,0 +1,339 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import { Button, Palette, Typography } from "@mui/material"; +import * as _ from "lodash-es"; +import { Dispatch, SetStateAction, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useState } from "react"; +import { makeStyles } from "tss-react/mui"; + +import { parseMessagePath, MessagePath } from "@foxglove/message-path"; +import { MessageEvent, PanelExtensionContext, SettingsTreeAction } from "@foxglove/studio"; +import { simpleGetMessagePathDataItems } from "@foxglove/studio-base/components/MessagePathSyntax/simpleGetMessagePathDataItems"; +import NotificationModal from "@foxglove/studio-base/components/NotificationModal"; +import Stack from "@foxglove/studio-base/components/Stack"; +import { Config } from "@foxglove/studio-base/panels/ToggleSrvButton/types"; +import ThemeProvider from "@foxglove/studio-base/theme/ThemeProvider"; + +import { defaultConfig, settingsActionReducer, useSettingsTree } from "./settings"; + +import "./styles.css"; + +type Props = { + context: PanelExtensionContext; +}; + +type ButtonState = "activated" | "deactivated" | undefined; +type SrvResponse = { success: boolean; message: string }; + +type SrvState = { + status: "requesting" | "error" | "success"; + response: SrvResponse | undefined; +}; + +type State = { + path: string; + parsedPath: MessagePath | undefined; + latestMessage: MessageEvent | undefined; + latestMatchingQueriedData: unknown; + error: Error | undefined; + pathParseError: string | undefined; +}; + +type Action = + | { type: "frame"; messages: readonly MessageEvent[] } + | { type: "path"; path: string } + | { type: "seek" }; + +const useStyles = makeStyles<{ action?: ButtonState; config: Config }>()((theme, { action, config }) => { + const buttonColor = action === "activated" ? config.deactivationColor : config.activationColor; + const augmentedButtonColor = theme.palette.augmentColor({ + color: { main: buttonColor }, + }); + + return { + button: { + backgroundColor: augmentedButtonColor.main, + color: augmentedButtonColor.contrastText, + + "&:hover": { + backgroundColor: augmentedButtonColor.dark, + }, + }, + }; +}); + +function getSingleDataItem(results: unknown[]) { + if (results.length <= 1) { + return results[0]; + } + throw new Error("Message path produced multiple results"); +} + +function reducer(state: State, action: Action): State { + try { + switch (action.type) { + case "frame": { + if (state.pathParseError != undefined) { + return { ...state, latestMessage: _.last(action.messages), error: undefined }; + } + let latestMatchingQueriedData = state.latestMatchingQueriedData; + let latestMessage = state.latestMessage; + if (state.parsedPath) { + for (const message of action.messages) { + if (message.topic !== state.parsedPath.topicName) { + continue; + } + const data = getSingleDataItem( + simpleGetMessagePathDataItems(message, state.parsedPath), + ); + if (data != undefined) { + latestMatchingQueriedData = data; + latestMessage = message; + } + } + } + return { ...state, latestMessage, latestMatchingQueriedData, error: undefined }; + } + case "path": { + const newPath = parseMessagePath(action.path); + let pathParseError: string | undefined; + if ( + newPath?.messagePath.some( + (part) => + (part.type === "filter" && typeof part.value === "object") || + (part.type === "slice" && + (typeof part.start === "object" || typeof part.end === "object")), + ) === true + ) { + pathParseError = "Message paths using variables are not currently supported"; + } + let latestMatchingQueriedData: unknown; + let error: Error | undefined; + try { + latestMatchingQueriedData = + newPath && pathParseError == undefined && state.latestMessage + ? getSingleDataItem(simpleGetMessagePathDataItems(state.latestMessage, newPath)) + : undefined; + } catch (err) { + error = err; + } + return { + ...state, + path: action.path, + parsedPath: newPath, + latestMatchingQueriedData, + error, + pathParseError, + }; + } + case "seek": + return { + ...state, + latestMessage: undefined, + latestMatchingQueriedData: undefined, + error: undefined, + }; + } + } catch (error) { + return { ...state, latestMatchingQueriedData: undefined, error }; + } +} + +export function ToggleSrvButton({ context }: Props): JSX.Element { + const [colorScheme, setColorScheme] = useState("light"); + + return ( + + + + ); +} + +function ToggleSrvButtonContent( + props: Props & { setColorScheme: Dispatch> }, +): JSX.Element { + const { context, setColorScheme } = props; + const [renderDone, setRenderDone] = useState<() => void>(() => () => { }); + const [srvState, setSrvState] = useState(); + const [config, setConfig] = useState(() => ({ + ...defaultConfig, + ...(context.initialState as Partial), + })); + const [buttonAction, setButtonAction] = useState(undefined); + const { classes } = useStyles({ action: buttonAction, config }); + + const [state, dispatch] = useReducer( + reducer, + { ...config, path: config.statusTopicName }, + ({ path }): State => ({ + path, + parsedPath: parseMessagePath(path), + latestMessage: undefined, + latestMatchingQueriedData: undefined, + pathParseError: undefined, + error: undefined, + }), + ); + + const handleRequestCloseNotification = () => { + setSrvState(undefined); + }; + + useLayoutEffect(() => { + dispatch({ type: "path", path: config.statusTopicName }); + }, [config.statusTopicName]); + + useEffect(() => { + context.saveState(config); + context.setDefaultPanelTitle( + config.serviceName ? `Call service ${config.serviceName}` : undefined, + ); + }, [config, context]); + + useEffect(() => { + context.watch("colorScheme"); + + context.onRender = (renderState, done) => { + setRenderDone(() => done); + setColorScheme(renderState.colorScheme ?? "light"); + + if (renderState.didSeek === true) { + dispatch({ type: "seek" }); + } + + if (renderState.currentFrame) { + dispatch({ type: "frame", messages: renderState.currentFrame }); + } + }; + context.watch("currentFrame"); + context.watch("didSeek"); + + return () => { + context.onRender = undefined; + }; + }, [context, setColorScheme]); + + useEffect(() => { + if (state.parsedPath?.topicName != undefined) { + context.subscribe([{ topic: state.parsedPath.topicName, preload: false }]); + } + return () => { + context.unsubscribeAll(); + }; + }, [context, state.parsedPath?.topicName]); + + const settingsActionHandler = useCallback( + (action: SettingsTreeAction) => { + setConfig((prevConfig) => settingsActionReducer(prevConfig, action)); + }, + [setConfig], + ); + + const settingsTree = useSettingsTree(config, state.pathParseError); + useEffect(() => { + context.updatePanelSettingsEditor({ + actionHandler: settingsActionHandler, + nodes: settingsTree, + }); + }, [context, settingsActionHandler, settingsTree]); + + const statusMessage = useMemo(() => { + if (context.callService == undefined) { + return "Connect to a data source that supports calling services"; + } + if (!config.serviceName) { + return "Configure a service in the panel settings"; + } + return undefined; + }, [context, config.serviceName]); + + const canToggleSrvButton = Boolean( + context.callService != undefined && + config.serviceName && + config.statusTopicName && + buttonAction != undefined && + srvState?.status !== "requesting", + ); + + const toggleSrvButtonClicked = useCallback(async () => { + if (!context.callService) { + setSrvState({ status: "error", response: undefined }); + return; + } + + if (buttonAction != undefined) { + setSrvState({ status: "requesting", response: undefined }); + const requestPayload = { data: buttonAction === "activated" ? false : true }; + const response = await context.callService(config.serviceName, requestPayload) as SrvResponse; + setSrvState({ status: "success", response }); + } + }, [context, buttonAction, config]); + + // Setting buttonAction based on received state + useEffect(() => { + const data = state.latestMatchingQueriedData; + if (typeof data === "boolean") { + const isDeactivated = data === config.reverseLogic; + setButtonAction(isDeactivated ? "deactivated" : "activated"); + } + }, [state.latestMatchingQueriedData, config.reverseLogic]); + + // Indicate render is complete - the effect runs after the dom is updated + useEffect(() => { + renderDone(); + }, [renderDone]); + + return ( + <> + + +
+ + {statusMessage && ( + + {statusMessage} + + )} + + + + +
+
+
+ {srvState?.response?.success === false && ( + + )} + + ); +} diff --git a/packages/studio-base/src/panels/ServiceButton/index.stories.tsx b/packages/studio-base/src/panels/ToggleSrvButton/index.stories.tsx similarity index 64% rename from packages/studio-base/src/panels/ServiceButton/index.stories.tsx rename to packages/studio-base/src/panels/ToggleSrvButton/index.stories.tsx index e98671a357..d7c34ee6fb 100644 --- a/packages/studio-base/src/panels/ServiceButton/index.stories.tsx +++ b/packages/studio-base/src/panels/ToggleSrvButton/index.stories.tsx @@ -10,17 +10,16 @@ import { PlayerCapabilities } from "@foxglove/studio-base/players/types"; import PanelSetup, { Fixture } from "@foxglove/studio-base/stories/PanelSetup"; import delay from "@foxglove/studio-base/util/delay"; -import ServiceButtonPanel from "./index"; +import ToggleSrvButtonPanel from "./index"; import { Config } from "./types"; const successResponseJson = JSON.stringify({ success: true }, undefined, 2); const baseConfig: Config = { serviceName: "/set_bool", - requestPayload: `{\n "data": true\n}`, }; -const getFixture = ({ allowServiceButton }: { allowServiceButton: boolean }): Fixture => { - const serviceButton = async (service: string, _request: unknown) => { +const getFixture = ({ allowToggleSrvButton }: { allowToggleSrvButton: boolean }): Fixture => { + const toggleSrvButton = async (service: string, _request: unknown) => { if (service !== baseConfig.serviceName) { throw new Error(`Service "${service}" does not exist`); } @@ -35,14 +34,14 @@ const getFixture = ({ allowServiceButton }: { allowServiceButton: boolean }): Fi }), ), frame: {}, - capabilities: allowServiceButton ? [PlayerCapabilities.serviceButtons] : [], - serviceButton, + capabilities: allowToggleSrvButton ? [PlayerCapabilities.toggleSrvButtons] : [], + toggleSrvButton, }; }; export default { - title: "panels/ServiceButton", - component: ServiceButtonPanel, + title: "panels/ToggleSrvButton", + component: ToggleSrvButtonPanel, parameters: { colorScheme: "both-column", }, @@ -59,27 +58,27 @@ export default { export const Default: StoryObj = { render: () => { - return ; + return ; }, }; export const DefaultHorizontalLayout: StoryObj = { render: () => { - return ; + return ; }, }; -export const ServiceButtonEnabled: StoryObj = { +export const ToggleSrvButtonEnabled: StoryObj = { render: () => { - return ; + return ; }, - parameters: { panelSetup: { fixture: getFixture({ allowServiceButton: true }) } }, + parameters: { panelSetup: { fixture: getFixture({ allowToggleSrvButton: true }) } }, }; -export const ServiceButtonEnabledServiceName: StoryObj = { +export const ToggleSrvButtonEnabledServiceName: StoryObj = { render: () => { - return ; + return ; }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -94,53 +93,44 @@ export const ServiceButtonEnabledServiceName: StoryObj = { } }, - parameters: { panelSetup: { fixture: getFixture({ allowServiceButton: true }) } }, + parameters: { panelSetup: { fixture: getFixture({ allowToggleSrvButton: true }) } }, }; -export const ServiceButtonEnabledWithCustomButtonSettings: StoryObj = { +export const ToggleSrvButtonEnabledWithCustomButtonSettings: StoryObj = { render: () => { return ( - ); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const buttons = await canvas.findAllByText("Call that funky service"); + const buttons = await canvas.findAllByText("Activate that funky service"); buttons.forEach(async (button) => { await userEvent.hover(button); }); }, - parameters: { panelSetup: { fixture: getFixture({ allowServiceButton: true }) } }, + parameters: { panelSetup: { fixture: getFixture({ allowToggleSrvButton: true }) } }, }; -const validJSON = `{\n "a": 1,\n "b": 2,\n "c": 3\n}`; export const WithValidJSON: StoryObj = { render: () => { - return ; - }, -}; - -const invalidJSON = `{\n "a": 1,\n 'b: 2,\n "c": 3\n}`; - -export const WithInvalidJSON: StoryObj = { - render: () => { - return ; + return ; }, }; export const CallingServiceThatDoesNotExist: StoryObj = { render: () => { return ( - , context: PanelExtensionContext) { @@ -20,7 +20,7 @@ function initPanel(crash: ReturnType, context: PanelExtensionCo ReactDOM.render( - + , context.panelElement, @@ -36,7 +36,7 @@ type Props = { saveConfig: SaveConfig; }; -function ServiceButtonPanelAdapter(props: Props) { +function ToggleSrvButtonPanelAdapter(props: Props) { const crash = useCrash(); const boundInitPanel = useMemo(() => initPanel.bind(undefined, crash), [crash]); @@ -50,7 +50,7 @@ function ServiceButtonPanelAdapter(props: Props) { ); } -ServiceButtonPanelAdapter.panelType = "ServiceButton"; -ServiceButtonPanelAdapter.defaultConfig = {}; +ToggleSrvButtonPanelAdapter.panelType = "ToggleSrvButton"; +ToggleSrvButtonPanelAdapter.defaultConfig = {}; -export default Panel(ServiceButtonPanelAdapter); +export default Panel(ToggleSrvButtonPanelAdapter); diff --git a/packages/studio-base/src/panels/ServiceButton/settings.ts b/packages/studio-base/src/panels/ToggleSrvButton/settings.ts similarity index 51% rename from packages/studio-base/src/panels/ServiceButton/settings.ts rename to packages/studio-base/src/panels/ToggleSrvButton/settings.ts index 4ef55a60ae..4e6510c643 100644 --- a/packages/studio-base/src/panels/ServiceButton/settings.ts +++ b/packages/studio-base/src/panels/ToggleSrvButton/settings.ts @@ -12,9 +12,13 @@ import { SettingsTreeAction, SettingsTreeNodes } from "@foxglove/studio"; import { Config } from "./types"; export const defaultConfig: Config = { - requestPayload: "{}", - layout: "vertical", - advancedView: true, + serviceName: "", + statusTopicName: "", + reverseLogic: false, + activationText: "Activate", + activationColor: "#090", + deactivationText: "Deactivate", + deactivationColor: "#900", }; function serviceError(serviceName?: string) { @@ -33,7 +37,12 @@ export function settingsActionReducer(prevConfig: Config, action: SettingsTreeAc }); } -export function useSettingsTree(config: Config): SettingsTreeNodes { +const supportedDataTypes = ["bool"]; + +export function useSettingsTree( + config: Config, + pathParseError: string | undefined, +): SettingsTreeNodes { const settings = useMemo( (): SettingsTreeNodes => ({ general: { @@ -42,35 +51,47 @@ export function useSettingsTree(config: Config): SettingsTreeNodes { label: "Service name", input: "string", error: serviceError(config.serviceName), - value: config.serviceName ?? "", + value: config.serviceName, }, - layout: { - label: "Layout", - input: "toggle", - options: [ - { label: "Vertical", value: "vertical" }, - { label: "Horizontal", value: "horizontal" }, - ], - value: config.layout ?? defaultConfig.layout, + statusTopicName: { + label: "Current State Data", + input: "messagepath", + value: config.statusTopicName, + error: pathParseError, + validTypes: supportedDataTypes, }, - advancedView: { - label: "Editing mode", + reverseLogic: { + label: "Reverse state logic", input: "boolean", - value: config.advancedView, + value: config.reverseLogic, }, }, }, button: { label: "Button", fields: { - buttonText: { - label: "Title", + activationText: { + label: "Activation Message", input: "string", - value: config.buttonText, - placeholder: `Call service ${config.serviceName ?? ""}`, + value: config.activationText, + placeholder: "Activate", + }, + activationColor: { + label: "Activation Color", + input: "rgb", + value: config.activationColor, + }, + deactivationText: { + label: "Deactivation Message", + input: "string", + value: config.deactivationText, + placeholder: "Deactivate", + }, + deactivationColor: { + label: "Deactivation Color", + input: "rgb", + value: config.deactivationColor, }, - buttonTooltip: { label: "Tooltip", input: "string", value: config.buttonTooltip }, - buttonColor: { label: "Color", input: "rgb", value: config.buttonColor }, }, }, }), diff --git a/packages/studio-base/src/panels/ServiceButton/styles.css b/packages/studio-base/src/panels/ToggleSrvButton/styles.css similarity index 100% rename from packages/studio-base/src/panels/ServiceButton/styles.css rename to packages/studio-base/src/panels/ToggleSrvButton/styles.css diff --git a/packages/studio-base/src/panels/ServiceButton/thumbnail.png b/packages/studio-base/src/panels/ToggleSrvButton/thumbnail.png similarity index 100% rename from packages/studio-base/src/panels/ServiceButton/thumbnail.png rename to packages/studio-base/src/panels/ToggleSrvButton/thumbnail.png diff --git a/packages/studio-base/src/panels/ServiceButton/types.ts b/packages/studio-base/src/panels/ToggleSrvButton/types.ts similarity index 54% rename from packages/studio-base/src/panels/ServiceButton/types.ts rename to packages/studio-base/src/panels/ToggleSrvButton/types.ts index cd68532212..4b910c4b32 100644 --- a/packages/studio-base/src/panels/ServiceButton/types.ts +++ b/packages/studio-base/src/panels/ToggleSrvButton/types.ts @@ -3,11 +3,11 @@ // file, You can obtain one at http://mozilla.org/MPL/2.0/ export type Config = { - serviceName?: string; - requestPayload?: string; - layout?: "vertical" | "horizontal"; - advancedView: boolean; - buttonText?: string; - buttonTooltip?: string; - buttonColor?: string; + serviceName: string; + statusTopicName: string; + reverseLogic: boolean; + activationText: string; + activationColor: string; + deactivationText: string; + deactivationColor: string; }; diff --git a/packages/studio-base/src/panels/index.ts b/packages/studio-base/src/panels/index.ts index dbf7653b07..d4d0c4efb5 100644 --- a/packages/studio-base/src/panels/index.ts +++ b/packages/studio-base/src/panels/index.ts @@ -20,12 +20,12 @@ import parametersThumbnail from "./Parameters/thumbnail.png"; import plotThumbnail from "./Plot/thumbnail.png"; import publishThumbnail from "./Publish/thumbnail.png"; import rawMessagesThumbnail from "./RawMessages/thumbnail.png"; -import serviceButtonThumbnail from "./ServiceButton/thumbnail.png"; import stateTransitionsThumbnail from "./StateTransitions/thumbnail.png"; import tabThumbnail from "./Tab/thumbnail.png"; import tableThumbnail from "./Table/thumbnail.png"; import teleopThumbnail from "./Teleop/thumbnail.png"; import threeDeeRenderThumbnail from "./ThreeDeeRender/thumbnail.png"; +import toggleSrvButtonThumbnail from "./ToggleSrvButton/thumbnail.png"; import topicGraphThumbnail from "./TopicGraph/thumbnail.png"; import variableSliderThumbnail from "./VariableSlider/thumbnail.png"; import diagnosticStatusThumbnail from "./diagnostics/thumbnails/diagnostic-status.png"; @@ -199,11 +199,11 @@ export const getBuiltin: (t: TFunction<"panels">) => PanelInfo[] = (t) => [ hasCustomToolbar: true, }, { - title: t("serviceButton"), - type: "ServiceButton", - description: t("serviceButtonDescription"), - thumbnail: serviceButtonThumbnail, - module: async () => await import("./ServiceButton"), + title: t("toggleSrvButton"), + type: "ToggleSrvButton", + description: t("toggleSrvButtonDescription"), + thumbnail: toggleSrvButtonThumbnail, + module: async () => await import("./ToggleSrvButton"), }, { title: t("eStop"),