Skip to content

Commit

Permalink
Make Indicator Panel works with the global vars in the message path (#…
Browse files Browse the repository at this point in the history
…320)

**User-Facing Changes**
<!-- will be used as a changelog entry -->
Enables the user to use global variables in the Indicator panel when
selecting the topic in the message path input.

Indicator panel working with _$testVariable_:
<img width="1500" alt="image"
src="https://github.com/user-attachments/assets/66a7ee6d-afb4-4dfe-a03b-2c21fc1ddc91"
/>

Autocomplete with the list of global variables:
<img width="489" alt="Screenshot 2025-01-07 at 20 39 15"
src="https://github.com/user-attachments/assets/9f7b605e-7bdf-4a96-94e1-f1f76db2663b"
/>


**Description**
- Reused existent logic to fill the paths when using global variables.
- Improved performance of the component using the proper react state
hooks.
- Improved coding with good practices.

<!-- link relevant GitHub issues -->
<!-- add `docs` label if this PR requires documentation updates -->
<!-- add relevant metric tracking for experimental / new features -->

**Checklist**

- [x] The web version was tested and it is running ok
- [x] The desktop version was tested and it is running ok
- [x] This change is covered by unit tests
- [x] Files constants.ts, types.ts and *.style.ts have been checked and
relevant code snippets have been relocated
  • Loading branch information
luluiz authored Jan 9, 2025
1 parent 3b26c47 commit 57a820a
Show file tree
Hide file tree
Showing 15 changed files with 360 additions and 139 deletions.
1 change: 1 addition & 0 deletions packages/suite-base/src/panels/Gauge/Gauge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function Gauge({ context }: GaugeProps): React.JSX.Element {
stateReducer,
config,
({ path }): GaugeAndIndicatorState => ({
globalVariables: undefined,
path,
parsedPath: parseMessagePath(path),
latestMessage: undefined,
Expand Down
18 changes: 18 additions & 0 deletions packages/suite-base/src/panels/Indicator/Indicator.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<[email protected]>
// SPDX-License-Identifier: MPL-2.0

import { makeStyles } from "tss-react/mui";

export const useStyles = makeStyles()({
root: {
width: 40,
height: 40,
borderRadius: "50%",
position: "relative",
backgroundImage: [
`radial-gradient(transparent, transparent 55%, rgba(255,255,255,0.4) 80%, rgba(255,255,255,0.4))`,
`radial-gradient(circle at 38% 35%, rgba(255,255,255,0.8), transparent 30%, transparent)`,
`radial-gradient(circle at 46% 44%, transparent, transparent 61%, rgba(0,0,0,0.7) 74%, rgba(0,0,0,0.7))`,
].join(","),
},
});
106 changes: 106 additions & 0 deletions packages/suite-base/src/panels/Indicator/Indicator.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/** @jest-environment jsdom */
// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<[email protected]>
// SPDX-License-Identifier: MPL-2.0
import { userEvent } from "@storybook/testing-library";
import { render, screen } from "@testing-library/react";
import React from "react";

import { PanelExtensionContext } from "@lichtblick/suite";
import MockPanelContextProvider from "@lichtblick/suite-base/components/MockPanelContextProvider";
import { PanelExtensionAdapter } from "@lichtblick/suite-base/components/PanelExtensionAdapter";
import Indicator from "@lichtblick/suite-base/panels/Indicator";
import { getMatchingRule } from "@lichtblick/suite-base/panels/Indicator/getMatchingRule";
import { IndicatorConfig, IndicatorProps } from "@lichtblick/suite-base/panels/Indicator/types";
import PanelSetup from "@lichtblick/suite-base/stories/PanelSetup";
import BasicBuilder from "@lichtblick/suite-base/testing/builders/BasicBuilder";
import IndicatorBuilder from "@lichtblick/suite-base/testing/builders/IndicatorBuilder";
import ThemeProvider from "@lichtblick/suite-base/theme/ThemeProvider";

jest.mock("./getMatchingRule", () => ({
getMatchingRule: jest.fn(),
}));

type Setup = {
configOverride?: Partial<IndicatorConfig>;
contextOverride?: Partial<PanelExtensionContext>;
};

describe("Indicator Component", () => {
beforeEach(() => {
jest.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
jest.clearAllMocks();
});

function setup({ contextOverride, configOverride }: Setup = {}) {
const props: IndicatorProps = {
context: {
initialState: {},
layout: {
addPanel: jest.fn(),
},
onRender: undefined,
panelElement: document.createElement("div"),
saveState: jest.fn(),
setDefaultPanelTitle: jest.fn(),
setParameter: jest.fn(),
setPreviewTime: jest.fn(),
setSharedPanelState: jest.fn(),
setVariable: jest.fn(),
subscribe: jest.fn(),
subscribeAppSettings: jest.fn(),
unsubscribeAll: jest.fn(),
updatePanelSettingsEditor: jest.fn(),
watch: jest.fn(),
...contextOverride,
},
};

const config: IndicatorConfig = {
...IndicatorBuilder.config(),
...configOverride,
};
const saveConfig = () => {};
const initPanel = jest.fn();

const ui: React.ReactElement = (
<ThemeProvider isDark>
<MockPanelContextProvider>
<PanelSetup>
<PanelExtensionAdapter config={config} saveConfig={saveConfig} initPanel={initPanel}>
<Indicator {...props} />
</PanelExtensionAdapter>
</PanelSetup>
</MockPanelContextProvider>
</ThemeProvider>
);

const matchingRule = {
color: "#68e24a",
label: BasicBuilder.string(),
};
(getMatchingRule as jest.Mock).mockReturnValue(matchingRule);

const augmentColor = jest.fn(({ color: { main } }) => ({
contrastText: `${main}-contrast`,
}));

return {
...render(ui),
config,
matchingRule,
props,
user: userEvent.setup(),
augmentColor,
};
}

it("renders Indicator component", () => {
const { matchingRule } = setup();

const element = screen.getByText(matchingRule.label);
expect(element).toBeTruthy();
});
});
103 changes: 47 additions & 56 deletions packages/suite-base/src/panels/Indicator/Indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,45 +7,21 @@

import { Typography } from "@mui/material";
import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useState } from "react";
import { makeStyles } from "tss-react/mui";

import { parseMessagePath } from "@lichtblick/message-path";
import { PanelExtensionContext, SettingsTreeAction } from "@lichtblick/suite";
import { SettingsTreeAction } from "@lichtblick/suite";
import Stack from "@lichtblick/suite-base/components/Stack";
import { GlobalVariables } from "@lichtblick/suite-base/hooks/useGlobalVariables";
import { useStyles } from "@lichtblick/suite-base/panels/Indicator/Indicator.style";
import { DEFAULT_CONFIG } from "@lichtblick/suite-base/panels/Indicator/constants";
import { stateReducer } from "@lichtblick/suite-base/panels/shared/gaugeAndIndicatorStateReducer";
import { GaugeAndIndicatorState } from "@lichtblick/suite-base/panels/types";

import { getMatchingRule } from "./getMatchingRule";
import { settingsActionReducer, useSettingsTree } from "./settings";
import { Config } from "./types";

type Props = {
context: PanelExtensionContext;
};

const defaultConfig: Config = {
path: "",
style: "bulb",
fallbackColor: "#a0a0a0",
fallbackLabel: "False",
rules: [{ operator: "=", rawValue: "true", color: "#68e24a", label: "True" }],
};

const useStyles = makeStyles()({
root: {
width: 40,
height: 40,
borderRadius: "50%",
position: "relative",
backgroundImage: [
`radial-gradient(transparent, transparent 55%, rgba(255,255,255,0.4) 80%, rgba(255,255,255,0.4))`,
`radial-gradient(circle at 38% 35%, rgba(255,255,255,0.8), transparent 30%, transparent)`,
`radial-gradient(circle at 46% 44%, transparent, transparent 61%, rgba(0,0,0,0.7) 74%, rgba(0,0,0,0.7))`,
].join(","),
},
});

export function Indicator({ context }: Props): React.JSX.Element {
import { IndicatorConfig, IndicatorProps, RawValueIndicator } from "./types";

export function Indicator({ context }: IndicatorProps): React.JSX.Element {
// 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>(() => () => {});
Expand All @@ -57,23 +33,26 @@ export function Indicator({ context }: Props): React.JSX.Element {
} = useStyles();

const [config, setConfig] = useState(() => ({
...defaultConfig,
...(context.initialState as Partial<Config>),
...DEFAULT_CONFIG,
...(context.initialState as Partial<IndicatorConfig>),
}));

const [state, dispatch] = useReducer(
stateReducer,
config,
({ path }): GaugeAndIndicatorState => ({
path,
parsedPath: parseMessagePath(path),
latestMessage: undefined,
({ path: statePath }): GaugeAndIndicatorState => ({
globalVariables: undefined,
error: undefined,
latestMatchingQueriedData: undefined,
latestMessage: undefined,
parsedPath: parseMessagePath(statePath),
path: statePath,
pathParseError: undefined,
error: undefined,
}),
);

const { error, latestMatchingQueriedData, parsedPath, pathParseError } = state;

useLayoutEffect(() => {
dispatch({ type: "path", path: config.path });
}, [config.path]);
Expand All @@ -87,6 +66,13 @@ export function Indicator({ context }: Props): React.JSX.Element {
context.onRender = (renderState, done) => {
setRenderDone(() => done);

if (renderState.variables) {
dispatch({
type: "updateGlobalVariables",
globalVariables: Object.fromEntries(renderState.variables) as GlobalVariables,
});
}

if (renderState.didSeek === true) {
dispatch({ type: "seek" });
}
Expand All @@ -97,11 +83,12 @@ export function Indicator({ context }: Props): React.JSX.Element {
};
context.watch("currentFrame");
context.watch("didSeek");
context.watch("variables");

return () => {
context.onRender = undefined;
};
}, [context]);
}, [context, dispatch]);

const settingsActionHandler = useCallback(
(action: SettingsTreeAction) => {
Expand All @@ -110,7 +97,7 @@ export function Indicator({ context }: Props): React.JSX.Element {
[setConfig],
);

const settingsTree = useSettingsTree(config, state.pathParseError, state.error?.message);
const settingsTree = useSettingsTree(config, pathParseError, error?.message);
useEffect(() => {
context.updatePanelSettingsEditor({
actionHandler: settingsActionHandler,
Expand All @@ -119,29 +106,38 @@ export function Indicator({ context }: Props): React.JSX.Element {
}, [context, settingsActionHandler, settingsTree]);

useEffect(() => {
if (state.parsedPath?.topicName != undefined) {
context.subscribe([{ topic: state.parsedPath.topicName, preload: false }]);
if (parsedPath?.topicName != undefined) {
context.subscribe([{ topic: parsedPath.topicName, preload: false }]);
}
return () => {
context.unsubscribeAll();
};
}, [context, state.parsedPath?.topicName]);
}, [context, parsedPath?.topicName]);

// Indicate render is complete - the effect runs after the dom is updated
useEffect(() => {
renderDone();
}, [renderDone]);

const rawValue =
typeof state.latestMatchingQueriedData === "boolean" ||
typeof state.latestMatchingQueriedData === "bigint" ||
typeof state.latestMatchingQueriedData === "string" ||
typeof state.latestMatchingQueriedData === "number"
? state.latestMatchingQueriedData
const rawValue = useMemo(() => {
return ["boolean", "number", "bigint", "string"].includes(typeof latestMatchingQueriedData)
? latestMatchingQueriedData
: undefined;
}, [latestMatchingQueriedData]);

const { style, rules, fallbackColor, fallbackLabel } = config;
const matchingRule = useMemo(() => getMatchingRule(rawValue, rules), [rawValue, rules]);
const matchingRule = useMemo(
() => getMatchingRule(rawValue as RawValueIndicator, rules),
[rawValue, rules],
);

const bulbStyle = useMemo(
() => ({
backgroundColor: matchingRule?.color ?? fallbackColor,
}),
[matchingRule?.color, fallbackColor],
);

return (
<Stack fullHeight>
<Stack
Expand All @@ -156,12 +152,7 @@ export function Indicator({ context }: Props): React.JSX.Element {
}}
>
<Stack direction="row" alignItems="center" gap={2}>
{style === "bulb" && (
<div
className={classes.root}
style={{ backgroundColor: matchingRule?.color ?? fallbackColor }}
/>
)}
{style === "bulb" && <div className={classes.root} style={bulbStyle} />}
<Typography
color={
style === "background"
Expand Down
11 changes: 11 additions & 0 deletions packages/suite-base/src/panels/Indicator/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: Copyright (C) 2023-2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)<[email protected]>
// SPDX-License-Identifier: MPL-2.0
import { IndicatorConfig } from "./types";

export const DEFAULT_CONFIG: IndicatorConfig = {
path: "",
style: "bulb",
fallbackColor: "#a0a0a0",
fallbackLabel: "False",
rules: [{ operator: "=", rawValue: "true", color: "#68e24a", label: "True" }],
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import { getMatchingRule } from "./getMatchingRule";
import { Rule } from "./types";
import { IndicatorRule } from "./types";

describe("getMatchingRule", () => {
it.each([
Expand All @@ -20,7 +20,7 @@ describe("getMatchingRule", () => {
[100000000000000000001n, "Large int"],
[-1.4, undefined],
])("matches %s with %s", (value, expectedLabel) => {
const rules: Rule[] = [
const rules: IndicatorRule[] = [
{
rawValue: "hello",
operator: "=",
Expand Down
14 changes: 4 additions & 10 deletions packages/suite-base/src/panels/Indicator/getMatchingRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,12 @@

import { assertNever } from "@lichtblick/suite-base/util/assertNever";

import { Rule } from "./types";
import { IndicatorRule, RawValueIndicator } from "./types";

export function getMatchingRule(
rawValue:
| undefined
| boolean
| bigint
| number
| string
| { data?: boolean | bigint | number | string },
rules: readonly Rule[],
): Rule | undefined {
rawValue: RawValueIndicator,
rules: readonly IndicatorRule[],
): IndicatorRule | undefined {
const value = typeof rawValue === "object" ? rawValue.data : rawValue;
if (value == undefined) {
return undefined;
Expand Down
Loading

0 comments on commit 57a820a

Please sign in to comment.