From 3a6cf8bbc7767458748cec5c68f0b934045976e3 Mon Sep 17 00:00:00 2001 From: David Winegar Date: Mon, 7 Oct 2019 13:26:12 -0700 Subject: [PATCH 01/50] Make lines truly instanced (#246) * Make lines truly instanced Make the lines component able to take multiple poses per marker. This will help us implement some markers that have different poses instead of different points. While we're at it, make the loading of colors into lines more efficient by passing a typed array directly to regl. Test plan: added a story, test that existing tests continue working. * Address comments --- docs/src/4.6.Lines.mdx | 13 +- docs/src/jsx/allDemos.stories.js | 2 + docs/src/jsx/allLiveEditors.js | 2 + docs/src/jsx/commands/LinesPoses.js | 63 ++++++ packages/regl-worldview/src/commands/Lines.js | 199 ++++++++++++++---- packages/regl-worldview/src/types/index.js | 1 + 6 files changed, 234 insertions(+), 46 deletions(-) create mode 100644 docs/src/jsx/commands/LinesPoses.js diff --git a/docs/src/4.6.Lines.mdx b/docs/src/4.6.Lines.mdx index 7d3753fe4..7bf51912c 100755 --- a/docs/src/4.6.Lines.mdx +++ b/docs/src/4.6.Lines.mdx @@ -1,4 +1,4 @@ -import { LinesDemo, LinesWireframe, LinesHitmap } from "./jsx/allLiveEditors"; +import { LinesDemo, LinesWireframe, LinesHitmap, LinesPoses } from "./jsx/allLiveEditors"; # Lines @@ -22,6 +22,13 @@ type Line = { position: { x: number, y: number, z: number }, orientation: { x: number, y: number, z: number, w: number } } + // Lines can optionally include multiple poses. + poses: [ + { + position: { x: number, y: number, z: number }, + orientation: { x: number, y: number, z: number, w: number } + } + ], scale: { x: number, y: number, @@ -45,6 +52,10 @@ type Line = { +## Multi-Pose Example + + + ## Mouse Interaction diff --git a/docs/src/jsx/allDemos.stories.js b/docs/src/jsx/allDemos.stories.js index bc551c33d..fbca441ac 100644 --- a/docs/src/jsx/allDemos.stories.js +++ b/docs/src/jsx/allDemos.stories.js @@ -19,6 +19,7 @@ import Cylinders from "./commands/Cylinders"; import FilledPolygons from "./commands/FilledPolygons"; import GLTFScene from "./commands/GLTFScene"; import LinesDemo from "./commands/LinesDemo"; +import LinesPoses from "./commands/LinesPoses"; import LinesWireframe from "./commands/LinesWireframe"; import Overlay from "./commands/Overlay"; import Points from "./commands/Points"; @@ -46,6 +47,7 @@ const allDemos = { FilledPolygons, Hitmap, LinesDemo, + LinesPoses, LinesWireframe, MouseEvents, Overlay, diff --git a/docs/src/jsx/allLiveEditors.js b/docs/src/jsx/allLiveEditors.js index 63569e876..23d3131fe 100755 --- a/docs/src/jsx/allLiveEditors.js +++ b/docs/src/jsx/allLiveEditors.js @@ -83,6 +83,8 @@ export const LinesDemo = makeCodeComponent(require("!!raw-loader!./commands/Line export const LinesHitmap = makeCodeComponent(require("!!raw-loader!./commands/LinesHitmap"), "LinesHitmap"); +export const LinesPoses = makeCodeComponent(require("!!raw-loader!./commands/LinesPoses"), "LinesPoses"); + export const LinesWireframe = makeCodeComponent(require("!!raw-loader!./commands/LinesWireframe"), "LinesWireframe"); export const Overlay = makeCodeComponent(require("!!raw-loader!./commands/Overlay"), "Overlay"); diff --git a/docs/src/jsx/commands/LinesPoses.js b/docs/src/jsx/commands/LinesPoses.js new file mode 100644 index 000000000..1e0a33437 --- /dev/null +++ b/docs/src/jsx/commands/LinesPoses.js @@ -0,0 +1,63 @@ +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +// #BEGIN EXAMPLE +import React from "react"; +import Worldview, { Lines } from "regl-worldview"; + +// #BEGIN EDITABLE +function Example() { + const lines = [ + { + pose: { + position: { x: 0, y: 0, z: 0 }, + orientation: { x: 0, y: 0, z: 0, w: 0 }, + }, + scale: { x: 0.1, y: 0.1, z: 0.1 }, + color: { r: 0, g: 1, b: 0, a: 1 }, + points: [ + { x: 0, y: -3, z: 1 }, + { x: 1, y: -2, z: 1 }, + { x: 0, y: -3, z: 0 }, + { x: 1, y: -2, z: 0 }, + + { x: 0, y: -3, z: 1 }, + { x: 1, y: -2, z: 1 }, + { x: 0, y: -3, z: 0 }, + { x: 1, y: -2, z: 0 }, + ], + // There are 8 points, so 4 + poses: [ + { + position: { x: 0, y: 0, z: 0 }, + orientation: { x: 0, y: 0, z: 0, w: 0 }, + }, + { + position: { x: 0, y: 0, z: 0 }, + orientation: { x: 0, y: 0, z: 0, w: 0 }, + }, + // shift the last two lines + { + position: { x: 2, y: 2, z: 0 }, + orientation: { x: 0, y: 0, z: 0, w: 0 }, + }, + { + position: { x: 2, y: 2, z: 0 }, + orientation: { x: 0, y: 0, z: 0, w: 0 }, + }, + ], + colors: [], + }, + ]; + return ( + + {lines} + + ); +} +// #END EXAMPLE + +export default Example; diff --git a/packages/regl-worldview/src/commands/Lines.js b/packages/regl-worldview/src/commands/Lines.js index 0445e97b0..1d7323f85 100755 --- a/packages/regl-worldview/src/commands/Lines.js +++ b/packages/regl-worldview/src/commands/Lines.js @@ -6,9 +6,10 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. +import flatten from "lodash/flatten"; import * as React from "react"; -import type { Line } from "../types"; +import type { Line, Vec4, Color, Pose } from "../types"; import { defaultBlend, withPose, toRGBA, shouldConvert, pointToVec3 } from "../utils/commandUtils"; import { nonInstancedGetChildrenForHitmap } from "../utils/getChildrenForHitmapDefaults"; import Command, { type CommonCommandProps } from "./Command"; @@ -87,6 +88,9 @@ attribute vec3 positionA; attribute vec3 positionB; attribute vec3 positionC; attribute vec3 positionD; +// per-instance pose attributes +attribute vec3 posePosition; +attribute vec4 poseRotation; uniform mat4 projection, view; uniform float viewportWidth; @@ -104,6 +108,12 @@ ${Object.keys(POINT_TYPES) #WITH_POSE +vec3 applyPoseInstance(vec3 point, vec4 rotation, vec3 position) { + // rotate the point and then add the position of the pose + // this function is defined in WITH_POSE + return rotate(point, rotation) + position; +} + vec2 rotateCCW(vec2 v) { return vec2(-v.y, v.x); } @@ -145,10 +155,10 @@ void main () { float scale = isTop ? 1. : -1.; mat4 projView = projection * view; - vec4 projA = projView * vec4(applyPose(positionA), 1); - vec4 projB = projView * vec4(applyPose(positionB), 1); - vec4 projC = projView * vec4(applyPose(positionC), 1); - vec4 projD = projView * vec4(applyPose(positionD), 1); + vec4 projA = projView * vec4(applyPose(applyPoseInstance(positionA, poseRotation, posePosition)), 1); + vec4 projB = projView * vec4(applyPose(applyPoseInstance(positionB, poseRotation, posePosition)), 1); + vec4 projC = projView * vec4(applyPose(applyPoseInstance(positionC, poseRotation, posePosition)), 1); + vec4 projD = projView * vec4(applyPose(applyPoseInstance(positionD, poseRotation, posePosition)), 1); vec2 aspectVec = vec2(viewportWidth / viewportHeight, 1.0); vec2 screenA = projA.xy / projA.w * aspectVec; @@ -240,6 +250,19 @@ const lines = (regl: any) => { [1, 0, 1, 1], // magenta ], }); + // The pose position and rotation buffers contain the identity position/rotation, for use when we don't have instanced + // poses. + const defaultPosePositionBuffer = regl.buffer({ + type: "float", + usage: "static", + data: flatten(new Array(VERTICES_PER_INSTANCE).fill([0, 0, 0])), + }); + const defaultPoseRotationBuffer = regl.buffer({ + type: "float", + usage: "static", + // Rotation array identity is [x: 0, y: 0, z: 0, w: 1] + data: flatten(new Array(VERTICES_PER_INSTANCE).fill([0, 0, 0, 1])), + }); // The buffers used for input position & color data const colorBuffer = regl.buffer({ type: "float" }); @@ -251,6 +274,9 @@ const lines = (regl: any) => { const positionBuffer1 = regl.buffer({ type: "float" }); const positionBuffer2 = regl.buffer({ type: "float" }); + const posePositionBuffer = regl.buffer({ type: "float" }); + const poseRotationBuffer = regl.buffer({ type: "float" }); + const command = regl( withPose({ vert, @@ -290,7 +316,7 @@ const lines = (regl: any) => { stride: (joined ? 1 : 2) * POINT_BYTES, divisor: 1, }), - positionC: (context, { joined, instances }) => ({ + positionC: (context, { joined }) => ({ buffer: positionBuffer2, offset: 2 * POINT_BYTES, stride: (joined ? 1 : 2) * POINT_BYTES, @@ -302,6 +328,14 @@ const lines = (regl: any) => { stride: (joined ? 1 : 2) * POINT_BYTES, divisor: 1, }), + posePosition: (context, { hasInstancedPoses }) => ({ + buffer: hasInstancedPoses ? posePositionBuffer : defaultPosePositionBuffer, + divisor: hasInstancedPoses ? 1 : 0, + }), + poseRotation: (context, { hasInstancedPoses }) => ({ + buffer: hasInstancedPoses ? poseRotationBuffer : defaultPoseRotationBuffer, + divisor: hasInstancedPoses ? 1 : 0, + }), }, count: VERTICES_PER_INSTANCE, instances: regl.prop("instances"), @@ -309,12 +343,13 @@ const lines = (regl: any) => { }) ); - // array reused for colors when monochrome - const monochromeColors = new Array(VERTICES_PER_INSTANCE); + let colorArray = new Float32Array(VERTICES_PER_INSTANCE * 4); let pointArray = new Float32Array(0); let allocatedPoints = 0; + let positionArray = new Float32Array(0); + let rotationArray = new Float32Array(0); - const fillPointArray = (points: any[], alreadyClosed: boolean, shouldClose: boolean) => { + function fillPointArray(points: any[], alreadyClosed: boolean, shouldClose: boolean) { const numTotalPoints = points.length + (shouldClose ? 3 : 2); if (allocatedPoints < numTotalPoints) { pointArray = new Float32Array(numTotalPoints * 3); @@ -346,7 +381,79 @@ const lines = (regl: any) => { pointArray.copyWithin(0, 3, 6); pointArray.copyWithin(n - 3, n - 6, n - 3); } - }; + } + + function fillPoseArrays(instances: number, poses: Pose[]): ?Error { + if (positionArray.length < instances * 3) { + positionArray = new Float32Array(instances * 3); + rotationArray = new Float32Array(instances * 4); + } + for (let index = 0; index < poses.length; index++) { + const positionOffset = index * 3; + const rotationOffset = index * 4; + const { position, orientation: r } = poses[index]; + const convertedPosition = Array.isArray(position) ? position : pointToVec3(position); + positionArray[positionOffset + 0] = convertedPosition[0]; + positionArray[positionOffset + 1] = convertedPosition[1]; + positionArray[positionOffset + 2] = convertedPosition[2]; + + const convertedRotation = Array.isArray(r) ? r : [r.x, r.y, r.z, r.w]; + rotationArray[rotationOffset + 0] = convertedRotation[0]; + rotationArray[rotationOffset + 1] = convertedRotation[1]; + rotationArray[rotationOffset + 2] = convertedRotation[2]; + rotationArray[rotationOffset + 3] = convertedRotation[3]; + } + } + + function convertColors(colors: any): Vec4[] { + return shouldConvert(colors) ? colors.map(toRGBA) : colors; + } + + function fillColorArray( + color: ?Color | ?Vec4, + colors: ?((Color | Vec4)[]), + monochrome: boolean, + shouldClose: boolean + ) { + if (monochrome) { + if (colorArray.length < VERTICES_PER_INSTANCE * 4) { + colorArray = new Float32Array(VERTICES_PER_INSTANCE * 4); + } + const monochromeColor = color || DEFAULT_MONOCHROME_COLOR; + const [convertedMonochromeColor] = convertColors([monochromeColor]); + const [r, g, b, a] = convertedMonochromeColor; + for (let index = 0; index < VERTICES_PER_INSTANCE; index++) { + const offset = index * 4; + colorArray[offset + 0] = r; + colorArray[offset + 1] = g; + colorArray[offset + 2] = b; + colorArray[offset + 3] = a; + } + } else if (colors) { + const length = shouldClose ? colors.length + 1 : colors.length; + if (colorArray.length < length * 4) { + colorArray = new Float32Array(length * 4); + } + const convertedColors = convertColors(colors); + for (let index = 0; index < convertedColors.length; index++) { + const offset = index * 4; + const [r, g, b, a] = convertedColors[index]; + colorArray[offset + 0] = r; + colorArray[offset + 1] = g; + colorArray[offset + 2] = b; + colorArray[offset + 3] = a; + } + + if (shouldClose) { + const [r, g, b, a] = convertedColors[0]; + const lastIndex = length - 1; + colorArray[lastIndex * 4 + 0] = r; + colorArray[lastIndex * 4 + 1] = g; + colorArray[lastIndex * 4 + 2] = b; + colorArray[lastIndex * 4 + 3] = a; + } + } + } // Disable depth for debug rendering (so lines stay visible) const render = (debug, commands) => { @@ -358,7 +465,7 @@ const lines = (regl: any) => { }; // Render one line list/strip - const renderLine = (props) => { + function renderLine(props) { const { debug, primitive = "lines", scaleInvariant = false } = props; const numInputPoints = props.points.length; @@ -375,52 +482,54 @@ const lines = (regl: any) => { positionBuffer2({ data: pointArray, usage: "dynamic" }); const monochrome = !(props.colors && props.colors.length); - let colors; - if (monochrome) { - if (shouldConvert(props.color)) { - colors = monochromeColors.fill(props.color ? toRGBA(props.color) : DEFAULT_MONOCHROME_COLOR); - } else { - colors = monochromeColors.fill(props.color || DEFAULT_MONOCHROME_COLOR); - } - } else { - if (shouldConvert(props.colors)) { - colors = props.colors.map(toRGBA); - } else { - colors = props.colors.slice(); - } - if (shouldClose) { - colors.push(colors[0]); - } - } - colorBuffer({ data: colors, usage: "dynamic" }); + fillColorArray(props.color, props.colors, monochrome, shouldClose); + colorBuffer({ data: colorArray, usage: "dynamic" }); const joined = primitive === "line strip"; const effectiveNumPoints = numInputPoints + (shouldClose ? 1 : 0); const instances = joined ? effectiveNumPoints - 1 : Math.floor(effectiveNumPoints / 2); + // fill instanced pose buffers + const { poses } = props; + const hasInstancedPoses = !!poses && poses.length > 0; + if (hasInstancedPoses && poses) { + if (instances !== poses.length) { + console.error(`Expected ${instances} poses but given ${poses.length} poses: will result in webgl error.`); + return; + } + fillPoseArrays(instances, poses); + posePositionBuffer({ data: positionArray, usage: "dynamic" }); + poseRotationBuffer({ data: rotationArray, usage: "dynamic" }); + } + render(debug, () => { - command({ - ...props, - joined, - primitive: "triangle strip", - alpha: debug ? 0.2 : 1, - monochrome, - instances, - scaleInvariant, - }); - if (debug) { - command({ - ...props, + // Use Object.assign because it's actually faster than babel's object spread polyfill. + command( + Object.assign({}, props, { joined, - primitive: "line strip", - alpha: 1, + primitive: "triangle strip", + alpha: debug ? 0.2 : 1, monochrome, instances, scaleInvariant, - }); + hasInstancedPoses, + }) + ); + if (debug) { + command( + Object.assign({}, props, { + joined, + primitive: "line strip", + alpha: 1, + monochrome, + instances, + scaleInvariant, + hasInstancedPoses, + }) + ); } }); - }; + } return (inProps: any) => { if (Array.isArray(inProps)) { diff --git a/packages/regl-worldview/src/types/index.js b/packages/regl-worldview/src/types/index.js index 27d303d12..20ff5a6ce 100755 --- a/packages/regl-worldview/src/types/index.js +++ b/packages/regl-worldview/src/types/index.js @@ -179,6 +179,7 @@ export type Cylinder = BaseShape & { export type Line = BaseShape & { points: (Point | Vec3)[], + poses?: Pose[], }; export type PointType = BaseShape & { From 35898d87a23348df98d78250d0e9e12b3b87d5ee Mon Sep 17 00:00:00 2001 From: David Winegar Date: Tue, 8 Oct 2019 16:11:32 -0700 Subject: [PATCH 02/50] Fix minor Worldview issues and release new version (#249) * No longer require colors to enable instancing * Remove memo from grid * Bump worldview to 0.2.4 --- packages/regl-worldview/package-lock.json | 2 +- packages/regl-worldview/package.json | 2 +- packages/regl-worldview/src/commands/Grid.js | 4 +--- .../regl-worldview/src/utils/getChildrenForHitmapDefaults.js | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/regl-worldview/package-lock.json b/packages/regl-worldview/package-lock.json index 43a76c5a3..8a0598ea6 100755 --- a/packages/regl-worldview/package-lock.json +++ b/packages/regl-worldview/package-lock.json @@ -1,6 +1,6 @@ { "name": "regl-worldview", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/packages/regl-worldview/package.json b/packages/regl-worldview/package.json index 6a98e944d..b54abd9f9 100755 --- a/packages/regl-worldview/package.json +++ b/packages/regl-worldview/package.json @@ -1,6 +1,6 @@ { "name": "regl-worldview", - "version": "0.2.3", + "version": "0.2.4", "description": "A reusable component for rendering 2D and 3D views using regl", "license": "Apache-2.0", "repository": "cruise-automation/webviz/tree/master/packages/regl-worldview", diff --git a/packages/regl-worldview/src/commands/Grid.js b/packages/regl-worldview/src/commands/Grid.js index 10fd537b3..417a7574f 100755 --- a/packages/regl-worldview/src/commands/Grid.js +++ b/packages/regl-worldview/src/commands/Grid.js @@ -70,7 +70,7 @@ type Props = { // useful for rendering a grid for debugging in stories -function Grid({ count, ...rest }: Props) { +export default function Grid({ count, ...rest }: Props) { const children = { count }; return ( @@ -80,5 +80,3 @@ function Grid({ count, ...rest }: Props) { } Grid.defaultProps = { count: 6 }; - -export default React.memo(Grid); diff --git a/packages/regl-worldview/src/utils/getChildrenForHitmapDefaults.js b/packages/regl-worldview/src/utils/getChildrenForHitmapDefaults.js index 18b3dc112..71544695c 100644 --- a/packages/regl-worldview/src/utils/getChildrenForHitmapDefaults.js +++ b/packages/regl-worldview/src/utils/getChildrenForHitmapDefaults.js @@ -72,7 +72,7 @@ function instancedGetChildrenForHitmapFromSingleProp( const idColors = assignNextColors(prop, instanceCount); const startColor = idColors[0]; // We have to map these instance colors to `pointCountPerInstance` number of points - if (hitmapProp.colors && hitmapProp.points && hitmapProp.points.length) { + if (hitmapProp.points && hitmapProp.points.length) { const allColors = new Array(hitmapProp.points.length).fill().map(() => startColor); for (let i = 0; i < instanceCount; i++) { for (let j = 0; j < pointCountPerInstance; j++) { From 2d4f677b057b27c8a6dcb7307ba4134d655f55c6 Mon Sep 17 00:00:00 2001 From: Jan Paul Posma Date: Thu, 17 Oct 2019 16:04:57 -0700 Subject: [PATCH 03/50] Update packages/webviz-core from internal repo (#251) Changelog: - Fixed zoom issue in plot panel. - Allow full-screen panel locking by holding `` ` `` + `shift` and clicking a panel. - In the Plot panel, we now default the "timestamp method" (`receiveTime` vs `header.stamp`) to the last used value. - Added ability to save playback speed to layout config. - Added ability to search panels by name. - Moved the filter feature in Diagnostic Summary panel and got rid of it in Diagnostic Detail panel. - Fixed issue so that navigating away from a new tab before it loads will still start data loading. - Fixed a memory leak in the 3D panel. - Added ability to plot timestamp values in Raw Messages and Plot panels. - Updated React to latest version. - In tests, enabled `restoreMocks`. --- jest/jest.config.js | 2 +- package-lock.json | 173 ++-- package.json | 8 +- .../hooks/src/useAbortable.test.js | 5 + packages/webviz-core/.eslintrc.js | 11 + packages/webviz-core/package-lock.json | 37 +- packages/webviz-core/package.json | 7 +- .../webviz-core/shared/parentTopicStyles.js | 17 + packages/webviz-core/src/PanelAPI/README.md | 77 ++ packages/webviz-core/src/PanelAPI/index.js | 17 + .../src/PanelAPI/useDataSourceInfo.js | 57 ++ .../src/PanelAPI/useDataSourceInfo.test.js | 149 +++ .../src/PanelAPI/useMessages.test.js | 271 ++++++ packages/webviz-core/src/actions/index.js | 4 +- .../src/actions/nodeDiagnostics.js | 23 - packages/webviz-core/src/actions/panels.js | 26 +- packages/webviz-core/src/actions/userNodes.js | 60 ++ .../src/components/AppMenu/index.js | 50 +- .../src/components/AppMenu/index.stories.js | 3 +- packages/webviz-core/src/components/Button.js | 15 +- .../webviz-core/src/components/Checkbox.js | 33 +- .../src/components/Dropdown/index.js | 7 +- .../src/components/ExpandingToolbar.js | 6 +- packages/webviz-core/src/components/Flex.js | 5 +- .../GlobalVariablesAccessor/index.js | 18 +- .../webviz-core/src/components/LayoutMenu.js | 18 +- .../webviz-core/src/components/LayoutModal.js | 56 ++ .../MessageHistory/FrameCompatibility.js | 154 +--- .../MessageHistory/FrameCompatibility.test.js | 5 +- .../MessageHistory/MessageHistoryInput.js | 42 +- .../MessageHistoryOnlyTopics.js | 81 +- .../MessageHistory/getMessageHistoryItem.js | 12 +- .../getMessageHistoryItem.test.js | 6 +- .../src/components/MessageHistory/hooks.js | 154 +++- .../components/MessageHistory/hooks.test.js | 299 +++++- .../src/components/MessageHistory/index.js | 49 +- .../MessageHistory/index.stories.js | 39 +- .../components/MessageHistory/index.test.js | 14 +- .../src/components/MessagePipeline/index.js | 151 +-- .../components/MessagePipeline/index.test.js | 59 +- .../warnOnOutOfSyncMessages.js | 23 +- .../warnOnOutOfSyncMessages.test.js | 58 ++ packages/webviz-core/src/components/Panel.js | 87 +- .../src/components/Panel.module.scss | 44 +- .../src/components/PanelContext.js | 5 + .../src/components/PerfMonitor/index.js | 86 -- .../PerfMonitor/reconciliationPerf.js | 63 -- .../PerfMonitor/reconciliationPerf.test.js | 69 -- .../src/components/PlaybackControls/index.js | 41 +- .../PlaybackControls/index.stories.js | 37 +- .../src/components/PlayerManager.js | 28 +- packages/webviz-core/src/components/Root.js | 70 +- .../src/components/SegmentControl.js | 72 ++ .../src/components/SegmentControl.stories.js | 80 ++ .../src/components/ShareJsonModal.test.js | 2 +- .../webviz-core/src/components/TextContent.js | 5 +- .../webviz-core/src/components/TextField.js | 161 ++++ .../src/components/TextField.stories.js | 122 +++ .../src/components/TimeBasedChart/index.js | 54 +- .../src/components/Toolbar.module.scss | 3 +- .../src/components/ValidatedInput.js | 64 +- .../src/components/ValidatedInput.stories.js | 113 ++- .../src/components/renderToBody.js | 9 +- .../webviz-core/src/components/validators.js | 15 +- .../src/components/validators.test.js | 16 + .../IdbCacheDataProviderDatabase.js | 2 +- packages/webviz-core/src/globals.js.flow | 3 +- .../webviz-core/src/hooks/useGlobalData.js | 27 - .../src/hooks/useGlobalVariables.js | 27 + packages/webviz-core/src/loadWebviz.js | 57 +- .../src/panels/GlobalVariables/index.help.md | 2 + .../src/panels/GlobalVariables/index.js | 67 +- .../panels/GlobalVariables/index.stories.js | 4 +- .../src/panels/ImageView/ImageCanvas.js | 4 +- packages/webviz-core/src/panels/Internals.js | 9 +- .../BottomBar/DiagnosticsSection.js | 63 ++ .../NodePlayground/BottomBar/LogsSection.js | 87 ++ .../panels/NodePlayground/BottomBar/index.js | 142 +++ .../src/panels/NodePlayground/Editor.js | 107 ++- .../src/panels/NodePlayground/Sidebar.js | 275 ++++-- .../src/panels/NodePlayground/index.help.md | 357 ++++++- .../src/panels/NodePlayground/index.js | 328 +++++-- .../panels/NodePlayground/index.module.scss | 14 - .../panels/NodePlayground/index.stories.js | 339 ++++++- .../src/panels/NodePlayground/index.test.md | 21 + .../NodePlayground/theme/vs-webviz.json | 2 +- .../NodePlayground/theme/vs-webviz.tmTheme | 2 +- .../webviz-core/src/panels/NumberOfRenders.js | 57 +- .../{PanelList.js => PanelList/index.js} | 50 +- .../src/panels/PanelList/index.stories.js | 58 ++ .../webviz-core/src/panels/Plot/PlotChart.js | 82 +- .../webviz-core/src/panels/Plot/PlotLegend.js | 17 +- .../webviz-core/src/panels/Plot/PlotMenu.js | 45 + .../src/panels/Plot/PlotMenu.stories.js | 5 +- .../src/panels/Plot/PlotMenu.test.js | 222 +++++ .../Plot/__snapshots__/PlotMenu.test.js.snap | 37 + packages/webviz-core/src/panels/Plot/index.js | 54 +- .../src/panels/Plot/index.stories.js | 152 +-- .../webviz-core/src/panels/SubscribeToList.js | 47 + .../src/panels/SubscribeToList.stories.js | 37 + .../panels/ThreeDimensionalViz/Crosshair.js | 77 ++ .../DrawingTools/CameraInfo.js | 16 +- .../DrawingTools/MeasuringTool.js | 47 +- .../ThreeDimensionalViz/DrawingTools/index.js | 57 +- .../DrawingTools/index.stories.js | 22 +- .../ThreeDimensionalViz/FollowTFControl.js | 18 +- .../LinkToGlobalVariable.js | 6 +- .../UnlinkGlobalVariables.stories.js | 2 +- .../Interactions/GlobalVariableLink/index.js | 2 +- .../Interactions/Interaction.stories.js | 31 +- .../Interactions/Interactions.js | 18 +- .../Interactions/ObjectDetails.js | 58 +- .../Interactions/PointCloudDetails.js | 2 +- .../src/panels/ThreeDimensionalViz/Layout.js | 512 +++++----- .../ThreeDimensionalViz/MeasureMarker.js | 64 ++ .../ThreeDimensionalViz/SceneBuilder/index.js | 7 +- .../TopicSelector/index.js | 101 +- .../src/panels/ThreeDimensionalViz/World.js | 188 ++-- .../src/panels/ThreeDimensionalViz/index.js | 490 ++++------ .../threeDimensionalVizUtils.js | 102 ++ .../panels/diagnostics/DiagnosticSummary.js | 35 +- .../panels/diagnostics/DiagnosticsHistory.js | 73 +- .../src/panels/diagnostics/util.js | 14 + .../src/panels/diagnostics/util.test.js | 75 +- .../src/players/RandomAccessPlayer.js | 89 +- .../src/players/RandomAccessPlayer.test.js | 276 +++++- .../src/players/UserNodePlayer/index.js | 133 ++- .../src/players/UserNodePlayer/index.test.js | 620 ++++++++++++- .../nodeRuntimeWorker/registry.js | 96 +- .../nodeRuntimeWorker/registry.test.js | 50 + .../players/UserNodePlayer/nodeSecurity.js | 38 + .../UserNodePlayer/nodeSecurity.test.js | 37 + .../nodeTransformerWorker/transformer.js | 124 ++- .../nodeTransformerWorker/transformer.test.js | 872 +++++++++++++++++- .../nodeTransformerWorker/typescript/ast.js | 387 ++++++++ .../typescript/baseDatatypes.js | 87 ++ .../nodeTransformerWorker/typescript/debug.js | 102 ++ .../typescript/errors.js | 92 ++ .../nodeTransformerWorker/typescript/lib.js | 42 + .../nodeTransformerWorker/typescript/ros.js | 135 +++ .../nodeTransformerWorker/utils.js | 7 +- .../src/players/UserNodePlayer/types.js | 63 +- packages/webviz-core/src/reducers/index.js | 4 +- .../src/reducers/nodeDiagnostics.js | 32 - packages/webviz-core/src/reducers/panels.js | 30 +- .../webviz-core/src/reducers/panels.test.js | 57 +- .../webviz-core/src/reducers/userNodes.js | 73 ++ .../src/reducers/userNodes.test.js | 43 + .../webviz-core/src/store/getGlobalStore.js | 24 + .../webviz-core/src/stories/PanelSetup.js | 73 +- .../webviz-core/src/styles/variables.scss | 2 + packages/webviz-core/src/test/setup.js | 19 + packages/webviz-core/src/types/Messages.js | 16 +- packages/webviz-core/src/types/Scene.js | 2 + packages/webviz-core/src/types/panels.js | 11 +- packages/webviz-core/src/util.js | 14 +- packages/webviz-core/src/util/Logger.js | 10 +- packages/webviz-core/src/util/colors.js | 2 + packages/webviz-core/src/util/demoLayout.json | 2 +- .../webviz-core/src/util/globalConstants.js | 13 +- packages/webviz-core/src/util/history.js | 2 +- .../src/util/indexeddb/MetaDatabase.js | 11 +- .../webviz-core/src/util/migratePanels.js | 62 ++ .../src/util/migratePanels.test.js | 89 ++ packages/webviz-core/src/util/selectors.js | 8 +- packages/webviz-core/src/util/time.test.js | 2 + 166 files changed, 9661 insertions(+), 2556 deletions(-) create mode 100644 packages/webviz-core/shared/parentTopicStyles.js create mode 100644 packages/webviz-core/src/PanelAPI/README.md create mode 100644 packages/webviz-core/src/PanelAPI/index.js create mode 100644 packages/webviz-core/src/PanelAPI/useDataSourceInfo.js create mode 100644 packages/webviz-core/src/PanelAPI/useDataSourceInfo.test.js create mode 100644 packages/webviz-core/src/PanelAPI/useMessages.test.js delete mode 100644 packages/webviz-core/src/actions/nodeDiagnostics.js create mode 100644 packages/webviz-core/src/actions/userNodes.js create mode 100644 packages/webviz-core/src/components/LayoutModal.js create mode 100644 packages/webviz-core/src/components/MessagePipeline/warnOnOutOfSyncMessages.test.js delete mode 100644 packages/webviz-core/src/components/PerfMonitor/index.js delete mode 100644 packages/webviz-core/src/components/PerfMonitor/reconciliationPerf.js delete mode 100644 packages/webviz-core/src/components/PerfMonitor/reconciliationPerf.test.js create mode 100644 packages/webviz-core/src/components/SegmentControl.js create mode 100644 packages/webviz-core/src/components/SegmentControl.stories.js create mode 100644 packages/webviz-core/src/components/TextField.js create mode 100644 packages/webviz-core/src/components/TextField.stories.js delete mode 100644 packages/webviz-core/src/hooks/useGlobalData.js create mode 100644 packages/webviz-core/src/hooks/useGlobalVariables.js create mode 100644 packages/webviz-core/src/panels/NodePlayground/BottomBar/DiagnosticsSection.js create mode 100644 packages/webviz-core/src/panels/NodePlayground/BottomBar/LogsSection.js create mode 100644 packages/webviz-core/src/panels/NodePlayground/BottomBar/index.js delete mode 100644 packages/webviz-core/src/panels/NodePlayground/index.module.scss create mode 100644 packages/webviz-core/src/panels/NodePlayground/index.test.md rename packages/webviz-core/src/panels/{PanelList.js => PanelList/index.js} (84%) create mode 100644 packages/webviz-core/src/panels/PanelList/index.stories.js create mode 100644 packages/webviz-core/src/panels/Plot/PlotMenu.test.js create mode 100644 packages/webviz-core/src/panels/Plot/__snapshots__/PlotMenu.test.js.snap create mode 100644 packages/webviz-core/src/panels/SubscribeToList.js create mode 100644 packages/webviz-core/src/panels/SubscribeToList.stories.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/Crosshair.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/MeasureMarker.js create mode 100644 packages/webviz-core/src/panels/ThreeDimensionalViz/threeDimensionalVizUtils.js create mode 100644 packages/webviz-core/src/players/UserNodePlayer/nodeRuntimeWorker/registry.test.js create mode 100644 packages/webviz-core/src/players/UserNodePlayer/nodeSecurity.js create mode 100644 packages/webviz-core/src/players/UserNodePlayer/nodeSecurity.test.js create mode 100644 packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/ast.js create mode 100644 packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/baseDatatypes.js create mode 100644 packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/debug.js create mode 100644 packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/errors.js create mode 100644 packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/lib.js create mode 100644 packages/webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/ros.js delete mode 100644 packages/webviz-core/src/reducers/nodeDiagnostics.js create mode 100644 packages/webviz-core/src/reducers/userNodes.js create mode 100644 packages/webviz-core/src/reducers/userNodes.test.js create mode 100644 packages/webviz-core/src/store/getGlobalStore.js create mode 100644 packages/webviz-core/src/styles/variables.scss create mode 100644 packages/webviz-core/src/util/migratePanels.js create mode 100644 packages/webviz-core/src/util/migratePanels.test.js diff --git a/jest/jest.config.js b/jest/jest.config.js index e640e4bda..4926db2e4 100644 --- a/jest/jest.config.js +++ b/jest/jest.config.js @@ -18,7 +18,7 @@ module.exports = { }, moduleDirectories: ["/packages", "node_modules"], moduleFileExtensions: ["web.js", "js", "json", "web.jsx", "jsx", "node"], - resetMocks: true, + restoreMocks: true, setupFiles: [ "/packages/webviz-core/src/test/setup.js", "/jest/configureEnzyme.js", diff --git a/package-lock.json b/package-lock.json index 001df6dbf..a72306bfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7066,7 +7066,7 @@ "dependencies": { "globby": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "resolved": "http://registry.npmjs.org/globby/-/globby-6.1.0.tgz", "integrity": "sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=", "dev": true, "requires": { @@ -7079,7 +7079,7 @@ "dependencies": { "pify": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true } @@ -9390,28 +9390,28 @@ "dependencies": { "abbrev": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "resolved": false, "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true, "optional": true }, "ansi-regex": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "resolved": false, "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", "dev": true, "optional": true }, "aproba": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "resolved": false, "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", "dev": true, "optional": true }, "are-we-there-yet": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.4.tgz", + "resolved": false, "integrity": "sha1-u13KOCu5TwXhUZQ3PRb9O6HKEQ0=", "dev": true, "optional": true, @@ -9422,14 +9422,14 @@ }, "balanced-match": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "resolved": false, "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true, "optional": true }, "brace-expansion": { "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "resolved": false, "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "optional": true, @@ -9440,42 +9440,42 @@ }, "chownr": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.0.1.tgz", + "resolved": false, "integrity": "sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE=", "dev": true, "optional": true }, "code-point-at": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "resolved": false, "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "dev": true, "optional": true }, "concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "resolved": false, "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true, "optional": true }, "console-control-strings": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "resolved": false, "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", "dev": true, "optional": true }, "core-util-is": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "resolved": false, "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true, "optional": true }, "debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "resolved": false, "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "optional": true, @@ -9485,28 +9485,28 @@ }, "deep-extend": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.5.1.tgz", + "resolved": false, "integrity": "sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w==", "dev": true, "optional": true }, "delegates": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "resolved": false, "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "dev": true, "optional": true }, "detect-libc": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "resolved": false, "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", "dev": true, "optional": true }, "fs-minipass": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz", + "resolved": false, "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==", "dev": true, "optional": true, @@ -9516,14 +9516,14 @@ }, "fs.realpath": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "resolved": false, "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true, "optional": true }, "gauge": { "version": "2.7.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "resolved": false, "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", "dev": true, "optional": true, @@ -9540,7 +9540,7 @@ }, "glob": { "version": "7.1.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "resolved": false, "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", "dev": true, "optional": true, @@ -9555,14 +9555,14 @@ }, "has-unicode": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "resolved": false, "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", "dev": true, "optional": true }, "iconv-lite": { "version": "0.4.21", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.21.tgz", + "resolved": false, "integrity": "sha512-En5V9za5mBt2oUA03WGD3TwDv0MKAruqsuxstbMUZaj9W9k/m1CV/9py3l0L5kw9Bln8fdHQmzHSYtvpvTLpKw==", "dev": true, "optional": true, @@ -9572,7 +9572,7 @@ }, "ignore-walk": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.1.tgz", + "resolved": false, "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==", "dev": true, "optional": true, @@ -9582,7 +9582,7 @@ }, "inflight": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "resolved": false, "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "dev": true, "optional": true, @@ -9593,21 +9593,21 @@ }, "inherits": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "resolved": false, "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", "dev": true, "optional": true }, "ini": { "version": "1.3.5", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "resolved": false, "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "dev": true, "optional": true }, "is-fullwidth-code-point": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "resolved": false, "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, "optional": true, @@ -9617,14 +9617,14 @@ }, "isarray": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "resolved": false, "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true, "optional": true }, "minimatch": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "resolved": false, "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, "optional": true, @@ -9634,14 +9634,14 @@ }, "minimist": { "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "resolved": false, "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", "dev": true, "optional": true }, "minipass": { "version": "2.2.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.2.4.tgz", + "resolved": false, "integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==", "dev": true, "optional": true, @@ -9652,7 +9652,7 @@ }, "minizlib": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.1.0.tgz", + "resolved": false, "integrity": "sha512-4T6Ur/GctZ27nHfpt9THOdRZNgyJ9FZchYO1ceg5S8Q3DNLCKYy44nCZzgCJgcvx2UM8czmqak5BCxJMrq37lA==", "dev": true, "optional": true, @@ -9662,7 +9662,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolved": false, "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, "optional": true, @@ -9672,14 +9672,14 @@ }, "ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "resolved": false, "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", "dev": true, "optional": true }, "needle": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/needle/-/needle-2.2.0.tgz", + "resolved": false, "integrity": "sha512-eFagy6c+TYayorXw/qtAdSvaUpEbBsDwDyxYFgLZ0lTojfH7K+OdBqAF7TAFwDokJaGpubpSGG0wO3iC0XPi8w==", "dev": true, "optional": true, @@ -9691,7 +9691,7 @@ }, "node-pre-gyp": { "version": "0.10.0", - "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.10.0.tgz", + "resolved": false, "integrity": "sha512-G7kEonQLRbcA/mOoFoxvlMrw6Q6dPf92+t/l0DFSMuSlDoWaI9JWIyPwK0jyE1bph//CUEL65/Fz1m2vJbmjQQ==", "dev": true, "optional": true, @@ -9710,7 +9710,7 @@ }, "nopt": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", + "resolved": false, "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", "dev": true, "optional": true, @@ -9721,14 +9721,14 @@ }, "npm-bundled": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.0.3.tgz", + "resolved": false, "integrity": "sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow==", "dev": true, "optional": true }, "npm-packlist": { "version": "1.1.10", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.1.10.tgz", + "resolved": false, "integrity": "sha512-AQC0Dyhzn4EiYEfIUjCdMl0JJ61I2ER9ukf/sLxJUcZHfo+VyEfz2rMJgLZSS1v30OxPQe1cN0LZA1xbcaVfWA==", "dev": true, "optional": true, @@ -9739,7 +9739,7 @@ }, "npmlog": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "resolved": false, "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", "dev": true, "optional": true, @@ -9752,21 +9752,21 @@ }, "number-is-nan": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "resolved": false, "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", "dev": true, "optional": true }, "object-assign": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "resolved": false, "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "dev": true, "optional": true }, "once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "resolved": false, "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, "optional": true, @@ -9776,21 +9776,21 @@ }, "os-homedir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "resolved": false, "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "dev": true, "optional": true }, "os-tmpdir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "resolved": false, "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "dev": true, "optional": true }, "osenv": { "version": "0.1.5", - "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "resolved": false, "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", "dev": true, "optional": true, @@ -9801,21 +9801,21 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "resolved": false, "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true, "optional": true }, "process-nextick-args": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "resolved": false, "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", "dev": true, "optional": true }, "rc": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.7.tgz", + "resolved": false, "integrity": "sha512-LdLD8xD4zzLsAT5xyushXDNscEjB7+2ulnl8+r1pnESlYtlJtVSoCMBGr30eDRJ3+2Gq89jK9P9e4tCEH1+ywA==", "dev": true, "optional": true, @@ -9828,7 +9828,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": false, "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true, "optional": true @@ -9837,7 +9837,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": false, "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "optional": true, @@ -9853,7 +9853,7 @@ }, "rimraf": { "version": "2.6.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", + "resolved": false, "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", "dev": true, "optional": true, @@ -9863,49 +9863,49 @@ }, "safe-buffer": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", + "resolved": false, "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", "dev": true, "optional": true }, "safer-buffer": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "resolved": false, "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, "optional": true }, "sax": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "resolved": false, "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "dev": true, "optional": true }, "semver": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz", + "resolved": false, "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==", "dev": true, "optional": true }, "set-blocking": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "resolved": false, "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "dev": true, "optional": true }, "signal-exit": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "resolved": false, "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true, "optional": true }, "string-width": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "resolved": false, "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, "optional": true, @@ -9917,7 +9917,7 @@ }, "string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "resolved": false, "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "optional": true, @@ -9927,7 +9927,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": false, "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, "optional": true, @@ -9937,14 +9937,14 @@ }, "strip-json-comments": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "resolved": false, "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "dev": true, "optional": true }, "tar": { "version": "4.4.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.1.tgz", + "resolved": false, "integrity": "sha512-O+v1r9yN4tOsvl90p5HAP4AEqbYhx4036AGMm075fH9F8Qwi3oJ+v4u50FkT/KkvywNGtwkk0zRI+8eYm1X/xg==", "dev": true, "optional": true, @@ -9960,14 +9960,14 @@ }, "util-deprecate": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "resolved": false, "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true, "optional": true }, "wide-align": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.2.tgz", + "resolved": false, "integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==", "dev": true, "optional": true, @@ -9977,14 +9977,14 @@ }, "wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "resolved": false, "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true, "optional": true }, "yallist": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz", + "resolved": false, "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=", "dev": true, "optional": true @@ -16453,7 +16453,7 @@ "dependencies": { "async": { "version": "1.5.2", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", "dev": true } @@ -17154,27 +17154,14 @@ "dev": true }, "react": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react/-/react-16.8.6.tgz", - "integrity": "sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==", + "version": "16.10.2", + "resolved": "https://registry.npmjs.org/react/-/react-16.10.2.tgz", + "integrity": "sha512-MFVIq0DpIhrHFyqLU0S3+4dIcBhhOvBE8bJ/5kHPVOVaGdo0KuiQzpcjCPsf585WvhypqtrMILyoE2th6dT+Lw==", "dev": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", - "prop-types": "^15.6.2", - "scheduler": "^0.13.6" - }, - "dependencies": { - "scheduler": { - "version": "0.13.6", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz", - "integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==", - "dev": true, - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" - } - } + "prop-types": "^15.6.2" } }, "react-base16-styling": { @@ -17359,21 +17346,21 @@ } }, "react-dom": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.6.tgz", - "integrity": "sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==", + "version": "16.10.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.10.2.tgz", + "integrity": "sha512-kWGDcH3ItJK4+6Pl9DZB16BXYAZyrYQItU4OMy0jAkv5aNqc+mAKb4TpFtAteI6TJZu+9ZlNhaeNQSVQDHJzkw==", "dev": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "scheduler": "^0.13.6" + "scheduler": "^0.16.2" }, "dependencies": { "scheduler": { - "version": "0.13.6", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz", - "integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==", + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.16.2.tgz", + "integrity": "sha512-BqYVWqwz6s1wZMhjFvLfVR5WXP7ZY32M/wYPo04CcuPM7XZEbV2TBNW7Z0UkguPTl0dWMA59VbNXxK6q+pHItg==", "dev": true, "requires": { "loose-envify": "^1.1.0", diff --git a/package.json b/package.json index 9132195ec..98fc74210 100755 --- a/package.json +++ b/package.json @@ -68,10 +68,10 @@ "prettier": "1.15.2", "quickhull3d": "2.0.4", "raw-loader": "^0.5.1", - "react": "16.8.6", + "react": "16.10.2", "react-container-dimensions": "^1.4.1", "react-copy-to-clipboard": "^5.0.1", - "react-dom": "16.8.6", + "react-dom": "16.10.2", "react-hooks-testing-library": "^0.4.0", "react-hot-loader": "4.8.2", "react-live": "^1.12.0", @@ -112,8 +112,8 @@ }, "dependencies": {}, "scripts": { - "bootstrap": "npm install && lerna bootstrap --hoist react", - "install-ci": "npm ci && lerna bootstrap --hoist react", + "bootstrap": "npm install && lerna bootstrap --hoist \"{react,react-dom}\"", + "install-ci": "npm ci && lerna bootstrap --hoist \"{react,react-dom}\"", "build": "lerna run build && webpack", "watch": "lerna run watch --parallel", "clean": "lerna run clean", diff --git a/packages/@cruise-automation/hooks/src/useAbortable.test.js b/packages/@cruise-automation/hooks/src/useAbortable.test.js index 0e7539070..5187111da 100644 --- a/packages/@cruise-automation/hooks/src/useAbortable.test.js +++ b/packages/@cruise-automation/hooks/src/useAbortable.test.js @@ -27,6 +27,11 @@ function signal(): ResolvablePromise { } describe("useAbortable", () => { + beforeEach(() => { + // Suppress "test was not wrapped in act" error which is kind of hard to fix with this hook. + jest.spyOn(console, "error").mockReturnValue(); + }); + const Test = React.forwardRef((props, ref) => { const { action, cleanup } = props; const [value, abort] = useAbortable("pending", action, cleanup || (() => {}), [action]); diff --git a/packages/webviz-core/.eslintrc.js b/packages/webviz-core/.eslintrc.js index e7f7cac31..a6d6048a0 100644 --- a/packages/webviz-core/.eslintrc.js +++ b/packages/webviz-core/.eslintrc.js @@ -21,6 +21,17 @@ module.exports = { patterns: ["client/*", "shared/*", "server/*"], }, ], + "no-restricted-syntax": [ + "error", + { + selector: "MethodDefinition[kind='get'], Property[kind='get']", + message: "Property getters are not allowed; prefer function syntax instead.", + }, + { + selector: "MethodDefinition[kind='set'], Property[kind='set']", + message: "Property setters are not allowed; prefer function syntax instead.", + }, + ], "header/header": [ 2, "line", diff --git a/packages/webviz-core/package-lock.json b/packages/webviz-core/package-lock.json index c26aa2ed7..0fac2bc8a 100644 --- a/packages/webviz-core/package-lock.json +++ b/packages/webviz-core/package-lock.json @@ -961,6 +961,11 @@ "@types/webpack": "^4.4.19" } }, + "monaco-vim": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/monaco-vim/-/monaco-vim-0.1.3.tgz", + "integrity": "sha512-1Rvgbren/4HbL1S/LhW2IVHyayFBxNUxHrh0Do+afh/MTYbteyKwC4ANjVRlqeSX2Ib8qovDUajLG30yF6aeQA==" + }, "moo": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/moo/-/moo-0.4.3.tgz", @@ -1166,17 +1171,6 @@ "resolved": "https://registry.npmjs.org/react-document-events/-/react-document-events-1.4.0.tgz", "integrity": "sha512-eJuzYYLAqtHA2uV3JUWKWhmKXPBfSCVZEpFQpCswd3gzfJWn9UbX+7pIe3MKaofLLIIE/KwZ06ay5QJePt9bHw==" }, - "react-dom": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.6.tgz", - "integrity": "sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==", - "requires": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "prop-types": "^15.6.2", - "scheduler": "^0.13.6" - } - }, "react-draggable": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-3.0.5.tgz", @@ -1412,21 +1406,21 @@ } }, "react-test-renderer": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.8.6.tgz", - "integrity": "sha512-H2srzU5IWYT6cZXof6AhUcx/wEyJddQ8l7cLM/F7gDXYyPr4oq+vCIxJYXVGhId1J706sqziAjuOEjyNkfgoEw==", + "version": "16.10.2", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.10.2.tgz", + "integrity": "sha512-k9Qzyev6cTIcIfrhgrFlYQAFxh5EEDO6ALNqYqmKsWVA7Q/rUMTay5nD3nthi6COmYsd4ghVYyi8U86aoeMqYQ==", "dev": true, "requires": { "object-assign": "^4.1.1", "prop-types": "^15.6.2", "react-is": "^16.8.6", - "scheduler": "^0.13.6" + "scheduler": "^0.16.2" }, "dependencies": { "react-is": { - "version": "16.8.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", - "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==", + "version": "16.10.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.10.2.tgz", + "integrity": "sha512-INBT1QEgtcCCgvccr5/86CfD71fw9EPmDxgiJX4I2Ddr6ZsV6iFXsuby+qWJPtmNuMY0zByTsG4468P7nHuNWA==", "dev": true } } @@ -1576,9 +1570,10 @@ } }, "scheduler": { - "version": "0.13.6", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz", - "integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==", + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.16.2.tgz", + "integrity": "sha512-BqYVWqwz6s1wZMhjFvLfVR5WXP7ZY32M/wYPo04CcuPM7XZEbV2TBNW7Z0UkguPTl0dWMA59VbNXxK6q+pHItg==", + "dev": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" diff --git a/packages/webviz-core/package.json b/packages/webviz-core/package.json index d3b6c5ecf..82902d987 100644 --- a/packages/webviz-core/package.json +++ b/packages/webviz-core/package.json @@ -27,18 +27,19 @@ "moment-duration-format": "2.2.2", "moment-timezone": "0.5.23", "monaco-editor": "0.17.1", + "monaco-vim": "0.1.3", "natsort": "2.0.0", "nearley": "2.15.1", "promise-queue": "2.2.5", "prop-types": "15.6.2", "raven-js": "3.27.0", - "react": "16.8.6", + "react": "16.10.2", "react-autocomplete": "janpaul123/react-autocomplete#bc8737070b5744069719c8fcd4e0a197192b0d48", "react-chartjs-2": "2.7.4", "react-container-dimensions": "1.4.1", "react-dnd": "2.5.4", "react-document-events": "1.4.0", - "react-dom": "16.8.6", + "react-dom": "16.10.2", "react-draggable": "3.0.5", "react-hot-loader": "4.8.2", "react-hover-observer": "2.1.1", @@ -72,7 +73,7 @@ "fetch-mock": "7.2.5", "flow-bin": "0.106.2", "monaco-editor-webpack-plugin": "1.7.0", - "react-test-renderer": "16.8.6" + "react-test-renderer": "16.10.2" }, "importjs": { "isRoot": false diff --git a/packages/webviz-core/shared/parentTopicStyles.js b/packages/webviz-core/shared/parentTopicStyles.js new file mode 100644 index 000000000..690bc8b6e --- /dev/null +++ b/packages/webviz-core/shared/parentTopicStyles.js @@ -0,0 +1,17 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. +import styled from "styled-components"; + +export const ParentTopicLabel = styled.label` + display: block; + padding-bottom: 5px; +`; + +export const ParentTopicInput = styled.input` + width: 100%; +`; diff --git a/packages/webviz-core/src/PanelAPI/README.md b/packages/webviz-core/src/PanelAPI/README.md new file mode 100644 index 000000000..201f64015 --- /dev/null +++ b/packages/webviz-core/src/PanelAPI/README.md @@ -0,0 +1,77 @@ +# PanelAPI + +The `PanelAPI` namespace contains React [Hooks](https://reactjs.org/docs/hooks-intro.html) and components which allow panel authors to access Webviz data and metadata inside their panels. Using these APIs across all panels helps ensure that data appears consistent among panels, and makes it easier for panels to support advanced features (such as multiple simultaneous data sources). + +To use PanelAPI, it's recommended that you import the whole namespace, so that all usage sites look consistent, like `PanelAPI.useSomething()`. + +```js +import * as PanelAPI from "webviz-core/src/PanelAPI"; +``` + +## [`PanelAPI.useDataSourceInfo()`](useDataSourceInfo.js) + +"Data source info" encapsulates **rarely-changing** metadata about the sources from which Webviz is loading data. (A data source might be a local [bag file](http://wiki.ros.org/Bags/Format) dropped into the browser, or a bag stored on a remote server; see [players](../players) and [dataSources](../dataSources) for more details.) + +Using this hook inside a panel will cause the panel to re-render automatically when the metadata changes, but this won't happen very often or during playback. (Exception: the internal WebSocket player might cause `endTime` to update frequently; this is considered a bug.) + +```js + PanelAPI.useDataSourceInfo(): {| + topics: $ReadOnlyArray, + datatypes: RosDatatypes, + capabilities: string[], + startTime: ?Time, + endTime: ?Time, +|}; +``` + +## [`PanelAPI.useMessages()`](../components/MessageHistory/MessageHistoryOnlyTopics.js) + +`useMessages()` provides panels a way to access [messages](http://wiki.ros.org/Messages) from [topics](http://wiki.ros.org/Topics). `useMessages` is a fairly **low-level API** that many panels will use via [``](../components/MessageHistory) (in the future, we'll provide alternative hooks or helper functions to use MessageHistory [topic path syntax](../components/MessageHistory/topicPathSyntax.help.md) with useMessages). Users can define how to initialize a custom state, and how to update the state based on incoming messages. + +Using this hook will cause the panel to re-render when any new messages come in on the requested topics. + +```js +PanelAPI.useMessages(props: {| + topics: string[], + imageScale?: number, + restore: (prevState: ?T) => T, + addMessage: (prevState: T, message: Message) => T, +|}): {| reducedValue: T |}; +``` + +### Subscription parameters + +- `topics`: set of topics to subscribe to. Changing only the topics will not cause `restore` or `addMessage` to be called. +- `imageScale`: number between 0 and 1 for subscriptions to image topics, requesting that the player downsample images. _(Unused in the open-source version of Webviz.)_ + +### Reducer functions + +The useMessages hook returns a user-defined "state" (`T`). The `restore` and `addMessage` callbacks specify how to initialize and update the state. + +These reducers should be wrapped in [`useCallback()`](https://reactjs.org/docs/hooks-reference.html#usecallback), because the useMessages hook will do extra work when they change, so they should change only when the interpretation of message data is actually changing. + +- `restore: (?T) => T`: + - Called with `undefined` to initialize a new state when the panel first renders, and when the user seeks to a different playback time (at which point Webviz automatically clears out state across all panels). + - Called with the previous state when the `restore` or `addMessage` reducer functions change. This allows the panel an opportunity to reuse its previous state when a parameter changes, without totally discarding it (as in the case of a seek) and waiting for new messages to come in from the data source. + + For example, a panel that filters some incoming messages can use `restore` to create a filtered value immediately when the filter changes. To implement this, the caller might switch from unfiltered reducers: + + ```js + { + restore: (x: ?string[]) => (x || []), + addMessage: (x: string[], m: Message) => x.concat(m.data), + } + ``` + + to reducers implementing a filter: + + ```js + { + restore: (x: ?string[]) => (x ? x.filter(predicate) : []), + addMessage: (x: string[], m: Message) => (predicate(m.data) ? x.concat(m.data) : x), + } + ``` + + As soon as the reducers are swapped, the **new** `restore()` will be called with the **previous** data. (If the filter is removed again, the old data that was filtered out can't be magically restored unless it was kept in the state, but hopefully future work to preload data faster than real-time will help us there.) + +- `addMessage: (T, Message) => T`: called when any new message comes in on one of the requested topics. The return value from `addMessage` will be the new return value from `useMessages().reducedValue`. diff --git a/packages/webviz-core/src/PanelAPI/index.js b/packages/webviz-core/src/PanelAPI/index.js new file mode 100644 index 000000000..c2e7965d1 --- /dev/null +++ b/packages/webviz-core/src/PanelAPI/index.js @@ -0,0 +1,17 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +// This file contains hooks and components comprising the public API for Webviz panel development. +// Recommended use: import * as PanelAPI from "webviz-core/src/PanelAPI"; + +// More to come soon! + +export { default as useDataSourceInfo } from "./useDataSourceInfo"; +export type { DataSourceInfo } from "./useDataSourceInfo"; + +export { useMessages } from "webviz-core/src/components/MessageHistory/MessageHistoryOnlyTopics"; diff --git a/packages/webviz-core/src/PanelAPI/useDataSourceInfo.js b/packages/webviz-core/src/PanelAPI/useDataSourceInfo.js new file mode 100644 index 000000000..0b4f9bb98 --- /dev/null +++ b/packages/webviz-core/src/PanelAPI/useDataSourceInfo.js @@ -0,0 +1,57 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { useMemo, useContext, useCallback } from "react"; +import { type Time } from "rosbag"; + +import { useMessagePipeline } from "webviz-core/src/components/MessagePipeline"; +import PanelContext from "webviz-core/src/components/PanelContext"; +import filterMap from "webviz-core/src/filterMap"; +import { type Topic } from "webviz-core/src/players/types"; +import type { RosDatatypes } from "webviz-core/src/types/RosDatatypes"; + +// Metadata about the source of data currently being displayed in Webviz. +// This is not expected to change often, usually when changing data sources. +export type DataSourceInfo = {| + topics: $ReadOnlyArray, + datatypes: RosDatatypes, + capabilities: string[], + startTime: ?Time, + endTime: ?Time, +|}; + +export default function useDataSourceInfo(): DataSourceInfo { + const { topicPrefix = "" } = useContext(PanelContext) || {}; + const datatypes = useMessagePipeline(useCallback(({ datatypes }) => datatypes, [])); + const prefixedTopics = useMessagePipeline(useCallback(({ sortedTopics }) => sortedTopics, [])); + const startTime = useMessagePipeline( + useCallback(({ playerState: { activeData } }) => activeData && activeData.startTime, []) + ); + const endTime = useMessagePipeline( + useCallback(({ playerState: { activeData } }) => activeData && activeData.endTime, []) + ); + const capabilities = useMessagePipeline(useCallback(({ playerState: { capabilities } }) => capabilities, [])); + + const unprefixedTopics = useMemo( + () => + topicPrefix + ? filterMap(prefixedTopics, ({ name, datatype }) => + name.startsWith(topicPrefix) ? { name: name.slice(topicPrefix.length), datatype } : null + ) + : prefixedTopics, + [topicPrefix, prefixedTopics] + ); + + return { + topics: unprefixedTopics, + datatypes, + capabilities, + startTime, + endTime, + }; +} diff --git a/packages/webviz-core/src/PanelAPI/useDataSourceInfo.test.js b/packages/webviz-core/src/PanelAPI/useDataSourceInfo.test.js new file mode 100644 index 000000000..f7ed597d5 --- /dev/null +++ b/packages/webviz-core/src/PanelAPI/useDataSourceInfo.test.js @@ -0,0 +1,149 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { mount } from "enzyme"; +import * as React from "react"; + +import * as PanelAPI from "."; +import { MockMessagePipelineProvider } from "webviz-core/src/components/MessagePipeline"; +import { MockPanelContextProvider } from "webviz-core/src/components/Panel"; + +describe("useDataSourceInfo", () => { + const topics = [{ name: "/foo", datatype: "Foo" }]; + const messages = [ + { + op: "message", + datatype: "Foo", + topic: "/foo", + receiveTime: { sec: 1, nsec: 2 }, + message: {}, + }, + { + op: "message", + datatype: "Foo", + topic: "/foo", + receiveTime: { sec: 5, nsec: 6 }, + message: {}, + }, + ]; + const datatypes = { + Foo: [], + }; + + // Create a helper component that exposes the results of the hook in a Jest mock function + function createTest() { + function Test() { + return Test.renderFn(PanelAPI.useDataSourceInfo()); + } + Test.renderFn = jest.fn().mockImplementation(() => null); + return Test; + } + + it("returns data from MessagePipelineContext", () => { + const Test = createTest(); + const root = mount( + + + + ); + expect(Test.renderFn.mock.calls).toEqual([ + [ + { + topics: [{ name: "/foo", datatype: "Foo" }], + datatypes: { Foo: [] }, + capabilities: ["hello"], + startTime: { sec: 0, nsec: 1 }, + endTime: { sec: 10, nsec: 0 }, + }, + ], + ]); + root.unmount(); + }); + + it("doesn't change when messages change", () => { + const Test = createTest(); + const root = mount( + + + + ); + expect(Test.renderFn.mock.calls).toEqual([ + [ + { + topics: [{ name: "/foo", datatype: "Foo" }], + datatypes: { Foo: [] }, + capabilities: ["hello"], + startTime: { sec: 0, nsec: 1 }, + endTime: { sec: 10, nsec: 0 }, + }, + ], + ]); + Test.renderFn.mockClear(); + + root.setProps({ messages: [messages[1]] }); + expect(Test.renderFn).toHaveBeenCalledTimes(0); + + root.setProps({ topics: [...topics, { name: "/bar", datatype: "Bar" }] }); + expect(Test.renderFn.mock.calls).toEqual([ + [ + { + topics: [{ name: "/bar", datatype: "Bar" }, { name: "/foo", datatype: "Foo" }], + datatypes: { Foo: [] }, + capabilities: ["hello"], + startTime: { sec: 0, nsec: 1 }, + endTime: { sec: 10, nsec: 0 }, + }, + ], + ]); + + root.unmount(); + }); + + it("removes PanelContext.topicPrefix from topics", () => { + const Test = createTest(); + const root = mount( + + + + + + ); + expect(Test.renderFn.mock.calls).toEqual([ + [ + { + topics: [ + { name: "123", datatype: "Foo123" }, // FIXME: prefixes probably shouldn't be stripped this way + { name: "/abc", datatype: "FooAbc" }, + ], + datatypes: {}, + capabilities: [], + startTime: { sec: 100, nsec: 0 }, + endTime: { sec: 100, nsec: 0 }, + }, + ], + ]); + root.unmount(); + }); +}); diff --git a/packages/webviz-core/src/PanelAPI/useMessages.test.js b/packages/webviz-core/src/PanelAPI/useMessages.test.js new file mode 100644 index 000000000..4afdde57c --- /dev/null +++ b/packages/webviz-core/src/PanelAPI/useMessages.test.js @@ -0,0 +1,271 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { mount } from "enzyme"; +import * as React from "react"; + +import * as PanelAPI from "."; +import { MockMessagePipelineProvider } from "webviz-core/src/components/MessagePipeline"; + +describe("useMessages", () => { + // Create a helper component that exposes restore, addMessage, and the results of the hook for mocking + function createTest() { + function Test({ topics }) { + Test.result( + PanelAPI.useMessages({ + topics, + addMessage: Test.addMessage, + restore: Test.restore, + }).reducedValue + ); + return null; + } + Test.result = jest.fn(); + Test.restore = jest.fn(); + Test.addMessage = jest.fn(); + return Test; + } + + it("calls restore to initialize without messages", async () => { + const Test = createTest(); + Test.restore.mockReturnValue(1); + + const root = mount( + + + + ); + + await Promise.resolve(); + expect(Test.restore.mock.calls).toEqual([[undefined]]); + expect(Test.addMessage.mock.calls).toEqual([]); + expect(Test.result.mock.calls).toEqual([[1]]); + + root.unmount(); + }); + + it("calls restore to initialize and addMessage for initial messages", async () => { + const Test = createTest(); + + Test.restore.mockReturnValue(1); + Test.addMessage.mockImplementation((_, msg) => msg.message.value); + + const message = { + op: "message", + datatype: "Foo", + topic: "/foo", + receiveTime: { sec: 0, nsec: 0 }, + message: { value: 2 }, + }; + + const root = mount( + + + + ); + + expect(Test.restore.mock.calls).toEqual([[undefined]]); + expect(Test.addMessage.mock.calls).toEqual([[1, message]]); + expect(Test.result.mock.calls).toEqual([[2]]); + + root.unmount(); + }); + + it("calls addMessage for messages added later", async () => { + const Test = createTest(); + + Test.restore.mockReturnValue(1); + Test.addMessage.mockImplementation((_, msg) => msg.message.value); + + const message1 = { + op: "message", + datatype: "Foo", + topic: "/foo", + receiveTime: { sec: 0, nsec: 0 }, + message: { value: 2 }, + }; + const message2 = { + op: "message", + datatype: "Bar", + topic: "/bar", + receiveTime: { sec: 0, nsec: 0 }, + message: { value: 3 }, + }; + + const root = mount( + + + + ); + + root.setProps({ messages: [message1] }); + + expect(Test.restore.mock.calls).toEqual([[undefined]]); + expect(Test.addMessage.mock.calls).toEqual([[1, message1]]); + expect(Test.result.mock.calls).toEqual([[1], [2]]); + + // Subscribe to a new topic, then receive a message on that topic + root.setProps({ children: }); + + expect(Test.restore.mock.calls).toEqual([[undefined]]); + expect(Test.addMessage.mock.calls).toEqual([[1, message1]]); + expect(Test.result.mock.calls).toEqual([[1], [2], [2]]); + + root.setProps({ messages: [message2] }); + + expect(Test.restore.mock.calls).toEqual([[undefined]]); + expect(Test.addMessage.mock.calls).toEqual([[1, message1], [2, message2]]); + expect(Test.result.mock.calls).toEqual([[1], [2], [2], [3]]); + + root.unmount(); + }); + + it("doesn't re-render for messages on non-subscribed topics", async () => { + const Test = createTest(); + + Test.restore.mockReturnValue(1); + Test.addMessage.mockImplementation((_, msg) => msg.message.value); + + const message1 = { + op: "message", + datatype: "Foo", + topic: "/foo", + receiveTime: { sec: 0, nsec: 0 }, + message: { value: 2 }, + }; + const message2 = { + op: "message", + datatype: "Bar", + topic: "/bar", + receiveTime: { sec: 0, nsec: 0 }, + message: { value: 3 }, + }; + + const root = mount( + + + + ); + + expect(Test.restore.mock.calls).toEqual([[undefined]]); + expect(Test.addMessage.mock.calls).toEqual([[1, message1]]); + expect(Test.result.mock.calls).toEqual([[2]]); + + root.setProps({ messages: [message2] }); + + expect(Test.restore.mock.calls).toEqual([[undefined]]); + expect(Test.addMessage.mock.calls).toEqual([[1, message1]]); + expect(Test.result.mock.calls).toEqual([[2]]); + + root.unmount(); + }); + + it("doesn't re-render when requested topics change", async () => { + const Test = createTest(); + + Test.restore.mockReturnValue(1); + Test.addMessage.mockImplementation((_, msg) => msg.message.value); + + const message1 = { + op: "message", + datatype: "Foo", + topic: "/foo", + receiveTime: { sec: 0, nsec: 0 }, + message: { value: 2 }, + }; + const message2 = { + op: "message", + datatype: "Bar", + topic: "/bar", + receiveTime: { sec: 0, nsec: 0 }, + message: { value: 3 }, + }; + + const root = mount( + + + + ); + + expect(Test.restore.mock.calls).toEqual([[undefined]]); + expect(Test.addMessage.mock.calls).toEqual([[1, message2]]); + expect(Test.result.mock.calls).toEqual([[3]]); + + // When topics change, we expect useMessages NOT to call addMessage for pre-existing messages. + // (If the player is playing, new messages will come in soon, and if it's paused, we'll backfill.) + // This is because processing the same frame again might lead to duplicate or out-of-order + // addMessages calls. If the user really cares about re-processing the current frame, they can + // change their restore/addMessages reducers. + root.setProps({ children: }); + + expect(Test.restore.mock.calls).toEqual([[undefined]]); + expect(Test.addMessage.mock.calls).toEqual([[1, message2]]); + expect(Test.result.mock.calls).toEqual([[3], [3]]); + + root.unmount(); + }); + + it("doesn't re-render when player topics or other playerState changes", async () => { + const Test = createTest(); + + Test.restore.mockReturnValue(1); + Test.addMessage.mockImplementation((_, msg) => msg.message.value); + + const message = { + op: "message", + datatype: "Foo", + topic: "/foo", + receiveTime: { sec: 0, nsec: 0 }, + message: { value: 2 }, + }; + + const root = mount( + + + + ); + + expect(Test.restore.mock.calls).toEqual([[undefined]]); + expect(Test.addMessage.mock.calls).toEqual([[1, message]]); + expect(Test.result.mock.calls).toEqual([[2]]); + + root.setProps({ topics: ["/foo", "/bar"] }); + root.setProps({ capabilities: ["some_capability"] }); + + expect(Test.restore.mock.calls).toEqual([[undefined]]); + expect(Test.addMessage.mock.calls).toEqual([[1, message]]); + expect(Test.result.mock.calls).toEqual([[2]]); + + root.unmount(); + }); + + it("doesn't re-render when activeData is empty", async () => { + const Test = createTest(); + + Test.restore.mockReturnValue(1); + Test.addMessage.mockImplementation((_, msg) => msg.message.value); + + const root = mount( + + + + ); + + expect(Test.restore.mock.calls).toEqual([[undefined]]); + expect(Test.addMessage.mock.calls).toEqual([]); + expect(Test.result.mock.calls).toEqual([[1]]); + + root.setProps({ capabilities: ["some_capability"] }); + + expect(Test.restore.mock.calls).toEqual([[undefined]]); + expect(Test.addMessage.mock.calls).toEqual([]); + expect(Test.result.mock.calls).toEqual([[1]]); + + root.unmount(); + }); +}); diff --git a/packages/webviz-core/src/actions/index.js b/packages/webviz-core/src/actions/index.js index 8c77741df..e7b35b19f 100644 --- a/packages/webviz-core/src/actions/index.js +++ b/packages/webviz-core/src/actions/index.js @@ -8,7 +8,7 @@ import type { ExtensionsActions } from "./extensions"; import type { SET_MOSAIC_ID } from "./mosaic"; -import type { NodeDiagnosticsActions } from "./nodeDiagnostics"; import type { PanelsActions } from "./panels"; +import type { UserNodesActions } from "./userNodes"; -export type ActionTypes = PanelsActions | SET_MOSAIC_ID | ExtensionsActions | NodeDiagnosticsActions; +export type ActionTypes = PanelsActions | SET_MOSAIC_ID | ExtensionsActions | UserNodesActions; diff --git a/packages/webviz-core/src/actions/nodeDiagnostics.js b/packages/webviz-core/src/actions/nodeDiagnostics.js deleted file mode 100644 index 3161e41a1..000000000 --- a/packages/webviz-core/src/actions/nodeDiagnostics.js +++ /dev/null @@ -1,23 +0,0 @@ -// @flow -// -// Copyright (c) 2018-present, GM Cruise LLC -// -// This source code is licensed under the Apache License, Version 2.0, -// found in the LICENSE file in the root directory of this source tree. -// You may not use this file except in compliance with the License. - -import type { NodeDiagnostics } from "webviz-core/src/reducers/nodeDiagnostics"; - -type SET_NODE_DIAGNOSTICS = { - type: "SET_NODE_DIAGNOSTICS", - payload: NodeDiagnostics, -}; - -export const setNodeDiagnostics = (payload: NodeDiagnostics) => ({ - type: "SET_NODE_DIAGNOSTICS", - payload, -}); - -export type SetNodeDiagnostics = typeof setNodeDiagnostics; - -export type NodeDiagnosticsActions = SET_NODE_DIAGNOSTICS; diff --git a/packages/webviz-core/src/actions/panels.js b/packages/webviz-core/src/actions/panels.js index c26f77c81..fb48df118 100644 --- a/packages/webviz-core/src/actions/panels.js +++ b/packages/webviz-core/src/actions/panels.js @@ -14,11 +14,10 @@ import type { SaveConfigPayload, SaveFullConfigPayload, UserNodes, + PlaybackConfig, } from "webviz-core/src/types/panels"; import type { Dispatch, GetState } from "webviz-core/src/types/Store"; -// DANGER: if you change this you break existing layout urls -export const URL_KEY = "layout"; -export const LAYOUT_URL = "layout-url"; +import { LAYOUT_QUERY_KEY } from "webviz-core/src/util/globalConstants"; export type SAVE_PANEL_CONFIG = { type: "SAVE_PANEL_CONFIG", @@ -37,8 +36,8 @@ function maybeStripLayoutId(dispatch: Dispatch, getState: GetState): void { if (location) { const params = new URLSearchParams(location.search); - if (params.get(URL_KEY)) { - params.delete(URL_KEY); + if (params.get(LAYOUT_QUERY_KEY)) { + params.delete(LAYOUT_QUERY_KEY); const newSearch = params.toString(); const searchString = newSearch ? `?${newSearch}` : newSearch; const newPath = `${location.pathname}${searchString}`; @@ -104,7 +103,7 @@ type OVERWRITE_GLOBAL_DATA = { payload: any, }; -export const overwriteGlobalData = (payload: any): OVERWRITE_GLOBAL_DATA => ({ +export const overwriteGlobalVariables = (payload: any): OVERWRITE_GLOBAL_DATA => ({ type: "OVERWRITE_GLOBAL_DATA", payload, }); @@ -114,7 +113,7 @@ type SET_GLOBAL_DATA = { payload: any, }; -export const setGlobalData = (payload: any): SET_GLOBAL_DATA => ({ +export const setGlobalVariables = (payload: any): SET_GLOBAL_DATA => ({ type: "SET_GLOBAL_DATA", payload, }); @@ -139,6 +138,16 @@ export const setLinkedGlobalVariables = (payload: LinkedGlobalVariables): SET_LI payload, }); +type SET_PLAYBACK_CONFIG = { + type: "SET_PLAYBACK_CONFIG", + payload: PlaybackConfig, +}; + +export const setPlaybackConfig = (payload: PlaybackConfig): SET_PLAYBACK_CONFIG => ({ + type: "SET_PLAYBACK_CONFIG", + payload, +}); + export type PanelsActions = | CHANGE_PANEL_LAYOUT | IMPORT_PANEL_LAYOUT @@ -147,4 +156,5 @@ export type PanelsActions = | OVERWRITE_GLOBAL_DATA | SET_GLOBAL_DATA | SET_WEBVIZ_NODES - | SET_LINKED_GLOBAL_VARIABLES; + | SET_LINKED_GLOBAL_VARIABLES + | SET_PLAYBACK_CONFIG; diff --git a/packages/webviz-core/src/actions/userNodes.js b/packages/webviz-core/src/actions/userNodes.js new file mode 100644 index 000000000..c11b1caf2 --- /dev/null +++ b/packages/webviz-core/src/actions/userNodes.js @@ -0,0 +1,60 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import type { UserNodeDiagnostics, UserNodeLogs } from "webviz-core/src/players/UserNodePlayer/types"; + +type SET_USER_NODE_DIAGNOSTICS = { + type: "SET_USER_NODE_DIAGNOSTICS", + payload: UserNodeDiagnostics, +}; + +type ADD_USER_NODE_LOGS = { + type: "ADD_USER_NODE_LOGS", + payload: UserNodeLogs, +}; + +type CLEAR_USER_NODE_LOGS = { + type: "CLEAR_USER_NODE_LOGS", + payload: string, +}; + +type SET_USER_NODE_TRUST = { + type: "SET_USER_NODE_TRUST", + payload: { id: string, trusted: boolean }, +}; + +export const setUserNodeDiagnostics = (payload: UserNodeDiagnostics) => ({ + type: "SET_USER_NODE_DIAGNOSTICS", + payload, +}); + +export const addUserNodeLogs = (payload: UserNodeLogs) => ({ + type: "ADD_USER_NODE_LOGS", + payload, +}); + +export const clearUserNodeLogs = (payload: string) => ({ + type: "CLEAR_USER_NODE_LOGS", + payload, +}); + +export const setUserNodeTrust = (payload: { id: string, trusted: boolean }) => ({ + type: "SET_USER_NODE_TRUST", + payload, +}); + +export type AddUserNodeLogs = typeof addUserNodeLogs; +export type ClearUserNodeLogs = typeof clearUserNodeLogs; +export type SetUserNodeDiagnostics = typeof setUserNodeDiagnostics; +export type SetUserNodeTrust = typeof setUserNodeTrust; + +export type UserNodesActions = + | ADD_USER_NODE_LOGS + | CLEAR_USER_NODE_LOGS + | SET_USER_NODE_DIAGNOSTICS + | SET_USER_NODE_TRUST; diff --git a/packages/webviz-core/src/components/AppMenu/index.js b/packages/webviz-core/src/components/AppMenu/index.js index 300a5e13d..447afc6ac 100644 --- a/packages/webviz-core/src/components/AppMenu/index.js +++ b/packages/webviz-core/src/components/AppMenu/index.js @@ -7,21 +7,34 @@ // You may not use this file except in compliance with the License. import PlusBoxIcon from "@mdi/svg/svg/plus-box.svg"; +import { isEmpty } from "lodash"; import React, { Component } from "react"; +import { connect } from "react-redux"; +import { changePanelLayout, savePanelConfig } from "webviz-core/src/actions/panels"; import ChildToggle from "webviz-core/src/components/ChildToggle"; import Icon from "webviz-core/src/components/Icon"; import Menu from "webviz-core/src/components/Menu"; import PanelList from "webviz-core/src/panels/PanelList"; +import type { State as ReduxState } from "webviz-core/src/reducers"; +import type { PanelsState } from "webviz-core/src/reducers/panels"; +import type { PanelConfig, SaveConfigPayload } from "webviz-core/src/types/panels"; +import { getPanelIdForType } from "webviz-core/src/util"; -type Props = {| - onPanelSelect: (panelType: string) => void, +type OwnProps = {| defaultIsOpen?: boolean, // just for testing |}; +type Props = {| + ...OwnProps, + panels: PanelsState, + changePanelLayout: (panelLayout: any) => void, + savePanelConfig: (SaveConfigPayload) => void, +|}; + type State = {| isOpen: boolean |}; -export default class AppMenu extends Component { +class UnconnectedAppMenu extends Component { constructor(props: Props) { super(props); this.state = { isOpen: props.defaultIsOpen || false }; @@ -31,23 +44,48 @@ export default class AppMenu extends Component { return nextState.isOpen !== this.state.isOpen; } - onToggle = () => { + _onToggle = () => { const { isOpen } = this.state; this.setState({ isOpen: !isOpen }); }; + _onPanelSelect = (panelType: string, panelConfig?: PanelConfig) => { + const { panels, changePanelLayout, savePanelConfig } = this.props; + const id = getPanelIdForType(panelType); + let newPanels = { + direction: "row", + first: id, + second: panels.layout, + }; + if (isEmpty(panels.layout)) { + newPanels = id; + } + if (panelConfig) { + savePanelConfig({ id, config: panelConfig }); + } + changePanelLayout(newPanels); + window.ga("send", "event", "Panel", "Select", panelType); + }; + render() { const { isOpen } = this.state; return ( - + {/* $FlowFixMe - not sure why it thinks onPanelSelect is a Redux action */} - + ); } } + +export default connect( + (state: ReduxState) => ({ + panels: state.panels, + }), + { changePanelLayout, savePanelConfig } +)(UnconnectedAppMenu); diff --git a/packages/webviz-core/src/components/AppMenu/index.stories.js b/packages/webviz-core/src/components/AppMenu/index.stories.js index 3c37517ed..36e49e255 100644 --- a/packages/webviz-core/src/components/AppMenu/index.stories.js +++ b/packages/webviz-core/src/components/AppMenu/index.stories.js @@ -6,7 +6,6 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. -import { action } from "@storybook/addon-actions"; import { storiesOf } from "@storybook/react"; import { createMemoryHistory } from "history"; import React from "react"; @@ -26,7 +25,7 @@ storiesOf("", module)
- +
diff --git a/packages/webviz-core/src/components/Button.js b/packages/webviz-core/src/components/Button.js index 23c855280..5f8b05053 100644 --- a/packages/webviz-core/src/components/Button.js +++ b/packages/webviz-core/src/components/Button.js @@ -11,11 +11,14 @@ import cx from "classnames"; import * as React from "react"; import Tooltip from "webviz-core/src/components/Tooltip"; +import { colors } from "webviz-core/src/util/colors"; -type Props = { +export type Props = { ...React.ElementConfig, innerRef: ?React.Ref, tooltipProps?: $Shape<{ ...React.ElementConfig }>, + style: { [string]: string | number }, + isPrimary?: boolean, }; export type { BaseButton }; @@ -24,6 +27,7 @@ export type { BaseButton }; export default class Button extends React.Component { static defaultProps = { innerRef: undefined, + style: {}, }; render() { @@ -36,8 +40,13 @@ export default class Button extends React.Component { onClick, onMouseUp, onMouseLeave, + isPrimary, + style, ...otherProps } = this.props; + // overwrite the primary color for Webviz + // using `isPrimary` instead of `primary` now to prevent global UI changes until we are ready to migrate all styles + const styleAlt = isPrimary ? { ...style, backgroundColor: colors.BLUE1 } : style; const eventHandlers = disabled ? {} : { onClick, onMouseUp, onMouseLeave }; @@ -49,11 +58,11 @@ export default class Button extends React.Component { {/* Extra div allows Tooltip to insert the necessary event listeners */}
- +
); } - return ; + return ; } } diff --git a/packages/webviz-core/src/components/Checkbox.js b/packages/webviz-core/src/components/Checkbox.js index 8730c794e..d8da18d47 100644 --- a/packages/webviz-core/src/components/Checkbox.js +++ b/packages/webviz-core/src/components/Checkbox.js @@ -12,30 +12,55 @@ import * as React from "react"; import styled from "styled-components"; import Icon from "webviz-core/src/components/Icon"; +import { colors } from "webviz-core/src/util/colors"; export const SCheckbox = styled.div` display: flex; align-items: center; + flex-direction: ${(props) => (props.isVertical ? "column" : "row")}; + align-items: ${(props) => (props.isVertical ? "flex-start" : "center")}; + color: ${(props) => (props.disabled ? colors.GRAY : colors.LIGHT1)}; + cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")}; `; + export const SLabel = styled.label` margin: 6px; + margin: ${(props) => (props.isVertical ? "6px 6px 6px 0" : "6px")}; + color: ${(props) => (props.disabled || props.isVertical ? colors.GRAY : colors.LIGHT1)}; `; export type Props = { checked: boolean, + disabled?: boolean, label: string, tooltip?: string, onChange: (newChecked: boolean) => void, + isVertical?: boolean, + style?: { [string]: string | number }, }; -export default function Checkbox({ label, checked, tooltip, onChange }: Props) { +export default function Checkbox({ label, checked, tooltip, onChange, disabled, isVertical, style = {} }: Props) { const Component = checked ? CheckboxMarkedIcon : CheckboxBlankOutlineIcon; + const onClick = React.useCallback( + () => { + if (!disabled) { + onChange(!checked); + } + }, + [checked, disabled, onChange] + ); + return ( - - onChange(!checked)}> + + {isVertical && ( + + {label} + + )} + - {label} + {!isVertical && {label}} ); } diff --git a/packages/webviz-core/src/components/Dropdown/index.js b/packages/webviz-core/src/components/Dropdown/index.js index 56971ad87..92d65cac4 100644 --- a/packages/webviz-core/src/components/Dropdown/index.js +++ b/packages/webviz-core/src/components/Dropdown/index.js @@ -112,7 +112,12 @@ export default class Dropdown extends React.Component { borderBottomRightRadius: flatEdges && position === "above" ? "0" : undefined, }; return ( - + {this.renderButton()} {this.renderChildren()} diff --git a/packages/webviz-core/src/components/ExpandingToolbar.js b/packages/webviz-core/src/components/ExpandingToolbar.js index 828d580c1..9045b6d01 100644 --- a/packages/webviz-core/src/components/ExpandingToolbar.js +++ b/packages/webviz-core/src/components/ExpandingToolbar.js @@ -25,10 +25,8 @@ export const SToolGroupFixedSizePane = styled.div` padding: 8px 0; `; -export class ToolGroup extends React.Component<{ name: T, children: React.Node }> { - render() { - return this.props.children; - } +export function ToolGroup({ children }: { name: T, children: React.Node }) { + return children; } export function ToolGroupFixedSizePane({ children }: { children: React.Node }) { diff --git a/packages/webviz-core/src/components/Flex.js b/packages/webviz-core/src/components/Flex.js index 46c7124f6..430d3fb20 100644 --- a/packages/webviz-core/src/components/Flex.js +++ b/packages/webviz-core/src/components/Flex.js @@ -36,6 +36,8 @@ type Props = {| children?: React.Node, onClick?: (MouseEvent) => void, + // for storybook screenshots tests + dataTest?: string, |}; const Flex = (props: Props) => { @@ -55,6 +57,7 @@ const Flex = (props: Props) => { scrollX, children, onClick, + dataTest, } = props; if (col != null && col === row) { throw new Error("Flex col and row are mutually exclusive"); @@ -80,7 +83,7 @@ const Flex = (props: Props) => { // only copy combine flex & custom style if we were passed custom style const fullStyle = style ? { ...flexStyle, ...style } : flexStyle; return ( -
+
{children}
); diff --git a/packages/webviz-core/src/components/GlobalVariablesAccessor/index.js b/packages/webviz-core/src/components/GlobalVariablesAccessor/index.js index 56137775b..8768be4a2 100644 --- a/packages/webviz-core/src/components/GlobalVariablesAccessor/index.js +++ b/packages/webviz-core/src/components/GlobalVariablesAccessor/index.js @@ -8,21 +8,21 @@ import * as React from "react"; -import useGlobalData, { type GlobalData } from "webviz-core/src/hooks/useGlobalData"; +import useGlobalVariables, { type GlobalVariables } from "webviz-core/src/hooks/useGlobalVariables"; -type GlobalDataActions = {| - setGlobalData: (GlobalData) => void, - overwriteGlobalData: (GlobalData) => void, +type GlobalVariablesActions = {| + setGlobalVariables: (GlobalVariables) => void, + overwriteGlobalVariables: (GlobalVariables) => void, |}; type Props = {| - children: (GlobalData, GlobalDataActions) => React.Node, + children: (GlobalVariables, GlobalVariablesActions) => React.Node, |}; export default function GlobalVariablesAccessor(props: Props) { - const { globalData, setGlobalData, overwriteGlobalData } = useGlobalData(); - return props.children(globalData, { - setGlobalData, - overwriteGlobalData, + const { globalVariables, setGlobalVariables, overwriteGlobalVariables } = useGlobalVariables(); + return props.children(globalVariables, { + setGlobalVariables, + overwriteGlobalVariables, }); } diff --git a/packages/webviz-core/src/components/LayoutMenu.js b/packages/webviz-core/src/components/LayoutMenu.js index 9ecc9c590..cf511bc5b 100644 --- a/packages/webviz-core/src/components/LayoutMenu.js +++ b/packages/webviz-core/src/components/LayoutMenu.js @@ -13,41 +13,37 @@ import React, { PureComponent } from "react"; import ChildToggle from "webviz-core/src/components/ChildToggle"; import Icon from "webviz-core/src/components/Icon"; +import { openLayoutModal } from "webviz-core/src/components/LayoutModal"; import Menu, { Item } from "webviz-core/src/components/Menu"; -type Props = { - onImportSelect: () => void, -}; - type State = { isOpen: boolean, }; -export default class LayoutMenu extends PureComponent { +export default class LayoutMenu extends PureComponent<{}, State> { state = { isOpen: false, }; - onToggle = () => { + _onToggle = () => { this.setState({ isOpen: !this.state.isOpen }); }; - onImportClick = () => { - const { onImportSelect } = this.props; + _onImportClick = () => { this.setState({ isOpen: false }); - onImportSelect(); + openLayoutModal(); }; render() { const { isOpen } = this.state; return ( - + - } onClick={this.onImportClick}> + } onClick={this._onImportClick}> Import/export layout
diff --git a/packages/webviz-core/src/components/LayoutModal.js b/packages/webviz-core/src/components/LayoutModal.js new file mode 100644 index 000000000..f1d4cb50a --- /dev/null +++ b/packages/webviz-core/src/components/LayoutModal.js @@ -0,0 +1,56 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import React, { useCallback } from "react"; +import { connect } from "react-redux"; + +import { importPanelLayout } from "webviz-core/src/actions/panels"; +import renderToBody from "webviz-core/src/components/renderToBody"; +import ShareJsonModal from "webviz-core/src/components/ShareJsonModal"; +import type { State } from "webviz-core/src/reducers"; +import type { PanelsState } from "webviz-core/src/reducers/panels"; +import type { ImportPanelLayoutPayload } from "webviz-core/src/types/panels"; + +type OwnProps = {| + onRequestClose: () => void, +|}; + +type Props = {| + ...OwnProps, + panels: PanelsState, + importPanelLayout: (ImportPanelLayoutPayload, boolean) => void, +|}; + +function UnconnectedLayoutModal({ onRequestClose, importPanelLayout, panels }: Props) { + return ( + { + importPanelLayout(layout, false); + }, + [importPanelLayout] + )} + noun="layout" + /> + ); +} + +// TODO(JP): Use useSelector and useDispatch here, but unfortunately `importPanelLayout` needs +// a `getState` function in addition to `dispatch`, so needs a bit of rework. +const LayoutModal = connect( + (state: State) => ({ + panels: state.panels, + }), + { importPanelLayout } +)(UnconnectedLayoutModal); + +export function openLayoutModal() { + const modal = renderToBody( modal.remove()} />); +} diff --git a/packages/webviz-core/src/components/MessageHistory/FrameCompatibility.js b/packages/webviz-core/src/components/MessageHistory/FrameCompatibility.js index 2620d2d12..50564b401 100644 --- a/packages/webviz-core/src/components/MessageHistory/FrameCompatibility.js +++ b/packages/webviz-core/src/components/MessageHistory/FrameCompatibility.js @@ -7,123 +7,67 @@ // You may not use this file except in compliance with the License. import hoistNonReactStatics from "hoist-non-react-statics"; -import { last, uniq } from "lodash"; -import PropTypes from "prop-types"; +import { uniq } from "lodash"; import * as React from "react"; -import { createSelector } from "reselect"; -import MessageHistory from "."; -import type { MessageHistoryData } from "webviz-core/src/components/MessageHistory"; -import { getGlobalHooks } from "webviz-core/src/loadWebviz"; -import type { Frame, Message, Topic } from "webviz-core/src/players/types"; - -type Options = $Shape<{| - topics: string[], - historySize: number, - dontRemountOnSeek: boolean, -|}>; - -const heavyTopicsWithNoTimeDependency = createSelector( - (topics: Topic[]) => topics, - (topics: Topic[]): string[] => - topics - .filter(({ datatype }) => - getGlobalHooks() - .heavyDatatypesWithNoTimeDependency() - .includes(datatype) - ) - .map(({ name }) => name) -); +import { useMessages } from "./MessageHistoryOnlyTopics"; +import { useChangeDetector } from "webviz-core/src/components/MessageHistory/hooks"; +import type { Message, Topic } from "webviz-core/src/players/types"; // This higher-order component provides compatibility between the old way of Panels receiving -// messages, using "frames" and keeping state themselves, and the new `` API which +// messages, using "frames" and keeping state themselves, and the new `useMessages` API which // manages state for you and allows in the future for more flexibility in accessing messages. // -// We simulate frames by using a `` with some low history size, and then every time -// the history changes we cut a frame up until the last frame that we sent. If the history is cleared -// we remount the component altogether, to avoid any bugs in state management (unless you pass -// `dontRemountOnSeek`, in which case you should be really sure that you are handling clearing of -// state correctly). -// -// In the future we should migrate all components to use `` directly, but that will -// likely require some expansion of its API, to allow for "reducing" messages into a certain data -// structure. Otherwise we'd have to iterate over every array element any time we receive messages, -// e.g. for topics that publish key-value pairs. -export function FrameCompatibility(ChildComponent: React.ComponentType, options: Options = {}) { - class Component extends React.PureComponent { - static displayName = `FrameCompatibility(${ChildComponent.displayName || ChildComponent.name || ""})`; - static contextTypes = { store: PropTypes.any }; - - state = {}; - _lastMessageByTopic: { [topic: string]: Message } = {}; - _key: number = 1; +// TODO(JP): Remove FrameCompatibility from the last panel where it's still used: the 3d panel! +// This is the "Scenebuilder refactor" project. +export function FrameCompatibility(ChildComponent: React.ComponentType, baseTopics: string[]) { + function FrameCompatibilityComponent(props: Props & { forwardedRef: any, topics: Topic[] }) { + const { forwardedRef, ...childProps } = props; + const [topics, setTopics] = React.useState(baseTopics); + const componentSetSubscriptions = React.useCallback((newTopics: string[]) => { + setTopics(uniq(newTopics.concat(baseTopics || []))); + }, []); - _setSubscriptions = (topics: string[]) => { - this.setState({ topics: uniq(topics.concat(options.topics || [])) }); - }; + // NOTE(JP): This is a huge abuse of the `useMessages` API. Never use `useMessages` in this way + // yourself!! `restore` and `addMessage` should be pure functions and not have side effects! + const frame = React.useRef({}); + const lastClearTime = useMessages({ + topics, + restore: React.useCallback( + () => { + frame.current = {}; + return Date.now(); + }, + [frame] + ), + addMessage: React.useCallback( + (time, message: Message) => { + frame.current[message.topic] = frame.current[message.topic] || []; + frame.current[message.topic].push(message); + return time; + }, + [frame] + ), + }).reducedValue; - render() { - const { forwardedRef, ...childProps } = this.props; - const topics = this.state.topics || options.topics || []; + const cleared = useChangeDetector([lastClearTime], false); + const latestFrame = frame.current; + frame.current = {}; - // Temporary hack to stay fast when dealing with large point clouds and such. - // TODO(JP): We should remove this hack and do this properly in the - // 3d view. - const heavyTopics = heavyTopicsWithNoTimeDependency(this.props.topics || []); - const historySize = {}; - for (const topic of topics) { - if (heavyTopics.includes(topic)) { - historySize[topic] = 1; - } else { - historySize[topic] = options.historySize || 100; - } - } - - return ( - - {({ itemsByPath, cleared }: MessageHistoryData) => { - if (cleared) { - this._lastMessageByTopic = {}; - if (!options.dontRemountOnSeek) { - this._key++; - } - } - - const frame: Frame = {}; - for (const topic: string of topics) { - if (itemsByPath[topic].length > 0) { - const messages = itemsByPath[topic].map((item) => item.message); - let index = 0; - if (this._lastMessageByTopic[topic]) { - index = messages.lastIndexOf(this._lastMessageByTopic[topic]) + 1; - if (index === 0 && !heavyTopics.includes(topic)) { - console.warn("We seem to have skipped over messages; increase historySize for FrameCompatibility!"); - } - } - if (index < messages.length) { - frame[topic] = messages.slice(index); - this._lastMessageByTopic[topic] = last(frame[topic]); - } - } - } - return ( - - ); - }} - - ); - } + return ( + + ); } + return hoistNonReactStatics( React.forwardRef((props, ref) => { - return ; + return ; }), ChildComponent ); diff --git a/packages/webviz-core/src/components/MessageHistory/FrameCompatibility.test.js b/packages/webviz-core/src/components/MessageHistory/FrameCompatibility.test.js index 642a7b252..baefc1e2f 100644 --- a/packages/webviz-core/src/components/MessageHistory/FrameCompatibility.test.js +++ b/packages/webviz-core/src/components/MessageHistory/FrameCompatibility.test.js @@ -9,6 +9,7 @@ import { mount } from "enzyme"; import { last } from "lodash"; import * as React from "react"; +import { act } from "react-dom/test-utils"; import { datatypes, messages } from "./fixture"; import { FrameCompatibility } from "webviz-core/src/components/MessageHistory/FrameCompatibility"; @@ -42,7 +43,7 @@ describe("FrameCompatibility", () => { this.props.setSubscriptions(topics); } } - const MyComponentWithFrame = FrameCompatibility(MyComponent, { topics: ["/some/topic"] }); + const MyComponentWithFrame = FrameCompatibility(MyComponent, ["/some/topic"]); const ref = React.createRef(); const provider = mount( { if (!ref.current) { throw new Error("missing ref"); } - ref.current.setSubscriptions(["/foo"]); + act(() => ref.current.setSubscriptions(["/foo"])); provider.setProps({ messages: [messages[2], fooMsg2] }); expect(last(childFn.mock.calls)[0].frame).toEqual({ "/some/topic": [messages[2]], "/foo": [fooMsg2] }); }); diff --git a/packages/webviz-core/src/components/MessageHistory/MessageHistoryInput.js b/packages/webviz-core/src/components/MessageHistory/MessageHistoryInput.js index 72ded038b..de355e388 100644 --- a/packages/webviz-core/src/components/MessageHistory/MessageHistoryInput.js +++ b/packages/webviz-core/src/components/MessageHistory/MessageHistoryInput.js @@ -25,9 +25,9 @@ import Autocomplete from "webviz-core/src/components/Autocomplete"; import Dropdown from "webviz-core/src/components/Dropdown"; import Icon from "webviz-core/src/components/Icon"; import { TOPICS_WITH_INCORRECT_HEADERS } from "webviz-core/src/components/MessageHistory/internalCommon"; -import { useMessagePipeline } from "webviz-core/src/components/MessagePipeline"; import Tooltip from "webviz-core/src/components/Tooltip"; -import useGlobalData, { type GlobalData } from "webviz-core/src/hooks/useGlobalData"; +import useGlobalVariables, { type GlobalVariables } from "webviz-core/src/hooks/useGlobalVariables"; +import * as PanelAPI from "webviz-core/src/PanelAPI"; import type { Topic } from "webviz-core/src/players/types"; import type { RosDatatypes } from "webviz-core/src/types/RosDatatypes"; import { getTopicNames } from "webviz-core/src/util/selectors"; @@ -47,14 +47,14 @@ function topicHasNoHeaderStamp(topic: Topic, datatypes: RosDatatypes): boolean { function getFirstInvalidVariableFromRosPath( rosPath: RosPath, - globalData: GlobalData + globalVariables: GlobalVariables ): ?{| variableName: string, loc: number |} { const { messagePath } = rosPath; return messagePath .map((path) => path.type === "filter" && typeof path.value === "object" && - !Object.keys(globalData).includes(path.value.variableName) + !Object.keys(globalVariables).includes(path.value.variableName) ? { variableName: path.value.variableName, loc: path.valueLoc } : undefined ) @@ -102,9 +102,9 @@ type MessageHistoryInputBaseProps = { onTimestampMethodChange?: (MessageHistoryTimestampMethod, index: ?number) => void, }; type MessageHistoryInputProps = MessageHistoryInputBaseProps & { - topics: Topic[], + topics: $ReadOnlyArray, datatypes: RosDatatypes, - globalData: GlobalData, + globalVariables: GlobalVariables, }; type MessageHistoryInputState = {| focused: boolean |}; class MessageHistoryInputUnconnected extends React.PureComponent { @@ -147,7 +147,7 @@ class MessageHistoryInputUnconnected extends React.PureComponent { // If we're dealing with a topic name, and we cannot validly end in a message type, @@ -185,11 +185,11 @@ class MessageHistoryInputUnconnected extends React.PureComponent `$${key}`); + if (invalidGlobalVariablesVariable) { + autocompleteType = "globalVariables"; + autocompleteItems = Object.keys(globalVariables).map((key) => `$${key}`); autocompleteRange = { - start: invalidGlobalDataVariable.loc, - end: invalidGlobalDataVariable.loc + invalidGlobalDataVariable.variableName.length + 1, + start: invalidGlobalVariablesVariable.loc, + end: invalidGlobalVariablesVariable.loc + invalidGlobalVariablesVariable.variableName.length + 1, }; - autocompleteFilterText = invalidGlobalDataVariable.variableName; + autocompleteFilterText = invalidGlobalVariablesVariable.variableName; } } @@ -365,14 +365,14 @@ class MessageHistoryInputUnconnected extends React.PureComponent(function MessageHistoryInput( props: MessageHistoryInputBaseProps ) { - const { globalData } = useGlobalData(); - const context = useMessagePipeline(); + const { globalVariables } = useGlobalVariables(); + const { datatypes, topics } = PanelAPI.useDataSourceInfo(); return ( ); }); diff --git a/packages/webviz-core/src/components/MessageHistory/MessageHistoryOnlyTopics.js b/packages/webviz-core/src/components/MessageHistory/MessageHistoryOnlyTopics.js index 995a4df26..3965f7080 100644 --- a/packages/webviz-core/src/components/MessageHistory/MessageHistoryOnlyTopics.js +++ b/packages/webviz-core/src/components/MessageHistory/MessageHistoryOnlyTopics.js @@ -7,13 +7,19 @@ // You may not use this file except in compliance with the License. import { useCleanup } from "@cruise-automation/hooks"; -import React, { type Node, useRef, useCallback, useMemo, useState, useEffect } from "react"; +import { type Node, useRef, useCallback, useMemo, useState, useEffect, useContext } from "react"; import type { Time } from "rosbag"; import uuid from "uuid"; -import { useChangeDetector, useShallowMemo, useMustNotChange, useShouldNotChangeOften } from "./hooks"; +import { + useChangeDetector, + useShallowMemo, + useMustNotChange, + useShouldNotChangeOften, + useContextSelector, +} from "./hooks"; import { useMessagePipeline } from "webviz-core/src/components/MessagePipeline"; -import PerfMonitor from "webviz-core/src/components/PerfMonitor"; +import PanelContext from "webviz-core/src/components/PanelContext"; import type { Message, SubscribePayload } from "webviz-core/src/players/types"; // This is an internal component which only supports topics, @@ -30,9 +36,6 @@ type MessageHistoryOnlyTopicsData = {| type MessageReducer = (T, message: Message) => T; type Props = {| - children: (MessageHistoryOnlyTopicsData) => Node, - panelType: ?string, - topicPrefix: string, topics: string[], imageScale?: number, @@ -127,16 +130,12 @@ function useSubscriptions(requestedTopics: string[], imageScale?: ?number, panel ); } -// Be sure to pass in a new render function when you want to force a rerender. -// So you probably don't want to do -// `{this._renderSomething}`. -// This might be a bit counterintuitive but we do this since performance matters here. -export default function MessageHistoryOnlyTopics(props: Props) { +const NO_MESSAGES = Object.freeze([]); + +// TODO: remove clearedRef and just return T +export function useMessages(props: Props): {| reducedValue: T, _clearedRef: {| current: boolean |} |} { const [id] = useState(() => uuid.v4()); - const { - playerState: { activeData }, - setSubscriptions, - } = useMessagePipeline(); + const { topicPrefix = "", type: panelType = undefined } = useContext(PanelContext) || {}; useShouldNotChangeOften( props.restore, @@ -151,27 +150,63 @@ export default function MessageHistoryOnlyTopics(props: Props) { "shouldn't be created on each render. (If you're using Hooks, try useCallback.)" ); - const [requestedTopics, addMessage] = useTopicPrefix(props.topicPrefix, props.topics, props.addMessage); + // TODO(jacob): is it safe to call restore() when topicPrefix changes? + const [requestedTopics, addMessage] = useTopicPrefix(topicPrefix, props.topics, props.addMessage); + const requestedTopicsSet = useMemo(() => new Set(requestedTopics), [requestedTopics]); - const subscriptions = useSubscriptions(requestedTopics, props.imageScale, props.panelType); + const subscriptions = useSubscriptions(requestedTopics, props.imageScale, panelType); + const setSubscriptions = useMessagePipeline(useCallback(({ setSubscriptions }) => setSubscriptions, [])); useEffect(() => setSubscriptions(id, subscriptions), [id, setSubscriptions, subscriptions]); useCleanup(() => setSubscriptions(id, [])); - const { children } = props; + // Keep a reference to the last messages we processed to ensure we never process them more than once. + // If the topics we care about change, the player should send us new messages soon anyway (via backfill if paused). + const lastProcessedMessagesRef = useRef(); + // Keep a ref to the latest requested topics we were rendered with, because the useMessagePipeline + // selector's dependencies aren't allowed to change. + const latestRequestedTopicsRef = useRef(requestedTopicsSet); + latestRequestedTopicsRef.current = requestedTopicsSet; + const messages = useMessagePipeline( + useCallback(({ playerState: { activeData } }) => { + if (!activeData) { + return NO_MESSAGES; // identity must not change to avoid unnecessary re-renders + } + if (lastProcessedMessagesRef.current === activeData.messages) { + return useContextSelector.BAILOUT; + } + const filteredMessages = activeData.messages.filter(({ topic }) => latestRequestedTopicsRef.current.has(topic)); + // Bail out if we didn't want any of these messages, but not if this is our first render + const shouldBail = lastProcessedMessagesRef.current && filteredMessages.length === 0; + lastProcessedMessagesRef.current = activeData.messages; + return shouldBail ? useContextSelector.BAILOUT : filteredMessages; + }, []) + ); - const messages = activeData ? activeData.messages : []; - const lastSeekTime = activeData ? activeData.lastSeekTime : 0; - const startTime = activeData ? activeData.startTime : { sec: 0, nsec: 0 }; + const lastSeekTime = useMessagePipeline( + useCallback(({ playerState: { activeData } }) => (activeData ? activeData.lastSeekTime : 0), []) + ); const clearedRef = useRef(false); const reducedValue = useReducedValue(props.restore, addMessage, lastSeekTime, messages, clearedRef); + return { reducedValue, _clearedRef: clearedRef }; +} + +export default function MessageHistoryOnlyTopics(props: {| + ...Props, + children: (MessageHistoryOnlyTopicsData) => Node, +|}) { + const { children, ...useMessagesProps } = props; + const { reducedValue, _clearedRef: clearedRef } = useMessages(useMessagesProps); + const startTime = useMessagePipeline( + useCallback(({ playerState: { activeData } }) => activeData && activeData.startTime, []) + ); return useMemo( () => { const cleared = clearedRef.current; clearedRef.current = false; - return {children({ reducedValue, cleared, startTime })}; + return children({ reducedValue, cleared, startTime: startTime || { sec: 0, nsec: 0 } }); }, - [children, id, reducedValue, startTime] + [children, clearedRef, reducedValue, startTime] ); } diff --git a/packages/webviz-core/src/components/MessageHistory/getMessageHistoryItem.js b/packages/webviz-core/src/components/MessageHistory/getMessageHistoryItem.js index 8050fee1a..0b2a3632a 100644 --- a/packages/webviz-core/src/components/MessageHistory/getMessageHistoryItem.js +++ b/packages/webviz-core/src/components/MessageHistory/getMessageHistoryItem.js @@ -13,15 +13,15 @@ import { type MessagePathStructureItemMessage, } from "."; import { type RosPath, type MessagePathFilter } from "./internalCommon"; -import type { GlobalData } from "webviz-core/src/hooks/useGlobalData"; +import type { GlobalVariables } from "webviz-core/src/hooks/useGlobalVariables"; import type { Message, Topic } from "webviz-core/src/players/types"; import type { RosDatatypes } from "webviz-core/src/types/RosDatatypes"; import { enumValuesByDatatypeAndField } from "webviz-core/src/util/selectors"; -function filterMatches(filter: MessagePathFilter, value: any, globalData: any) { +function filterMatches(filter: MessagePathFilter, value: any, globalVariables: any) { let filterValue = filter.value; if (typeof filterValue === "object") { - filterValue = globalData[filterValue.variableName]; + filterValue = globalVariables[filterValue.variableName]; } let currentValue = value; @@ -44,7 +44,7 @@ export default function getMessageHistoryItem( rosPath: RosPath, topic: Topic, datatypes: RosDatatypes, - globalData: GlobalData = {}, + globalVariables: GlobalVariables = {}, structures: { [string]: MessagePathStructureItemMessage } ): ?MessageHistoryItem { // We don't care about messages that don't match the topic we're looking for. @@ -56,7 +56,7 @@ export default function getMessageHistoryItem( // will *always* return a history item, so this is our only chance to return nothing. for (const item of rosPath.messagePath) { if (item.type === "filter") { - if (!filterMatches(item, message.message, globalData)) { + if (!filterMatches(item, message.message, globalVariables)) { return undefined; } } else { @@ -122,7 +122,7 @@ export default function getMessageHistoryItem( traverse(value[i], pathIndex + 1, newPath, structureItem.next); } } else if (pathItem.type === "filter") { - if (filterMatches(pathItem, value, globalData)) { + if (filterMatches(pathItem, value, globalVariables)) { traverse(value, pathIndex + 1, `${path}{${pathItem.repr}}`, structureItem); } } else { diff --git a/packages/webviz-core/src/components/MessageHistory/getMessageHistoryItem.test.js b/packages/webviz-core/src/components/MessageHistory/getMessageHistoryItem.test.js index ee4b87561..803874c7c 100644 --- a/packages/webviz-core/src/components/MessageHistory/getMessageHistoryItem.test.js +++ b/packages/webviz-core/src/components/MessageHistory/getMessageHistoryItem.test.js @@ -13,11 +13,11 @@ import type { Message, Topic } from "webviz-core/src/players/types"; import type { RosDatatypes } from "webviz-core/src/types/RosDatatypes"; import { topicsByTopicName } from "webviz-core/src/util/selectors"; -function addValuesWithPathsToItems(messages, rosPath, topics, datatypes, globalData) { +function addValuesWithPathsToItems(messages, rosPath, topics, datatypes, globalVariables) { const structures = messagePathStructures(datatypes); const topic = topicsByTopicName(topics)[rosPath.topicName]; return filterMap(messages, (message) => - getMessageHistoryItem(message, rosPath, topic, datatypes, globalData, structures) + getMessageHistoryItem(message, rosPath, topic, datatypes, globalVariables, structures) ); } @@ -229,7 +229,7 @@ describe("getMessageHistoryItem", () => { ]); }); - it("filters properly for globalData, and uses the filter object in the path", () => { + it("filters properly for globalVariables, and uses the filter object in the path", () => { const messages: Message[] = [ { op: "message", diff --git a/packages/webviz-core/src/components/MessageHistory/hooks.js b/packages/webviz-core/src/components/MessageHistory/hooks.js index b911d815f..02f58f3d8 100644 --- a/packages/webviz-core/src/components/MessageHistory/hooks.js +++ b/packages/webviz-core/src/components/MessageHistory/hooks.js @@ -6,7 +6,9 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. -import { useRef } from "react"; +import { isEqual } from "lodash"; +import { useRef, useLayoutEffect, useState, useContext, createContext } from "react"; // eslint-disable-line import/no-duplicates +import * as React from "react"; // eslint-disable-line import/no-duplicates import shallowequal from "shallowequal"; // Return initiallyTrue the first time, and again if any of the given deps have changed. @@ -17,6 +19,14 @@ export function useChangeDetector(deps: any[], initiallyTrue: boolean) { return changed; } +// Similar to useChangeDetector, but using deep equality check +export function useDeepChangeDetector(deps: any[], initiallyTrue: boolean) { + const ref = useRef(initiallyTrue ? undefined : deps); + const changed = !isEqual(ref.current, deps); + ref.current = deps; + return changed; +} + // Continues to return the same instance as long as shallow equality is maintained. export function useShallowMemo(value: T): T { const ref = useRef(value); @@ -55,3 +65,145 @@ export function useShouldNotChangeOften(value: T, message: string): T { prev.current = value; return value; } + +type SelectableContextHandle = { + currentValue(): T, + publish(value: T): void, + addSubscriber((T) => void): void, + removeSubscriber((T) => void): void, +}; + +export type SelectableContext = {| + +Provider: React.ComponentType<{| value: T, children: ?React.Node |}>, + +_ctx: React.Context>, +|}; + +// Create a context for use with useContextSelector +export function createSelectableContext(): SelectableContext { + // Use a regular React context whose value never changes to provide access to the handle + // from nested components' calls to useContextSelector. + const ctx = createContext(); + + function Provider({ value, children }) { + const [handle] = useState(() => { + let currentValue = value; + const subscribers = new Set(); + return { + publish(value) { + currentValue = value; + for (const sub of subscribers) { + sub(currentValue); + } + }, + currentValue() { + return currentValue; + }, + addSubscriber(sub) { + subscribers.add(sub); + }, + removeSubscriber(sub) { + subscribers.delete(sub); + }, + }; + }); + + const valueChanged = useChangeDetector([value], false); + if (valueChanged) { + handle.publish(value); + } + + return {children}; + } + + return { + Provider, + _ctx: ctx, + }; +} + +export opaque type BailoutToken = Symbol; +const BAILOUT: BailoutToken = Symbol("BAILOUT"); + +function isBailout(value: mixed): boolean %checks { + // The opaque type above isn't enough to convince Flow that `=== BAILOUT` is a type check for + // BailoutToken, so we have to lie and say that we check for any Symbol. + return (value === useContextSelector.BAILOUT /*:: || value instanceof Symbol */); +} + +// `useContextSelector(context, selector)` behaves like `selector(useContext(context))`, but +// only triggers a re-render when the selected value actually changes. +// +// Changing the selector will not cause the context to be re-processed, so the selector must +// not have external dependencies that change over time. +// +// `useContextSelector.BAILOUT` can be returned from the selector as a special sentinel that indicates +// no update should occur. (Returning BAILOUT from the first call to selector is not allowed.) +export function useContextSelector(context: SelectableContext, selector: (T) => U | BailoutToken): U { + // eslint-disable-next-line no-underscore-dangle + const handle = useContext(context._ctx); + if (!handle) { + throw new Error(`useContextSelector was used outside a corresponding .`); + } + + useShouldNotChangeOften( + selector, + "useContextSelector() selector is changing frequently. " + + "Changing the selector will not cause the current context to be re-processed, " + + "so you may have a bug if the selector depends on external state. " + + "Wrap your selector in a useCallback() to silence this warning." + ); + + const [selectedValue, setSelectedValue] = useState(() => { + const value = selector(handle.currentValue()); + if (isBailout(value)) { + throw new Error("Initial selector call must not return BAILOUT"); + } + return value; + }); + + const latestSelectedValue = useRef(); + useLayoutEffect(() => { + latestSelectedValue.current = selectedValue; + }); + + // Subscribe to context updates, and setSelectedValue() only when the selected value changes. + useLayoutEffect( + () => { + const sub = (newValue) => { + const newSelectedValue = selector(newValue); + if (isBailout(newSelectedValue)) { + return; + } + if (newSelectedValue !== latestSelectedValue.current) { + // Because newSelectedValue might be a function, we have to always use the reducer form of setState. + setSelectedValue(() => newSelectedValue); + } + }; + handle.addSubscriber(sub); + return () => { + handle.removeSubscriber(sub); + }; + }, + [handle, selector] + ); + + return selectedValue; +} + +useContextSelector.BAILOUT = BAILOUT; + +// `useReducedValue` produces a new state based on the previous state and new inputs. +// the new state is only set when inputs have changed based on deep comparison. +export function useReducedValue( + initialState: State, + currentInputs: Inputs, + reducer: (State, Inputs) => State +): State { + useMustNotChange(reducer, "reducer for useReducedValue should never change"); + const prevStateRef = useRef(initialState); + const inputChanged = useDeepChangeDetector(currentInputs, false); + if (inputChanged) { + prevStateRef.current = reducer(prevStateRef.current, currentInputs); + } + return prevStateRef.current; +} diff --git a/packages/webviz-core/src/components/MessageHistory/hooks.test.js b/packages/webviz-core/src/components/MessageHistory/hooks.test.js index 53566564c..c053185af 100644 --- a/packages/webviz-core/src/components/MessageHistory/hooks.test.js +++ b/packages/webviz-core/src/components/MessageHistory/hooks.test.js @@ -7,8 +7,20 @@ // You may not use this file except in compliance with the License. import { renderHook } from "@testing-library/react-hooks"; +import { mount } from "enzyme"; +import * as React from "react"; -import { useChangeDetector, useShallowMemo, useMustNotChange, useShouldNotChangeOften } from "./hooks"; +import { + useChangeDetector, + useDeepChangeDetector, + useShallowMemo, + useMustNotChange, + useShouldNotChangeOften, + createSelectableContext, + useContextSelector, + type SelectableContext, + useReducedValue, +} from "./hooks"; describe("useChangeDetector", () => { it("returns true only when value changes", () => { @@ -45,6 +57,40 @@ describe("useChangeDetector", () => { }); }); +describe("useDeepChangeDetector", () => { + it("returns true only when value changes", () => { + for (const initialValue of [true, false]) { + const { result, rerender } = renderHook((deps) => useDeepChangeDetector(deps, initialValue), { + initialProps: [1, 1], + }); + expect(result.current).toBe(initialValue); + rerender([1, 1]); + expect(result.current).toBe(false); + rerender([2, 1]); + expect(result.current).toBe(true); + rerender([2, 1]); + expect(result.current).toBe(false); + rerender([2, 2]); + expect(result.current).toBe(true); + rerender([2, 2]); + expect(result.current).toBe(false); + } + }); + + it("uses deep comparison (lodash isEqual) for equality check", () => { + const obj = { name: "foo" }; + const objInArr = { name: "bar" }; + const { result, rerender } = renderHook((deps) => useDeepChangeDetector(deps, false), { + initialProps: [[1, objInArr], "a", obj], + }); + expect(result.current).toBe(false); + rerender([[1, objInArr], "a", obj]); + expect(result.current).toBe(false); + rerender([[1, { name: "bar" }], "a", { name: "foo" }]); + expect(result.current).toBe(false); + }); +}); + describe("useShallowMemo", () => { it("returns original object when shallowly equal", () => { let obj = { x: 1 }; @@ -77,6 +123,32 @@ describe("useMustNotChange", () => { }); }); +describe("useReducedValue", () => { + it("returns a new state only when the input values have changed (deep comparison)", () => { + const initialState = { name: "some name" }; + const mockFn = jest.fn(); + const input = ["foo", { name: "some other name" }]; + + function reducer(prevState, currentInput) { + const newState = currentInput.length ? { name: currentInput[0] } : prevState; + mockFn(newState); + return newState; + } + + const { result, rerender } = renderHook((val) => useReducedValue(initialState, val, reducer), { + initialProps: input, + }); + rerender(input); + expect(result.current).toEqual({ name: "some name" }); + rerender(["foo", { name: "some other name" }]); + expect(result.current).toEqual({ name: "some name" }); + expect(mockFn).toHaveBeenCalledTimes(0); + rerender(["bar", { name: "some other name" }]); + expect(result.current).toEqual({ name: "bar" }); + expect(mockFn).toHaveBeenCalledTimes(1); + }); +}); + describe("useShouldNotChangeOften", () => { it("logs when value changes twice in a row", () => { const warn = jest.spyOn(console, "warn").mockReturnValue(); @@ -96,3 +168,228 @@ describe("useShouldNotChangeOften", () => { warn.mockRestore(); }); }); + +describe("createSelectableContext/useContextSelector", () => { + function createTestConsumer(ctx: SelectableContext, selector: (T) => U) { + function Consumer() { + const value = useContextSelector(ctx, Consumer.selectorFn); + return Consumer.renderFn(value); + } + Consumer.selectorFn = jest.fn().mockImplementation(selector); + Consumer.renderFn = jest.fn().mockImplementation(() => null); + return Consumer; + } + + it("throws when selector is used outside a provider", () => { + jest.spyOn(console, "error").mockReturnValue(); // Library logs an error. + const C = createSelectableContext(); + const Consumer = createTestConsumer(C, (x) => x); + + expect(() => mount()).toThrow("useContextSelector was used outside a corresponding ."); + }); + + it("throws when first selector call returns BAILOUT", () => { + jest.spyOn(console, "error").mockReturnValue(); // Library logs an error. + const C = createSelectableContext(); + const Consumer = createTestConsumer(C, (x) => useContextSelector.BAILOUT); + + expect(() => + mount( + + + + ) + ).toThrow("Initial selector call must not return BAILOUT"); + }); + + it("calls selector and render once with initial value", () => { + const C = createSelectableContext(); + const Consumer = createTestConsumer(C, (x) => x); + + const root = mount( + + + + ); + + root.update(); + root.update(); + + expect(Consumer.selectorFn.mock.calls).toEqual([[1]]); + expect(Consumer.renderFn.mock.calls).toEqual([[1]]); + + root.unmount(); + }); + + it("re-renders when selector returns new value that isn't BAILOUT", () => { + const C = createSelectableContext(); + const Consumer = createTestConsumer(C, ({ num }) => (num === 3 ? useContextSelector.BAILOUT : num)); + + const root = mount( + + + + ); + + expect(Consumer.selectorFn.mock.calls).toEqual([[{ num: 1 }]]); + expect(Consumer.renderFn.mock.calls).toEqual([[1]]); + + root.setProps({ value: { num: 1 } }); + expect(Consumer.selectorFn.mock.calls).toEqual([[{ num: 1 }], [{ num: 1 }]]); + expect(Consumer.renderFn.mock.calls).toEqual([[1]]); + + root.setProps({ value: { num: 2 } }); + expect(Consumer.selectorFn.mock.calls).toEqual([[{ num: 1 }], [{ num: 1 }], [{ num: 2 }]]); + expect(Consumer.renderFn.mock.calls).toEqual([[1], [2]]); + + // Selector returns BAILOUT, so no update should occur + root.setProps({ value: { num: 3 } }); + expect(Consumer.selectorFn.mock.calls).toEqual([[{ num: 1 }], [{ num: 1 }], [{ num: 2 }], [{ num: 3 }]]); + expect(Consumer.renderFn.mock.calls).toEqual([[1], [2]]); + + root.setProps({ value: { num: 4 } }); + expect(Consumer.selectorFn.mock.calls).toEqual([ + [{ num: 1 }], + [{ num: 1 }], + [{ num: 2 }], + [{ num: 3 }], + [{ num: 4 }], + ]); + expect(Consumer.renderFn.mock.calls).toEqual([[1], [2], [4]]); + + root.unmount(); + }); + + it("propagates value to multiple consumers", () => { + const C = createSelectableContext(); + const Consumer1 = createTestConsumer(C, ({ one }) => one); + const Consumer2 = createTestConsumer(C, ({ two }) => two); + + const root = mount( + + +
+ +
+
+ ); + + expect(Consumer1.selectorFn).toHaveBeenCalledTimes(1); + expect(Consumer1.renderFn.mock.calls).toEqual([[1]]); + expect(Consumer2.selectorFn).toHaveBeenCalledTimes(1); + expect(Consumer2.renderFn.mock.calls).toEqual([[2]]); + + root.setProps({ value: { one: 1, two: 22 } }); + expect(Consumer1.selectorFn).toHaveBeenCalledTimes(2); + expect(Consumer1.renderFn.mock.calls).toEqual([[1]]); + expect(Consumer2.selectorFn).toHaveBeenCalledTimes(2); + expect(Consumer2.renderFn.mock.calls).toEqual([[2], [22]]); + + root.setProps({ value: { one: 11, two: 22 } }); + expect(Consumer1.selectorFn).toHaveBeenCalledTimes(3); + expect(Consumer1.renderFn.mock.calls).toEqual([[1], [11]]); + expect(Consumer2.selectorFn).toHaveBeenCalledTimes(3); + expect(Consumer2.renderFn.mock.calls).toEqual([[2], [22]]); + + root.unmount(); + }); + + it("doesn't call selector after unmount", () => { + const C = createSelectableContext(); + const Consumer = createTestConsumer(C, ({ num }) => num); + + const root = mount( + + + + ); + + expect(Consumer.selectorFn.mock.calls).toEqual([[{ num: 1 }]]); + expect(Consumer.renderFn.mock.calls).toEqual([[1]]); + + root.setProps({ children: null, value: { num: 2 } }); + expect(Consumer.selectorFn.mock.calls).toEqual([[{ num: 1 }], [{ num: 2 }]]); + expect(Consumer.renderFn.mock.calls).toEqual([[1]]); + + root.setProps({ value: { num: 3 } }); + expect(Consumer.selectorFn.mock.calls).toEqual([[{ num: 1 }], [{ num: 2 }]]); + expect(Consumer.renderFn.mock.calls).toEqual([[1]]); + + root.unmount(); + }); + + it("batches updates when a component subscribes multiple times", () => { + const C = createSelectableContext(); + + const selector1 = jest.fn().mockImplementation(({ x }) => x); + const selector2 = jest.fn().mockImplementation(({ y }) => y); + const selector3 = jest.fn().mockImplementation(({ z }) => z); + + const renderFn = jest.fn().mockImplementation(() => null); + + function clearMocks() { + selector1.mockClear(); + selector2.mockClear(); + selector3.mockClear(); + renderFn.mockClear(); + } + + function Test() { + const x = useContextSelector(C, selector1); + const y = useContextSelector(C, selector2); + const z = useContextSelector(C, selector3); + return renderFn([x, y, z]); + } + + const root = mount( + + + + ); + + expect(selector1.mock.calls).toEqual([[{ x: 0, y: 0, z: 0 }]]); + expect(selector2.mock.calls).toEqual([[{ x: 0, y: 0, z: 0 }]]); + expect(selector3.mock.calls).toEqual([[{ x: 0, y: 0, z: 0 }]]); + expect(renderFn.mock.calls).toEqual([[[0, 0, 0]]]); + + clearMocks(); + root.setProps({ value: { x: 1, y: 0, z: 0 } }); + expect(selector1.mock.calls).toEqual([[{ x: 1, y: 0, z: 0 }]]); + expect(selector2.mock.calls).toEqual([[{ x: 1, y: 0, z: 0 }]]); + expect(selector3.mock.calls).toEqual([[{ x: 1, y: 0, z: 0 }]]); + expect(renderFn.mock.calls).toEqual([[[1, 0, 0]]]); + + clearMocks(); + root.setProps({ value: { x: 1, y: 2, z: 3 } }); + expect(selector1.mock.calls).toEqual([[{ x: 1, y: 2, z: 3 }]]); + expect(selector2.mock.calls).toEqual([[{ x: 1, y: 2, z: 3 }]]); + expect(selector3.mock.calls).toEqual([[{ x: 1, y: 2, z: 3 }]]); + expect(renderFn.mock.calls).toEqual([[[1, 2, 3]]]); + + root.unmount(); + }); + + it("works with function values", () => { + const C = createSelectableContext(); + const Consumer = createTestConsumer(C, (x) => x); + + const fn1 = () => { + throw new Error("should not be called"); + }; + const fn2 = () => { + throw new Error("should not be called"); + }; + const root = mount( + + + + ); + + root.setProps({ value: fn2 }); + + expect(Consumer.selectorFn.mock.calls).toEqual([[fn1], [fn2]]); + expect(Consumer.renderFn.mock.calls).toEqual([[fn1], [fn2]]); + + root.unmount(); + }); +}); diff --git a/packages/webviz-core/src/components/MessageHistory/index.js b/packages/webviz-core/src/components/MessageHistory/index.js index 9b6b39a45..5711e6986 100644 --- a/packages/webviz-core/src/components/MessageHistory/index.js +++ b/packages/webviz-core/src/components/MessageHistory/index.js @@ -6,8 +6,8 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. -import { last } from "lodash"; -import React, { type Node, useContext, useCallback, useMemo } from "react"; +import { last, keyBy } from "lodash"; +import React, { type Node, useCallback, useMemo } from "react"; import type { Time } from "rosbag"; import getMessageHistoryItem from "./getMessageHistoryItem"; @@ -16,10 +16,9 @@ import { TOPICS_WITH_INCORRECT_HEADERS, type RosPrimitive, type RosPath } from " import MessageHistoryOnlyTopics from "./MessageHistoryOnlyTopics"; import { messagePathStructures, traverseStructure } from "./messagePathsForDatatype"; import parseRosPath from "./parseRosPath"; -import { useMessagePipeline } from "webviz-core/src/components/MessagePipeline"; -import PanelContext from "webviz-core/src/components/PanelContext"; import filterMap from "webviz-core/src/filterMap"; -import useGlobalData from "webviz-core/src/hooks/useGlobalData"; +import useGlobalVariables from "webviz-core/src/hooks/useGlobalVariables"; +import * as PanelAPI from "webviz-core/src/PanelAPI"; import type { Message } from "webviz-core/src/players/types"; // Use `` to get data from the player, typically a bag or ROS websocket bridge. @@ -158,10 +157,10 @@ export function getTimestampForMessage(message: Message, timestampMethod?: Messa // So you probably don't want to do `{this._renderSomething}`. // This might be a bit counterintuitive but we do this since performance matters here. export default React.memo(function MessageHistory({ children, paths, historySize, imageScale }: Props) { - const { globalData } = useGlobalData(); - const { datatypes, sortedTopics } = useMessagePipeline(); - const panelContext = useContext(PanelContext); - const topicPrefix = (panelContext || {}).topicPrefix || ""; + const { globalVariables } = useGlobalVariables(); + const { datatypes, topics: sortedTopics } = PanelAPI.useDataSourceInfo(); + + const memoizedTopicsByName = useMemo(() => keyBy(sortedTopics, ({ name }) => name), [sortedTopics]); const structures = messagePathStructures(datatypes); @@ -179,9 +178,9 @@ export default React.memo(function MessageHistory({ children, paths, hist pathsByTopic[rosPath.topicName] = pathsByTopic[rosPath.topicName] || []; pathsByTopic[rosPath.topicName].push({ path, rosPath }); - const originalTopic = sortedTopics.find((topic) => topic.name === topicPrefix + rosPath.topicName); - if (originalTopic) { - const { structureItem } = traverseStructure(structures[originalTopic.datatype], rosPath.messagePath); + const topic = memoizedTopicsByName[rosPath.topicName]; + if (topic) { + const { structureItem } = traverseStructure(structures[topic.datatype], rosPath.messagePath); if (structureItem) { metadataByPath[path] = { structureItem }; } @@ -190,7 +189,7 @@ export default React.memo(function MessageHistory({ children, paths, hist } return [rosPaths, metadataByPath, pathsByTopic, Object.keys(pathsByTopic)]; }, - [topicPrefix, memoizedPaths, sortedTopics, structures] + [memoizedPaths, memoizedTopicsByName, structures] ); const memoizedHistorySize = useShallowMemo(historySize); @@ -210,13 +209,11 @@ export default React.memo(function MessageHistory({ children, paths, hist let newItemsByPath; for (const { path, rosPath } of pathsMatchingMessage) { - const originalTopic = sortedTopics.find((topic) => topic.name === topicPrefix + rosPath.topicName); - if (!originalTopic) { - throw new Error( - `Missing topic (${topicPrefix}${rosPath.topicName}) for received message; this should never happen` - ); + const topic = memoizedTopicsByName[rosPath.topicName]; + if (!topic) { + throw new Error(`Missing topic (${rosPath.topicName}) for received message; this should never happen`); } - const item = getMessageHistoryItem(message, rosPath, originalTopic, datatypes, globalData, structures); + const item = getMessageHistoryItem(message, rosPath, topic, datatypes, globalVariables, structures); if (!item) { continue; } @@ -232,7 +229,7 @@ export default React.memo(function MessageHistory({ children, paths, hist } return newItemsByPath || itemsByPath; }, - [datatypes, sortedTopics, globalData, memoizedHistorySize, pathsByTopic, structures, topicPrefix] + [datatypes, memoizedTopicsByName, globalVariables, memoizedHistorySize, pathsByTopic, structures] ); // Create itemsByPath, using prevItemsByPath if items were present for the same topics. @@ -269,8 +266,8 @@ export default React.memo(function MessageHistory({ children, paths, hist itemsByPath[path] = []; continue; } - const originalTopic = sortedTopics.find((topic) => topic.name === topicPrefix + topicName); - if (!originalTopic) { + const topic = memoizedTopicsByName[topicName]; + if (!topic) { itemsByPath[path] = []; continue; } @@ -280,20 +277,20 @@ export default React.memo(function MessageHistory({ children, paths, hist // copy the items over; we must run them through getMessageHistoryItem again.) if (prevItemsByPath[path]) { itemsByPath[path] = filterMap(prevItemsByPath[path], ({ message }) => - getMessageHistoryItem(message, rosPath, originalTopic, datatypes, globalData, structures) + getMessageHistoryItem(message, rosPath, topic, datatypes, globalVariables, structures) ); continue; } // Extract items for this new path from the original messages. itemsByPath[path] = filterMap(messagesByTopic[topicName], (message) => - getMessageHistoryItem(message, rosPath, originalTopic, datatypes, globalData, structures) + getMessageHistoryItem(message, rosPath, topic, datatypes, globalVariables, structures) ); } return itemsByPath; }, - [datatypes, sortedTopics, globalData, memoizedPaths, rosPaths, structures, topicPrefix] + [datatypes, memoizedTopicsByName, globalVariables, memoizedPaths, rosPaths, structures] ); const renderChildren = useCallback( @@ -305,8 +302,6 @@ export default React.memo(function MessageHistory({ children, paths, hist return ( { @@ -65,30 +66,28 @@ const clickInput = (el) => { } }; -type MessageHistoryInputStoryProps = { path: string }; +function MessageHistoryInputStory(props: {| path: string, topicPrefix?: string |}) { + const [path, setPath] = React.useState(props.path); -class MessageHistoryInputStory extends React.Component { - state = { path: this.props.path }; - - render() { - return ( + return ( + - { - this.setState({ path: newPath }); - }} - timestampMethod="receiveTime" - /> + setPath(newPath)} timestampMethod="receiveTime" /> - ); - } + + ); } storiesOf("", module) .addDecorator(withScreenshot()) + .add("autocomplete topics", () => { + return ; + }) + .add("autocomplete topics with topicPrefix", () => { + return ; + }) .add("autocomplete messagePath", () => { return ; }) @@ -98,15 +97,15 @@ storiesOf("", module) .add("autocomplete top level filter", () => { return ; }) - .add("autocomplete for globalData variables", () => { + .add("autocomplete for globalVariables variables", () => { return ; }) - .add("path with valid globalData variable", () => { + .add("path with valid globalVariables variable", () => { return ; }) - .add("path with invalid globalData variable", () => { + .add("path with invalid globalVariables variable", () => { return ; }) - .add("path with incorrectly prefixed globalData variable", () => { + .add("path with incorrectly prefixed globalVariables variable", () => { return ; }); diff --git a/packages/webviz-core/src/components/MessageHistory/index.test.js b/packages/webviz-core/src/components/MessageHistory/index.test.js index 683d23594..6f95624fb 100644 --- a/packages/webviz-core/src/components/MessageHistory/index.test.js +++ b/packages/webviz-core/src/components/MessageHistory/index.test.js @@ -13,7 +13,7 @@ import React from "react"; import MessageHistory from "."; import { datatypes, messages, dualInputMessages } from "./fixture"; -import { setGlobalData } from "webviz-core/src/actions/panels"; +import { setGlobalVariables } from "webviz-core/src/actions/panels"; import { MockMessagePipelineProvider } from "webviz-core/src/components/MessagePipeline"; import { MockPanelContextProvider } from "webviz-core/src/components/Panel"; import createRootReducer from "webviz-core/src/reducers"; @@ -117,14 +117,6 @@ describe("", () => { startTime: { sec: 100, nsec: 0 }, }, ], - [ - { - cleared: false, - itemsByPath: { "/some/topic": [] }, - metadataByPath: {}, - startTime: { sec: 100, nsec: 0 }, - }, - ], [ { cleared: false, @@ -391,7 +383,7 @@ describe("", () => { const store = configureStore(createRootReducer(createMemoryHistory())); - store.dispatch(setGlobalData({ foo: 0 })); + store.dispatch(setGlobalVariables({ foo: 0 })); const provider = mount( ", () => { childFn.mockClear(); // when $foo changes to 1, queriedData.value should change to 11 - store.dispatch(setGlobalData({ foo: 1 })); + store.dispatch(setGlobalVariables({ foo: 1 })); expect(childFn.mock.calls).toEqual([ [ { diff --git a/packages/webviz-core/src/components/MessagePipeline/index.js b/packages/webviz-core/src/components/MessagePipeline/index.js index cb4c2d3f0..b95e13897 100644 --- a/packages/webviz-core/src/components/MessagePipeline/index.js +++ b/packages/webviz-core/src/components/MessagePipeline/index.js @@ -9,11 +9,17 @@ import { createMemoryHistory } from "history"; import { flatten, groupBy } from "lodash"; import * as React from "react"; // eslint-disable-line import/no-duplicates -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useContext } from "react"; // eslint-disable-line import/no-duplicates +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; // eslint-disable-line import/no-duplicates import { Provider } from "react-redux"; import { type Time, TimeUtil } from "rosbag"; import warnOnOutOfSyncMessages from "./warnOnOutOfSyncMessages"; +import { + useShallowMemo, + createSelectableContext, + useContextSelector, + type BailoutToken, +} from "webviz-core/src/components/MessageHistory/hooks"; import type { AdvertisePayload, Frame, @@ -46,14 +52,10 @@ export type MessagePipelineContext = {| seekPlayback(time: Time): void, |}; -const Context: React.Context = React.createContext(); +const Context = createSelectableContext(); -export function useMessagePipeline(): MessagePipelineContext { - const context = useContext(Context); - if (!context) { - throw new Error("Component must be nested within a to access the message pipeline."); - } - return context; +export function useMessagePipeline(selector: (MessagePipelineContext) => T | BailoutToken): T { + return useContextSelector(Context, selector); } function defaultPlayerState(): PlayerState { @@ -150,40 +152,50 @@ export function MessagePipelineProvider({ children, player }: ProviderProps) { const messages: ?(Message[]) = playerState.activeData ? playerState.activeData.messages : undefined; const topics: ?(Topic[]) = playerState.activeData ? playerState.activeData.topics : undefined; + const frame = useMemo(() => groupBy(messages || [], "topic"), [messages]); + const sortedTopics = useMemo(() => (topics || []).sort(naturalSort("name")), [topics]); + const datatypes = useMemo( + () => { + return playerState.activeData ? playerState.activeData.datatypes : {}; + }, + [playerState.activeData && playerState.activeData.datatypes] // eslint-disable-line react-hooks/exhaustive-deps + ); + const setSubscriptions = useCallback( + (id: string, subscriptionsForId: SubscribePayload[]) => { + setAllSubscriptions((s) => ({ ...s, [id]: subscriptionsForId })); + }, + [setAllSubscriptions] + ); + const setPublishers = useCallback( + (id: string, publishersForId: AdvertisePayload[]) => { + setAllPublishers((p) => ({ ...p, [id]: publishersForId })); + }, + [setAllPublishers] + ); + const publish = useCallback((request: PublishPayload) => (player ? player.publish(request) : undefined), [player]); + const startPlayback = useCallback(() => (player ? player.startPlayback() : undefined), [player]); + const pausePlayback = useCallback(() => (player ? player.pausePlayback() : undefined), [player]); + const setPlaybackSpeed = useCallback((speed: number) => (player ? player.setPlaybackSpeed(speed) : undefined), [ + player, + ]); + const seekPlayback = useCallback((time: Time) => (player ? player.seekPlayback(time) : undefined), [player]); return ( groupBy(messages || [], "topic"), [messages]), - sortedTopics: useMemo(() => (topics || []).sort(naturalSort("name")), [topics]), - datatypes: useMemo( - () => { - return playerState.activeData ? playerState.activeData.datatypes : {}; - }, - [playerState.activeData && playerState.activeData.datatypes] // eslint-disable-line react-hooks/exhaustive-deps - ), - setSubscriptions: useCallback( - (id: string, subscriptionsForId: SubscribePayload[]) => { - setAllSubscriptions((s) => ({ ...s, [id]: subscriptionsForId })); - }, - [setAllSubscriptions] - ), - setPublishers: useCallback( - (id: string, publishersForId: AdvertisePayload[]) => { - setAllPublishers((p) => ({ ...p, [id]: publishersForId })); - }, - [setAllPublishers] - ), - publish: useCallback((request: PublishPayload) => (player ? player.publish(request) : undefined), [player]), - startPlayback: useCallback(() => (player ? player.startPlayback() : undefined), [player]), - pausePlayback: useCallback(() => (player ? player.pausePlayback() : undefined), [player]), - setPlaybackSpeed: useCallback((speed: number) => (player ? player.setPlaybackSpeed(speed) : undefined), [ - player, - ]), - seekPlayback: useCallback((time: Time) => (player ? player.seekPlayback(time) : undefined), [player]), - }}> + frame, + sortedTopics, + datatypes, + setSubscriptions, + setPublishers, + publish, + startPlayback, + pausePlayback, + setPlaybackSpeed, + seekPlayback, + })}> {children} ); @@ -191,20 +203,24 @@ export function MessagePipelineProvider({ children, player }: ProviderProps) { type ConsumerProps = { children: (MessagePipelineContext) => React.Node }; export function MessagePipelineConsumer({ children }: ConsumerProps) { - const value = useMessagePipeline(); + const value = useMessagePipeline(useCallback((ctx) => ctx, [])); return children(value); } +// TODO(Audrey): put messages under activeData, add ability to mock seeking export function MockMessagePipelineProvider(props: {| children: React.Node, topics?: Topic[], datatypes?: RosDatatypes, messages?: Message[], setSubscriptions?: (string, SubscribePayload[]) => void, + noActiveData?: boolean, activeData?: $Shape, capabilities?: string[], store?: any, seekPlayback?: (Time) => void, + startTime?: Time, + endTime?: Time, |}) { const storeRef = useRef(props.store || configureStore(createRootReducer(createMemoryHistory()))); const startTime = useRef(); @@ -227,30 +243,49 @@ export function MockMessagePipelineProvider(props: {| setAllSubscriptions, ]); + const capabilities = useShallowMemo(props.capabilities || []); + + const playerState = useMemo( + () => ({ + isPresent: true, + playerId: "1", + progress: {}, + showInitializing: false, + showSpinner: false, + capabilities, + activeData: props.noActiveData + ? undefined + : { + messages: props.messages || [], + topics: props.topics || [], + datatypes: props.datatypes || {}, + startTime: props.startTime || startTime.current || { sec: 100, nsec: 0 }, + currentTime: currentTime || { sec: 100, nsec: 0 }, + endTime: props.endTime || currentTime || { sec: 100, nsec: 0 }, + isPlaying: false, + speed: 0.2, + lastSeekTime: 0, + ...props.activeData, + }, + }), + [ + capabilities, + currentTime, + props.messages, + props.topics, + props.datatypes, + props.startTime, + props.endTime, + props.activeData, + props.noActiveData, + ] + ); + return ( { ]); }); - it("updates when the player emits a new state", () => { + it("updates when the player emits a new state", async () => { const player = new FakePlayer(); const callback = jest.fn().mockReturnValue(null); mount( @@ -59,7 +60,9 @@ describe("MessagePipelineProvider/MessagePipelineConsumer", () => { {callback} ); - player.emit(); + act(() => { + player.emit(); + }); expect(callback.mock.calls).toEqual([ [ expect.objectContaining({ @@ -98,8 +101,10 @@ describe("MessagePipelineProvider/MessagePipelineConsumer", () => { {callback} ); - player.emit(); - expect(() => player.emit()).toThrow(); + act(() => { + player.emit(); + }); + expect(() => player.emit()).toThrow("New playerState was emitted before last playerState was rendered."); }); it("sets subscriptions", (done) => { @@ -116,8 +121,8 @@ describe("MessagePipelineProvider/MessagePipelineConsumer", () => { // update the subscriptions immediately after render, not during // calling this on the same tick as render causes an error because we're setting state during render loop setImmediate(() => { - context.setSubscriptions("test", [{ topic: "/webviz/test" }]); - context.setSubscriptions("bar", [{ topic: "/webviz/test2" }]); + act(() => context.setSubscriptions("test", [{ topic: "/webviz/test" }])); + act(() => context.setSubscriptions("bar", [{ topic: "/webviz/test2" }])); }); } if (callCount === 2) { @@ -127,7 +132,9 @@ describe("MessagePipelineProvider/MessagePipelineConsumer", () => { expect(context.subscriptions).toEqual([{ topic: "/webviz/test" }, { topic: "/webviz/test2" }]); // cause the player to emit a frame outside the render loop to trigger another render setImmediate(() => { - player.emit(); + act(() => { + player.emit(); + }); }); } if (callCount === 4) { @@ -156,8 +163,8 @@ describe("MessagePipelineProvider/MessagePipelineConsumer", () => { // update the publishers immediately after render, not during // calling this on the same tick as render causes an error because we're setting state during render loop setImmediate(() => { - context.setPublishers("test", [{ topic: "/webviz/test", datatype: "test" }]); - context.setPublishers("bar", [{ topic: "/webviz/test2", datatype: "test2" }]); + act(() => context.setPublishers("test", [{ topic: "/webviz/test", datatype: "test" }])); + act(() => context.setPublishers("bar", [{ topic: "/webviz/test2", datatype: "test2" }])); }); } if (callCount === 2) { @@ -170,7 +177,9 @@ describe("MessagePipelineProvider/MessagePipelineConsumer", () => { ]); // cause the player to emit a frame outside the render loop to trigger another render setImmediate(() => { - player.emit(); + act(() => { + player.emit(); + }); }); } if (callCount === 4) { @@ -202,7 +211,9 @@ describe("MessagePipelineProvider/MessagePipelineConsumer", () => { // cause the player to emit a frame outside the render loop to trigger another render setImmediate(() => { lastPromise.then(() => { - lastPromise = player.emit(); + act(() => { + lastPromise = player.emit(); + }); }); }); // we don't have a last context yet @@ -235,9 +246,9 @@ describe("MessagePipelineProvider/MessagePipelineConsumer", () => { expect(callback).toHaveBeenCalledTimes(1); // Now wait for the player state emit cycle to complete. // This promise should resolve when the render loop finishes. - await player.emit(); + await act(() => player.emit()); expect(callback).toHaveBeenCalledTimes(2); - await player.emit(); + await act(() => player.emit()); expect(callback).toHaveBeenCalledTimes(3); }); @@ -299,20 +310,20 @@ describe("MessagePipelineProvider/MessagePipelineConsumer", () => { {fn} ); - await player.emit(); + await act(() => player.emit()); expect(fn).toHaveBeenCalledTimes(2); player2 = new FakePlayer(); player2.playerId = "fake player 2"; - el.setProps({ player: player2 }); + act(() => el.setProps({ player: player2 }) && undefined); expect(player.close).toHaveBeenCalledTimes(1); expect(fn).toHaveBeenCalledTimes(4); }); it("closes old player when new player is supplied and stops old player message flow", async () => { - await player2.emit(); + await act(() => player2.emit()); expect(fn).toHaveBeenCalledTimes(5); - await player.emit(); + await act(() => player.emit()); expect(fn).toHaveBeenCalledTimes(5); expect(fn.mock.calls.map((args) => args[0].playerState.playerId)).toEqual([ "", @@ -324,9 +335,9 @@ describe("MessagePipelineProvider/MessagePipelineConsumer", () => { }); it("does not think the old player is the new player if it emits first", async () => { - await player.emit(); + await act(() => player.emit()); expect(fn).toHaveBeenCalledTimes(4); - await player2.emit(); + await act(() => player2.emit()); expect(fn).toHaveBeenCalledTimes(5); expect(fn.mock.calls.map((args) => args[0].playerState.playerId)).toEqual([ "", @@ -368,9 +379,9 @@ describe("MessagePipelineProvider/MessagePipelineConsumer", () => { // update the subscriptions immediately after render, not during // calling this on the same tick as render causes an error because we're setting state during render loop setImmediate(() => { - context.setSubscriptions("test", [{ topic: "/webviz/test" }]); - context.setSubscriptions("bar", [{ topic: "/webviz/test2" }]); - context.setPublishers("test", [{ topic: "/webviz/test", datatype: "test" }]); + act(() => context.setSubscriptions("test", [{ topic: "/webviz/test" }])); + act(() => context.setSubscriptions("bar", [{ topic: "/webviz/test2" }])); + act(() => context.setPublishers("test", [{ topic: "/webviz/test", datatype: "test" }])); wait.resolve(); }); } @@ -381,7 +392,7 @@ describe("MessagePipelineProvider/MessagePipelineConsumer", () => { ); await wait; const player2 = new FakePlayer(); - el.setProps({ player: player2 }); + act(() => el.setProps({ player: player2 }) && undefined); expect(player2.subscriptions).toEqual([{ topic: "/webviz/test" }, { topic: "/webviz/test2" }]); expect(player2.publishers).toEqual([{ topic: "/webviz/test", datatype: "test" }]); }); @@ -405,7 +416,7 @@ describe("MessagePipelineProvider/MessagePipelineConsumer", () => { topics: [{ name: "/input/foo", datatype: "foo" }], datatypes: { foo: [] }, }; - await player.emit(activeData); + await act(() => player.emit(activeData)); expect(fn).toHaveBeenCalledTimes(2); el.setProps({ player: undefined }); diff --git a/packages/webviz-core/src/components/MessagePipeline/warnOnOutOfSyncMessages.js b/packages/webviz-core/src/components/MessagePipeline/warnOnOutOfSyncMessages.js index 749a29466..127d90455 100644 --- a/packages/webviz-core/src/components/MessagePipeline/warnOnOutOfSyncMessages.js +++ b/packages/webviz-core/src/components/MessagePipeline/warnOnOutOfSyncMessages.js @@ -10,7 +10,8 @@ import { type Time, TimeUtil } from "rosbag"; import type { Message, PlayerState } from "webviz-core/src/players/types"; import Logger from "webviz-core/src/util/Logger"; -import { subtractTimes, toSec } from "webviz-core/src/util/time"; +import reportError from "webviz-core/src/util/reportError"; +import { subtractTimes, toSec, formatFrame } from "webviz-core/src/util/time"; const DRIFT_THRESHOLD_SEC = 1; // Maximum amount of drift allowed. const WAIT_FOR_SEEK_SEC = 1; // How long we wait for a change in `lastSeekTime` before warning. @@ -26,6 +27,7 @@ const log = new Logger(__filename); let lastMessages: ?(Message[]); let lastCurrentTime: ?Time; let lastReceiveTime: ?Time; +let lastReceiveTopic: ?string; let lastLastSeekTime: ?number; let warningTimeout: ?TimeoutID; let incorrectMessages: Message[] = []; @@ -65,17 +67,16 @@ export default function warnOnOutOfSyncMessages(playerState: PlayerState) { } } - if (lastReceiveTime && TimeUtil.isLessThan(message.receiveTime, lastReceiveTime)) { - incorrectMessages.push(message); - log.error("Went back in time; without updating lastSeekTime", { - lastReceiveTime, - lastSeekTime, - incorrectMessages: incorrectMessages.map((msg) => ({ - receiveTime: msg.receiveTime, - topic: msg.topic, - })), - }); + if (lastReceiveTime && lastReceiveTopic && TimeUtil.isLessThan(message.receiveTime, lastReceiveTime)) { + reportError( + "Bag went back in time", + `Received a message on ${message.topic} at ${formatFrame(message.receiveTime)} which is earlier than ` + + `last received message on ${lastReceiveTopic} at ${formatFrame(lastReceiveTime)}. ` + + `Data source may be corrupted on these or other topics.`, + "user" + ); } + lastReceiveTopic = message.topic; lastReceiveTime = message.receiveTime; } } diff --git a/packages/webviz-core/src/components/MessagePipeline/warnOnOutOfSyncMessages.test.js b/packages/webviz-core/src/components/MessagePipeline/warnOnOutOfSyncMessages.test.js new file mode 100644 index 000000000..71dbe7093 --- /dev/null +++ b/packages/webviz-core/src/components/MessagePipeline/warnOnOutOfSyncMessages.test.js @@ -0,0 +1,58 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import warnOnOutOfSyncMessages from "./warnOnOutOfSyncMessages"; +import reportError from "webviz-core/src/util/reportError"; + +jest.mock("webviz-core/src/util/reportError"); + +describe("MessagePipeline/warnOnOutOfSyncMessages", () => { + it("calls report error when messages are out of order", () => { + warnOnOutOfSyncMessages({ + isPresent: true, + showSpinner: false, + showInitializing: false, + progress: {}, + capabilities: [], + playerId: "test", + activeData: { + topics: [ + { name: "/foo", datatype: "visualization_msgs/Marker" }, + { name: "/bar", datatype: "visualization_msgs/Marker" }, + ], + datatypes: {}, + currentTime: { + sec: 1, + nsec: 11, + }, + speed: 0.2, + lastSeekTime: 1.0, + startTime: { sec: 0, nsec: 0 }, + endTime: { sec: 2, nsec: 0 }, + isPlaying: false, + messages: [ + { + topic: "/foo", + op: "message", + datatype: "visualization_msgs/Marker", + receiveTime: { sec: 1, nsec: 10 }, + message: {}, + }, + { + topic: "/bar", + op: "message", + datatype: "visualization_msgs/Marker", + receiveTime: { sec: 1, nsec: 5 }, + message: {}, + }, + ], + }, + }); + expect(reportError).toHaveBeenCalled(); + }); +}); diff --git a/packages/webviz-core/src/components/Panel.js b/packages/webviz-core/src/components/Panel.js index 291333503..1000e2e09 100644 --- a/packages/webviz-core/src/components/Panel.js +++ b/packages/webviz-core/src/components/Panel.js @@ -6,6 +6,7 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. +import CloseIcon from "@mdi/svg/svg/close.svg"; import FullscreenIcon from "@mdi/svg/svg/fullscreen.svg"; import GridLargeIcon from "@mdi/svg/svg/grid-large.svg"; import TrashCanOutlineIcon from "@mdi/svg/svg/trash-can-outline.svg"; @@ -28,10 +29,10 @@ import ErrorBoundary from "webviz-core/src/components/ErrorBoundary"; import Flex from "webviz-core/src/components/Flex"; import { Item } from "webviz-core/src/components/Menu"; import { getFilteredFormattedTopics } from "webviz-core/src/components/MessageHistory/topicPrefixUtils"; -import { useMessagePipeline } from "webviz-core/src/components/MessagePipeline"; import PanelContext, { type PanelContextType } from "webviz-core/src/components/PanelContext"; import MosaicDragHandle from "webviz-core/src/components/PanelToolbar/MosaicDragHandle"; import { getGlobalHooks } from "webviz-core/src/loadWebviz"; +import * as PanelAPI from "webviz-core/src/PanelAPI"; import PanelList, { getPanelsByType } from "webviz-core/src/panels/PanelList"; import type { Topic } from "webviz-core/src/players/types"; import type { @@ -60,6 +61,7 @@ export const TOPIC_PREFIX_CONFIG_KEY = "webviz___topicPrefix"; type Props = {| childId?: string, config?: Config |}; type State = { quickActionsKeyPressed: boolean, + shiftKeyPressed: boolean, fullScreen: boolean, removePanelKeyPressed: boolean, }; @@ -211,7 +213,14 @@ export default function Panel( mosaicWindowActions: PropTypes.any, }; - state = { quickActionsKeyPressed: false, fullScreen: false, removePanelKeyPressed: false }; + state = { + quickActionsKeyPressed: false, + shiftKeyPressed: false, + fullScreen: false, + removePanelKeyPressed: false, + }; + + _tildePressing: boolean = false; // Save the config, by mixing in the partial config with the current config, or if that does // not exist, with the `defaultConfig`. That way we always save complete configs. @@ -298,27 +307,70 @@ export default function Panel( // When using Synergy, holding down a key leads to repeated keydown/up events, so give the // keydown events a chance to cancel a pending keyup. this._keyUpTimeout = setTimeout(() => { - this.setState({ quickActionsKeyPressed: false, fullScreen: false }); + this.setState({ quickActionsKeyPressed: false, shiftKeyPressed: false, fullScreen: false }); this._keyUpTimeout = null; }, 0); }; _keyUpHandlers = { - "`": this._exitFullScreen, + "`": (e) => { + this._tildePressing = false; + const { fullScreen, shiftKeyPressed } = this.state; + if (!fullScreen || !shiftKeyPressed) { + this._exitFullScreen(); + } + }, + Shift: (e) => { + const { fullScreen, shiftKeyPressed, quickActionsKeyPressed } = this.state; + if (shiftKeyPressed && quickActionsKeyPressed && !fullScreen) { + this.setState({ shiftKeyPressed: false }); + } + }, + "~": (e) => { + if (!this.state.fullScreen) { + this.setState({ quickActionsKeyPressed: false }); + } + }, }; _keyDownHandlers = { "`": (e) => { - if (!e.repeat || !this.state.quickActionsKeyPressed) { + const { quickActionsKeyPressed, fullScreen } = this.state; + if (this._tildePressing) { + return; + } + if (!e.repeat && !quickActionsKeyPressed) { clearTimeout(this._keyUpTimeout); + this._tildePressing = true; this.setState({ quickActionsKeyPressed: true }); } + if (!e.repeat && fullScreen) { + clearTimeout(this._keyUpTimeout); + this._tildePressing = true; + this._exitFullScreen(); + } + }, + "~": (e) => { + clearTimeout(this._keyUpTimeout); + this.setState({ quickActionsKeyPressed: true, shiftKeyPressed: true }); + }, + Shift: (e) => { + if (!this.state.shiftKeyPressed) { + clearTimeout(this._keyUpTimeout); + this.setState({ shiftKeyPressed: true }); + } + }, + Escape: (e) => { + if (this.state.fullScreen || this.state.quickActionsKeyPressed || this.state.shiftKeyPressed) { + clearTimeout(this._keyUpTimeout); + this._exitFullScreen(); + } }, }; render() { const { topics, datatypes, capabilities, childId, isOnlyPanel, config = {} } = this.props; - const { quickActionsKeyPressed, fullScreen } = this.state; + const { quickActionsKeyPressed, shiftKeyPressed, fullScreen } = this.state; const panelsByType = getPanelsByType(); const type = PanelComponent.panelType; const title = panelsByType[type] && panelsByType[type].title; @@ -349,12 +401,13 @@ export default function Panel( })} col clip> + {fullScreen ?
: null} {quickActionsKeyPressed && !fullScreen && (
- Fullscreen + {shiftKeyPressed ? "Lock fullscreen" : "Fullscreen (Shift+click to lock)"}
)} + {fullScreen && shiftKeyPressed ? ( + + ) : null} {/* $FlowFixMe - https://github.com/facebook/flow/issues/6479 */} ( function ConnectedPanel(props: Props) { const store = React.useContext(ReactReduxContext).store; - const panelState = useSelector((state) => state.panels); - const context = useMessagePipeline(); + const savedProps = useSelector((state) => state.panels.savedProps[props.childId]); + const isOnlyPanel = useSelector((state) => !isParent(state.panels.layout)); + const { topics, datatypes, capabilities } = PanelAPI.useDataSourceInfo(); const dispatch = useDispatch(); const actions = React.useMemo(() => bindActionCreators({ savePanelConfig, saveFullPanelConfig }, dispatch), [ dispatch, @@ -407,11 +466,11 @@ export default function Panel( {...props} store={store} childId={props.childId} - config={panelState.savedProps[props.childId] || props.config} - isOnlyPanel={!isParent(panelState.layout)} - topics={context.sortedTopics} - datatypes={context.datatypes} - capabilities={context.playerState.capabilities} + config={savedProps || props.config} + isOnlyPanel={isOnlyPanel} + topics={topics} + datatypes={datatypes} + capabilities={capabilities} {...actions} /> ); diff --git a/packages/webviz-core/src/components/Panel.module.scss b/packages/webviz-core/src/components/Panel.module.scss index 6816f1930..7b7f16328 100644 --- a/packages/webviz-core/src/components/Panel.module.scss +++ b/packages/webviz-core/src/components/Panel.module.scss @@ -1,4 +1,5 @@ @import "~webviz-core/src/styles/colors.module.scss"; +@import "~webviz-core/src/styles/variables.scss"; .root { z-index: 1; @@ -8,12 +9,23 @@ .rootFullScreen { position: fixed; - top: 0; + z-index: 100; + border: 4px solid rgba(110, 81, 238, 0.3); + top: $topBarHeight; left: 0; right: 0; - bottom: 0; + bottom: $playbackControlHeight; +} + +.notClickable { + position: fixed; z-index: 100; - border: 4px solid rgba(110, 81, 238, 0.3); + top: 0; + left: 0; + right: 0; + height: $topBarHeight; + opacity: 0; + cursor: not-allowed; } .quickActionsOverlay { @@ -105,3 +117,29 @@ } } } + +.exitFullScreen { + position: fixed; + top: 75px; + right: 8px; + z-index: 102; + opacity: 1; + background-color: $toolbar; + display: none; + .root:hover & { + display: block; + } + &:global(.hoverForScreenshot) { + display: block; + } + svg { + width: 16px; + height: 16px; + fill: white; + float: left; + } + span { + float: right; + padding-left: 3px; + } +} diff --git a/packages/webviz-core/src/components/PanelContext.js b/packages/webviz-core/src/components/PanelContext.js index b8199bcd3..08fe3d1a9 100644 --- a/packages/webviz-core/src/components/PanelContext.js +++ b/packages/webviz-core/src/components/PanelContext.js @@ -11,12 +11,17 @@ import * as React from "react"; import type { SaveConfig, PanelConfig, UpdatePanelConfig, OpenSiblingPanel } from "webviz-core/src/types/panels"; export type PanelContextType = {| + // TODO(PanelAPI): private API, should not be used in panels type: string, id: string, title: string, topicPrefix?: string, + + // TODO(PanelAPI): move to usePanelConfig() config: PanelConfig, saveConfig: SaveConfig, + + // TODO(PanelAPI): move to usePanelActions() updatePanelConfig: UpdatePanelConfig, openSiblingPanel: OpenSiblingPanel, |}; diff --git a/packages/webviz-core/src/components/PerfMonitor/index.js b/packages/webviz-core/src/components/PerfMonitor/index.js deleted file mode 100644 index d4b07efcc..000000000 --- a/packages/webviz-core/src/components/PerfMonitor/index.js +++ /dev/null @@ -1,86 +0,0 @@ -// @flow -// -// Copyright (c) 2018-present, GM Cruise LLC -// -// This source code is licensed under the Apache License, Version 2.0, -// found in the LICENSE file in the root directory of this source tree. -// You may not use this file except in compliance with the License. - -import * as React from "react"; -import styled from "styled-components"; - -import { markUpdate, setCallback, setupPerfMonitoring } from "./reconciliationPerf"; -import colors from "webviz-core/src/styles/colors.module.scss"; -import mixins from "webviz-core/src/styles/mixins.module.scss"; -import { PANEL_PERF_QUERY_KEY } from "webviz-core/src/util/globalConstants"; - -// $FlowFixMe - apparently this is not in the flowtyped definitions yet. -const Profiler = React.unstable_Profiler; - -const showPanelPerf = new URLSearchParams(location.search).has(PANEL_PERF_QUERY_KEY); - -const SPerfIndicator = styled.div` - font-family: ${mixins.monospaceFont}; - font-size: 10px; - background-color: ${colors.backgroundControl}; - position: absolute; - bottom: 10px; - left: 10px; - padding: 0.5em; - z-index: 999999999; -`; - -if (showPanelPerf) { - setupPerfMonitoring(); -} - -// Simple wrapper around React's new Profiler API. Shows "actual" render time (which is the time -// that the last render took), and the "base" render time (which is the time that the render would -// have taken if all components in the tree would have `shouldComponentUpdate: true`). Also shows -// a unique id -- if that changes rapidly then that's also a bad sign (lots of remounts). -export default class PerfMonitor extends React.Component<{| id: string, children: React.Node |}> { - _top: ?HTMLDivElement; - - shouldComponentUpdate() { - if (showPanelPerf) { - markUpdate(this.props.id); - } - return true; - } - - _profilerOnRender = (id: ?string, phase: "mount" | "update", actualTime: number, baseTime: number) => { - const text = `actual: ${actualTime.toFixed(1)}ms\nbase: ${baseTime.toFixed(1)}ms\nid: ${this.props.id.slice(0, 8)}`; - if (this._top) { - this._top.innerText = text; - } - }; - - render() { - if (!showPanelPerf) { - return this.props.children; - } - - return ( - <> - -
(this._top = el)}>?
-
{ - if (el) { - setCallback(this.props.id, (measure: PerformanceEntry) => { - el.innerText = `reconcil: ${measure.duration.toFixed(1)}ms`; - }); - } else { - setCallback(this.props.id, undefined); - } - }}> - ? -
-
- - {this.props.children} - - - ); - } -} diff --git a/packages/webviz-core/src/components/PerfMonitor/reconciliationPerf.js b/packages/webviz-core/src/components/PerfMonitor/reconciliationPerf.js deleted file mode 100644 index b3e8ba04f..000000000 --- a/packages/webviz-core/src/components/PerfMonitor/reconciliationPerf.js +++ /dev/null @@ -1,63 +0,0 @@ -// @flow -// -// Copyright (c) 2018-present, GM Cruise LLC -// -// This source code is licensed under the Apache License, Version 2.0, -// found in the LICENSE file in the root directory of this source tree. -// You may not use this file except in compliance with the License. - -export const ID_PREFIX = "reconciliationPerf:"; -export const REACT_PANEL_WRAPPER_MEASURE_NAME = "⚛ PerfMonitor [update]"; - -const callbacksById: { [string]: (PerformanceEntry) => void } = {}; - -const entrySorter = (a: PerformanceEntry, b: PerformanceEntry) => (a.startTime < b.startTime ? -1 : 1); - -export const observeMeasures = (list: window.PerformanceObserverEntryList) => { - // General Idea: - // - React's performance measures don't have specifics of which measure corresponds to which exact component. - // - To compensate: - // - At the start of shouldComponentUpdate, we add a mark with a specific child Id for the component - // - In this function when we get all the measures together, we sort all the marks / measures by start time and: - // - at each measure for "⚛ PerfMonitor [update]" - // - The next time we see the child id mark, we know that the most recent measure was for this child id. - // - We store this measure in a dictionary keyed by that child id. - // - This works because the mark happens during the measured period, and we get the list chronologically, so you get a measure, then the marks in it. - // - Whenever we render the component, we see if we have a value for the previous render and show that overlaid. - let currentMeasure = null; - const sortedEntriesList: PerformanceEntry[] = [...list.getEntries()].sort(entrySorter); - for (const entry of sortedEntriesList) { - if (entry.entryType === "measure" && entry.name.startsWith(REACT_PANEL_WRAPPER_MEASURE_NAME)) { - currentMeasure = entry; - } else if (entry.entryType === "mark" && entry.name.startsWith(ID_PREFIX)) { - if (!currentMeasure) { - continue; - } - const id = entry.name.slice(ID_PREFIX.length); - if (callbacksById[id]) { - callbacksById[id](currentMeasure); - } - if (window.performance && window.performance.clearMarks) { - window.performance.clearMarks(entry.name); - } - currentMeasure = null; - } - } -}; - -export function setupPerfMonitoring() { - const panelPerfWatcher = new window.PerformanceObserver(observeMeasures); - panelPerfWatcher.observe({ entryTypes: ["mark", "measure"] }); -} - -export const setCallback = (id: string, callback: ?(PerformanceEntry) => void) => { - if (callback) { - callbacksById[id] = callback; - } else { - delete callbacksById[id]; - } -}; - -export const markUpdate = (id: string) => { - window.performance.mark(`${ID_PREFIX}${id}`); -}; diff --git a/packages/webviz-core/src/components/PerfMonitor/reconciliationPerf.test.js b/packages/webviz-core/src/components/PerfMonitor/reconciliationPerf.test.js deleted file mode 100644 index 8e32e1a94..000000000 --- a/packages/webviz-core/src/components/PerfMonitor/reconciliationPerf.test.js +++ /dev/null @@ -1,69 +0,0 @@ -// @flow -// -// Copyright (c) 2018-present, GM Cruise LLC -// -// This source code is licensed under the Apache License, Version 2.0, -// found in the LICENSE file in the root directory of this source tree. -// You may not use this file except in compliance with the License. - -import { observeMeasures, setCallback, ID_PREFIX, REACT_PANEL_WRAPPER_MEASURE_NAME } from "./reconciliationPerf"; - -describe("reconciliationPerf", () => { - it("should match measures to metrics independent of order", () => { - // Window.performance doesn't exist while our tests run, but in case it ever does in the glorious future, keep a copy and reset the stub at the end. - const oldPerformance = window.performance; - window.performance = { - clearMarks: () => {}, - }; - - // Fake measures and marks: - const list = { - getEntries: () => [ - // Basic. - { entryType: "measure", name: REACT_PANEL_WRAPPER_MEASURE_NAME, startTime: 1, duration: 1 }, - { entryType: "mark", name: `${ID_PREFIX}Id1`, startTime: 1.5 }, - - // Mark before measure. - { entryType: "mark", name: `${ID_PREFIX}Id2`, startTime: 7 }, - { entryType: "measure", name: REACT_PANEL_WRAPPER_MEASURE_NAME, startTime: 6, duration: 3 }, - - // Floating measure that doesn't have child (so doesn't get matched). - { entryType: "measure", name: REACT_PANEL_WRAPPER_MEASURE_NAME, startTime: 9.1, duration: 0.6 }, - - // Mark & Measure together but out of order with rest. - { entryType: "measure", name: REACT_PANEL_WRAPPER_MEASURE_NAME, startTime: 3, duration: 2 }, - { entryType: "mark", name: `${ID_PREFIX}Id3`, startTime: 4 }, - - // Measure that overlaps Id3, but doesn't have the right name. - { entryType: "measure", name: "blah", startTime: 3, duration: 2 }, - - // Multiple marks first, then matching measures. - { entryType: "mark", name: `${ID_PREFIX}Id5`, startTime: 13 }, - { entryType: "mark", name: `${ID_PREFIX}Id4`, startTime: 11 }, - { entryType: "measure", name: REACT_PANEL_WRAPPER_MEASURE_NAME, startTime: 12, duration: 6 }, - { entryType: "measure", name: REACT_PANEL_WRAPPER_MEASURE_NAME, startTime: 10, duration: 2 }, - ], - }; - - const callback1 = jest.fn(); - const callback2 = jest.fn(); - const callback3 = jest.fn(); - const callback4 = jest.fn(); - const callback5 = jest.fn(); - setCallback("Id1", callback1); - setCallback("Id2", callback2); - setCallback("Id3", callback3); - setCallback("Id4", callback4); - setCallback("Id5", callback5); - - observeMeasures(list); - - expect(callback1.mock.calls).toEqual([[expect.objectContaining({ duration: 1 })]]); - expect(callback2.mock.calls).toEqual([[expect.objectContaining({ duration: 3 })]]); - expect(callback3.mock.calls).toEqual([[expect.objectContaining({ duration: 2 })]]); - expect(callback4.mock.calls).toEqual([[expect.objectContaining({ duration: 2 })]]); - expect(callback5.mock.calls).toEqual([[expect.objectContaining({ duration: 6 })]]); - - window.performance = oldPerformance; - }); -}); diff --git a/packages/webviz-core/src/components/PlaybackControls/index.js b/packages/webviz-core/src/components/PlaybackControls/index.js index 933cbbc12..5cb5dea40 100644 --- a/packages/webviz-core/src/components/PlaybackControls/index.js +++ b/packages/webviz-core/src/components/PlaybackControls/index.js @@ -11,13 +11,15 @@ import CancelIcon from "@mdi/svg/svg/cancel.svg"; import PauseIcon from "@mdi/svg/svg/pause.svg"; import PlayIcon from "@mdi/svg/svg/play.svg"; import classnames from "classnames"; -import * as React from "react"; +import React, { useCallback } from "react"; import KeyListener from "react-key-listener"; +import { useSelector, useDispatch } from "react-redux"; import type { Time } from "rosbag"; import styled from "styled-components"; import styles from "./index.module.scss"; import { ProgressPlot } from "./ProgressPlot"; +import { setPlaybackConfig as setPlaybackConfigAction } from "webviz-core/src/actions/panels"; import Dropdown from "webviz-core/src/components/Dropdown"; import EmptyState from "webviz-core/src/components/EmptyState"; import Flex from "webviz-core/src/components/Flex"; @@ -51,6 +53,7 @@ const StyledMarker = styled.div.attrs({ `; type Props = {| + playbackSpeed: number, player: PlayerState, pause: () => void, play: () => void, @@ -199,18 +202,28 @@ export class UnconnectedPlaybackControls extends React.PureComponent { } } -export default function PlaybackControls() { - return ( - - {(context) => ( - - )} - +function PlaybackControls() { + const playbackSpeed = useSelector((state) => state.panels.playbackConfig.speed); + const dispatch = useDispatch(); + const setPlaybackConfig = useCallback((config) => dispatch(setPlaybackConfigAction(config)), [dispatch]); + + const renderUnconnectedPlaybackControls = useCallback( + (context) => ( + { + context.setPlaybackSpeed(speed); + setPlaybackConfig({ speed }); + }} + playbackSpeed={playbackSpeed} + /> + ), + [setPlaybackConfig, playbackSpeed] ); + return {renderUnconnectedPlaybackControls}; } + +export default PlaybackControls; diff --git a/packages/webviz-core/src/components/PlaybackControls/index.stories.js b/packages/webviz-core/src/components/PlaybackControls/index.stories.js index 754b8f23e..c42243f03 100644 --- a/packages/webviz-core/src/components/PlaybackControls/index.stories.js +++ b/packages/webviz-core/src/components/PlaybackControls/index.stories.js @@ -49,7 +49,14 @@ storiesOf("", module) const player = getPlayerState(); return (
- +
); }) @@ -62,7 +69,14 @@ storiesOf("", module) player.capabilities = []; return (
- +
); }) @@ -81,7 +95,14 @@ storiesOf("", module) } return (
- +
); }) @@ -127,6 +148,7 @@ storiesOf("", module) play={play} setSpeed={setSpeed} seek={seek} + playbackSpeed={0.2} /> ); } @@ -146,7 +168,14 @@ storiesOf("", module) player.progress.fullyLoadedFractionRanges = [{ start: 0.23, end: 0.6 }, { start: 0.7, end: 1 }]; return (
- +
); }); diff --git a/packages/webviz-core/src/components/PlayerManager.js b/packages/webviz-core/src/components/PlayerManager.js index d7f809782..6ef06e747 100644 --- a/packages/webviz-core/src/components/PlayerManager.js +++ b/packages/webviz-core/src/components/PlayerManager.js @@ -9,8 +9,15 @@ import * as React from "react"; import { connect } from "react-redux"; -import { setNodeDiagnostics, type SetNodeDiagnostics } from "webviz-core/src/actions/nodeDiagnostics"; import { importPanelLayout } from "webviz-core/src/actions/panels"; +import { + setUserNodeDiagnostics, + addUserNodeLogs, + setUserNodeTrust, + type SetUserNodeDiagnostics, + type AddUserNodeLogs, + type SetUserNodeTrust, +} from "webviz-core/src/actions/userNodes"; import DocumentDropListener from "webviz-core/src/components/DocumentDropListener"; import DropOverlay from "webviz-core/src/components/DropOverlay"; import { MessagePipelineProvider } from "webviz-core/src/components/MessagePipeline"; @@ -55,10 +62,19 @@ type OwnProps = { children: React.Node }; type Props = OwnProps & { importPanelLayout: (payload: ImportPanelLayoutPayload, isFromUrl: boolean, skipSettingLocalStorage: boolean) => void, userNodes: UserNodes, - setNodeDiagnostics: SetNodeDiagnostics, + setUserNodeDiagnostics: SetUserNodeDiagnostics, + addUserNodeLogs: AddUserNodeLogs, + setUserNodeTrust: SetUserNodeTrust, }; -function PlayerManager({ importPanelLayout, children, userNodes, setNodeDiagnostics }: Props) { +function PlayerManager({ + importPanelLayout, + children, + userNodes, + setUserNodeDiagnostics, + addUserNodeLogs, + setUserNodeTrust, +}: Props) { const usedFiles = React.useRef([]); const [player, setPlayer] = React.useState(); @@ -78,7 +94,7 @@ function PlayerManager({ importPanelLayout, children, userNodes, setNodeDiagnost ); if (new URLSearchParams(window.location.search).has(ENABLE_NODE_PLAYGROUND_QUERY_KEY)) { - setPlayer(new UserNodePlayer(newPlayer, setNodeDiagnostics)); + setPlayer(new UserNodePlayer(newPlayer, { setUserNodeDiagnostics, addUserNodeLogs, setUserNodeTrust })); } else { setPlayer(new NodePlayer(newPlayer)); } @@ -88,7 +104,7 @@ function PlayerManager({ importPanelLayout, children, userNodes, setNodeDiagnost } } }, - [importPanelLayout, setNodeDiagnostics] + [importPanelLayout, setUserNodeDiagnostics, addUserNodeLogs, setUserNodeTrust] ); useUserNodes({ nodePlayer: player, userNodes }); @@ -125,5 +141,5 @@ export default connect( (state) => ({ userNodes: state.panels.userNodes, }), - { importPanelLayout, setNodeDiagnostics } + { importPanelLayout, setUserNodeDiagnostics, addUserNodeLogs, setUserNodeTrust } )(PlayerManager); diff --git a/packages/webviz-core/src/components/Root.js b/packages/webviz-core/src/components/Root.js index ac6f571c9..edf720e8a 100644 --- a/packages/webviz-core/src/components/Root.js +++ b/packages/webviz-core/src/components/Root.js @@ -11,7 +11,7 @@ import { hot } from "react-hot-loader/root"; import { connect, Provider } from "react-redux"; import styles from "./Root.module.scss"; -import { changePanelLayout, importPanelLayout, savePanelConfig } from "webviz-core/src/actions/panels"; +import { importPanelLayout } from "webviz-core/src/actions/panels"; import Logo from "webviz-core/src/assets/logo.svg"; import AppMenu from "webviz-core/src/components/AppMenu"; import ErrorBoundary from "webviz-core/src/components/ErrorBoundary"; @@ -20,16 +20,10 @@ import LayoutMenu from "webviz-core/src/components/LayoutMenu"; import PanelLayout from "webviz-core/src/components/PanelLayout"; import PlaybackControls from "webviz-core/src/components/PlaybackControls"; import PlayerManager from "webviz-core/src/components/PlayerManager"; -import renderToBody from "webviz-core/src/components/renderToBody"; -import ShareJsonModal from "webviz-core/src/components/ShareJsonModal"; import Toolbar from "webviz-core/src/components/Toolbar"; import withDragDropContext from "webviz-core/src/components/withDragDropContext"; -import type { State } from "webviz-core/src/reducers"; -import type { PanelsState } from "webviz-core/src/reducers/panels"; -import type { Auth } from "webviz-core/src/types/Auth"; -import type { ImportPanelLayoutPayload, PanelConfig, SaveConfigPayload } from "webviz-core/src/types/panels"; -import type { Store } from "webviz-core/src/types/Store"; -import { getPanelIdForType } from "webviz-core/src/util"; +import getGlobalStore from "webviz-core/src/store/getGlobalStore"; +import type { ImportPanelLayoutPayload } from "webviz-core/src/types/panels"; import { setReactHotLoaderConfig } from "webviz-core/src/util/dev"; // Only used in dev. @@ -37,16 +31,8 @@ setReactHotLoaderConfig(); const LOGO_SIZE = 24; -type AppProps = {| - panels: PanelsState, - auth: Auth, -|}; - type Props = {| - ...AppProps, // panelLayout is an opaque structure defined by react-mosaic - changePanelLayout: (panelLayout: any) => void, - savePanelConfig: (SaveConfigPayload) => void, importPanelLayout: (ImportPanelLayoutPayload, boolean) => void, |}; class App extends React.PureComponent { @@ -62,35 +48,6 @@ class App extends React.PureComponent { window.setPanelLayout = (payload) => this.props.importPanelLayout(payload, false); } - onPanelSelect = (panelType: string, panelConfig?: PanelConfig) => { - const { panels, changePanelLayout, savePanelConfig } = this.props; - const id = getPanelIdForType(panelType); - const newPanels = { - direction: "row", - first: id, - second: panels.layout, - }; - if (panelConfig) { - savePanelConfig({ id, config: panelConfig }); - } - changePanelLayout(newPanels); - }; - - showLayoutModal = () => { - const modal = renderToBody( - modal.remove()} - value={this.props.panels} - onChange={this.onLayoutChange} - noun="layout" - /> - ); - }; - - onLayoutChange = (layout: any) => { - this.props.importPanelLayout(layout, false); - }; - render() { return (
(this.container = el)} className="app-container" tabIndex={0}> @@ -106,10 +63,10 @@ class App extends React.PureComponent {
- +
- +
@@ -124,25 +81,16 @@ class App extends React.PureComponent { } } -const mapStateToProps = (state: State, ownProps): AppProps => { - return { - panels: state.panels, - auth: state.auth, - }; -}; - -const ConnectedApp = connect( - mapStateToProps, +const ConnectedApp = connect( + null, { - changePanelLayout, - savePanelConfig, importPanelLayout, } )(withDragDropContext(App)); -const Root = ({ store }: { store: Store }) => { +const Root = () => { return ( - +
diff --git a/packages/webviz-core/src/components/SegmentControl.js b/packages/webviz-core/src/components/SegmentControl.js new file mode 100644 index 000000000..f4356fb86 --- /dev/null +++ b/packages/webviz-core/src/components/SegmentControl.js @@ -0,0 +1,72 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import * as React from "react"; +import styled from "styled-components"; +import tinyColor from "tinycolor2"; + +import { colors } from "webviz-core/src/util/colors"; + +const colorToAlpha = (hex, alpha) => { + const color = tinyColor(hex); + color.setAlpha(alpha); + return color.toRgbString(); +}; + +const SSegmentControl = styled.div` + display: inline-flex; + padding: 4px; + border-radius: 6px; + background-color: ${(props) => colorToAlpha(colors.LIGHT, 0.15)}; + outline: 0; + &:focus-within, + &:focus, + &:active { + box-shadow: inset 0 0 0 2px ${(props) => colorToAlpha(colors.LIGHT, 0.1)}; + } +`; + +const SOption = styled.div` + flex: none; + cursor: pointer; + transition: all 80ms ease-in-out; + border-radius: 4px; + background-color: ${(props) => (props.isSelected ? colors.DARK2 : "transparent")}; + color: ${(props) => (props.isSelected ? colors.LIGHT : colors.LIGHT)}; + padding: 8px 16px; + &:hover { + opacity: 0.8; + } +`; + +export type Option = {| + id: string, + label: string, +|}; + +type Props = {| + options: Option[], + selectedId: string, + onChange: (id: string) => void, +|}; + +export default function SegmentControl({ options, selectedId, onChange }: Props) { + if (options.length === 0) { + throw new Error(" requires at least one option"); + } + + return ( + + {options.map(({ id, label }) => ( + onChange(id)} isSelected={selectedId === id}> + {label} + + ))} + + ); +} diff --git a/packages/webviz-core/src/components/SegmentControl.stories.js b/packages/webviz-core/src/components/SegmentControl.stories.js new file mode 100644 index 000000000..5ffae9acf --- /dev/null +++ b/packages/webviz-core/src/components/SegmentControl.stories.js @@ -0,0 +1,80 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { storiesOf } from "@storybook/react"; +import * as React from "react"; +import { withScreenshot } from "storybook-chrome-screenshot"; + +import SegmentControl, { type Option } from "webviz-core/src/components/SegmentControl"; + +const OPTIONS = { + first: { + id: "first", + label: "First Option", + }, + second: { + id: "second", + label: "Second Option", + }, + third: { + id: "third", + label: "Third Option", + }, +}; + +function Box({ + title = "", + children, + onMount, +}: { + title?: string, + children: React.Node, + onMount?: (HTMLDivElement) => void, +}) { + return ( +
{ + if (el && onMount) { + onMount(el); + } + }}> +

{title}

+
{children}
+
+ ); +} + +// $FlowFixMe +const optionArr: Option[] = Object.values(OPTIONS); + +function ControlledExample() { + const [selectedId, setSelectedId] = React.useState(OPTIONS.first.id); + return ( + { + const secondOptionEl = el.querySelector("[data-test='second']"); + if (secondOptionEl) { + secondOptionEl.click(); + } + }}> + setSelectedId(newId)} /> + + ); +} +storiesOf("", module) + .addDecorator(withScreenshot()) + .add("basic", () => ( +
+ + {}} /> + + +
+ )); diff --git a/packages/webviz-core/src/components/ShareJsonModal.test.js b/packages/webviz-core/src/components/ShareJsonModal.test.js index 31fb7a333..383eae3f9 100644 --- a/packages/webviz-core/src/components/ShareJsonModal.test.js +++ b/packages/webviz-core/src/components/ShareJsonModal.test.js @@ -65,7 +65,7 @@ describe("", () => { JSON.stringify({ layout: "RosOut!cuuf9u", savedProps: {}, - globalData: {}, + globalVariables: {}, }) ); wrapper.find(".textarea").simulate("change", { target: { value: newValue } }); diff --git a/packages/webviz-core/src/components/TextContent.js b/packages/webviz-core/src/components/TextContent.js index 9fa45bb4b..fad9a31ad 100644 --- a/packages/webviz-core/src/components/TextContent.js +++ b/packages/webviz-core/src/components/TextContent.js @@ -15,11 +15,12 @@ import styles from "./TextContent.module.scss"; type Props = { children: React.Node | string, linkTarget?: string, + style?: { [string]: number | string }, }; export default class TextContent extends React.Component { render() { - const { children, linkTarget = undefined } = this.props; + const { children, linkTarget = undefined, style = {} } = this.props; // Make links in Markdown work with react-router. // Per https://github.com/rexxars/react-markdown/issues/29#issuecomment-275437798 @@ -34,7 +35,7 @@ export default class TextContent extends React.Component { } return ( -
+
{typeof children === "string" ? : children}
); diff --git a/packages/webviz-core/src/components/TextField.js b/packages/webviz-core/src/components/TextField.js new file mode 100644 index 000000000..ac94cba6f --- /dev/null +++ b/packages/webviz-core/src/components/TextField.js @@ -0,0 +1,161 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import * as React from "react"; +import styled from "styled-components"; + +import { colors } from "webviz-core/src/util/colors"; + +const { useRef, useState, useLayoutEffect, useCallback } = React; + +export const STextField = styled.div` + display: flex; + flex-direction: column; +`; + +export const STextFieldLabel = styled.label` + margin: 8px 0; + color: ${colors.GRAY}; +`; + +export const SError = styled.div` + color: ${colors.RED}; + margin: 4px 0; +`; + +type Props = { + defaultValue?: string, + focusOnMount?: boolean, + inputStyle: { [string]: string | number }, + label?: string, + onBlur: () => void, + onChange: (value: string) => void, + onError?: (error: string) => void, + placeholder?: string, + style: { [string]: string | number }, + validateOnBlur?: boolean, + validator: (value: any) => ?string, + value?: string, +}; + +export default function TextField({ + defaultValue, + focusOnMount, + inputStyle, + label, + onBlur, + onChange, + onError, + placeholder, + style, + validateOnBlur, + validator, + value, + ...rest +}: Props) { + const [error, setError] = useState(); + const [inputStr, setInputStr] = useState(value || defaultValue || ""); + + const prevIncomingVal = useRef(""); + const inputRef = useRef(null); + + useLayoutEffect( + () => { + // only compare if it's a controlled component + if (!defaultValue && !validateOnBlur && prevIncomingVal.current !== value) { + const validationResult = validator(value); + setError(validationResult || null); + setInputStr(value || ""); + } + prevIncomingVal.current = value; + }, + [defaultValue, validateOnBlur, validator, value] + ); + + useLayoutEffect( + () => { + if (inputRef.current && focusOnMount) { + inputRef.current.focus(); + } + }, + [focusOnMount] + ); + + useLayoutEffect( + () => { + if (onError && error) { + onError(error); + } + }, + [error, onError] + ); + + const validate = useCallback( + (value) => { + const validationResult = validator(value); + if (validationResult) { + setError(validationResult); + } else { + setError(null); + onChange(value); + } + }, + [onChange, validator] + ); + + const handleChange = useCallback( + (ev) => { + const value = ev.target.value; + setInputStr(value); + if (!validateOnBlur) { + validate(value); + } + }, + [validate, validateOnBlur] + ); + + const handleBlur = useCallback( + () => { + if (validateOnBlur) { + validate(inputStr); + } + if (onBlur) { + onBlur(); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [onBlur, validate, validateOnBlur] + ); + + // only show red border when there is some input and it's not valid + const errorStyle = inputStr && error ? { border: `1px solid ${colors.RED}` } : {}; + + return ( + + {label && {label}} + + {error && !onError && {error}} + + ); +} + +TextField.defaultProps = { + validator: (value) => undefined, + onChange: (value) => {}, + onBlur: (value) => {}, + inputStyle: {}, + style: {}, +}; diff --git a/packages/webviz-core/src/components/TextField.stories.js b/packages/webviz-core/src/components/TextField.stories.js new file mode 100644 index 000000000..de85f64f7 --- /dev/null +++ b/packages/webviz-core/src/components/TextField.stories.js @@ -0,0 +1,122 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { storiesOf } from "@storybook/react"; +import * as React from "react"; +import { withScreenshot } from "storybook-chrome-screenshot"; + +import TextField from "./TextField"; +import Flex from "webviz-core/src/components/Flex"; +import { createPrimitiveValidator, hasLen } from "webviz-core/src/components/validators"; +import { triggerInputChange, triggerInputBlur } from "webviz-core/src/stories/PanelSetup"; + +const validator = createPrimitiveValidator([hasLen(4)]); + +function Box({ children, title }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function ErrorExample() { + const [error, setError] = React.useState(); + return ( + <> + + This is a custom error UI: {error} + + ); +} + +function ControlledExample() { + const [value, setValue] = React.useState(""); + return ( +
{ + if (el) { + const input = ((el.querySelector("input"): any): HTMLInputElement | null); + triggerInputChange(input, "another value"); + } + }}> + +
+ ); +} + +function UncontrolledExample() { + const [value, setValue] = React.useState(""); + React.useEffect(() => { + setValue("another value but not set in TextField"); + }, []); + + return ( +
+ + {value} +
+ ); +} + +function ValidateOnBlurExample() { + return ( +
{ + if (el) { + const input = ((el.querySelector("input"): any): HTMLInputElement | null); + // only see the validation error after input blur + triggerInputChange(input, "invalid_val"); + setTimeout(() => { + triggerInputBlur(input); + }, 500); + } + }}> + +
+ ); +} + +storiesOf("", module) + .addDecorator(withScreenshot()) + .add("default", () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + }); diff --git a/packages/webviz-core/src/components/TimeBasedChart/index.js b/packages/webviz-core/src/components/TimeBasedChart/index.js index 3b054b14d..d825b6375 100644 --- a/packages/webviz-core/src/components/TimeBasedChart/index.js +++ b/packages/webviz-core/src/components/TimeBasedChart/index.js @@ -155,33 +155,27 @@ export default class TimeBasedChart extends React.PureComponent { document.removeEventListener("visibilitychange", this._onVisibilityChange); } - // Detect if the min/max is different than what we explicitly set, - // which means that the user has panned or zoomed. - // Note that for the y-axis timeBasedChartMin/Max is not set at all, - // so there we'll check that min/max is not undefined, which is the - // intended behavior. - // Also note that it's possible that only one of the axis is panned or - // zoomed, so in this function we only set `showResetZoom` to `true`, - // otherwise one axis might set it to true and the other to false, - // causing an infinite loop. We set `showResetZoom` to false in - // `_onResetZoom`. _onPlotChartUpdate = (axis: any) => { - const showResetZoom = - typeof axis.options.ticks.timeBasedChartMin === "number" && - typeof axis.options.ticks.timeBasedChartMax === "number" && - (axis.options.ticks.min !== axis.options.ticks.timeBasedChartMin || - axis.options.ticks.max !== axis.options.ticks.timeBasedChartMax); - if (showResetZoom && !this.state.showResetZoom) { - this.setState({ showResetZoom: true }); - } if (this.props.saveCurrentYs) { const scaleId = this.props.yAxes ? this.props.yAxes[0].id : Y_AXIS_ID; this.props.saveCurrentYs(axis.chart.scales[scaleId].min, axis.chart.scales[scaleId].max); } }; - _onPanUpdate = ({ minX, maxX, maxY, minY }: { minX: number, maxX: number, minY: number, maxY: number }) => { + _onPanZoomUpdate = (chartInstance: ChartComponent) => { + const { xAxes, saveCurrentYs } = this.props; + const Y_scaleId = this.props.yAxes[0].id; + const X_scaleId = xAxes ? xAxes[0].id : X_AXIS_ID; + const minX = chartInstance.chart.scales[X_scaleId].min; + const maxX = chartInstance.chart.scales[X_scaleId].max; + const minY = chartInstance.chart.scales[Y_scaleId].min; + const maxY = chartInstance.chart.scales[Y_scaleId].max; + + if (saveCurrentYs) { + saveCurrentYs(minY, maxY); + } this.setState({ + showResetZoom: true, userSetMinX: minX, userSetMaxX: maxX, userSetMinY: minY, @@ -298,7 +292,7 @@ export default class TimeBasedChart extends React.PureComponent { }; _chartjsOptions = (minX: number, maxX: number, userMinY: ?number, userMaxY: ?number) => { - const { plugins, xAxes, yAxes, saveCurrentYs } = this.props; + const { plugins, xAxes, yAxes } = this.props; const { annotations } = this.state; const defaultXTicksSettings = { fontFamily: mixins.monospaceFont, @@ -380,21 +374,15 @@ export default class TimeBasedChart extends React.PureComponent { pan: { enabled: true, onPan: (chartInstance: ChartComponent) => { - const Y_scaleId = this.props.yAxes[0].id; - const X_scaleId = xAxes ? xAxes[0].id : X_AXIS_ID; - const minX = chartInstance.chart.scales[X_scaleId].min; - const maxX = chartInstance.chart.scales[X_scaleId].max; - const minY = chartInstance.chart.scales[Y_scaleId].min; - const maxY = chartInstance.chart.scales[Y_scaleId].max; - - if (saveCurrentYs) { - saveCurrentYs(minY, maxY); - } - - this._onPanUpdate({ minX, maxX, maxY, minY }); + this._onPanZoomUpdate(chartInstance); + }, + }, + zoom: { + enabled: this.props.zoom, + onZoom: (chartInstance: ChartComponent) => { + this._onPanZoomUpdate(chartInstance); }, }, - zoom: { enabled: this.props.zoom }, plugins: plugins || {}, annotation: { annotations }, }; diff --git a/packages/webviz-core/src/components/Toolbar.module.scss b/packages/webviz-core/src/components/Toolbar.module.scss index 82333583d..39e0f674a 100644 --- a/packages/webviz-core/src/components/Toolbar.module.scss +++ b/packages/webviz-core/src/components/Toolbar.module.scss @@ -5,10 +5,11 @@ // You may not use this file except in compliance with the License. @import "~webviz-core/src/styles/colors.module.scss"; +@import "~webviz-core/src/styles/variables.scss"; .toolbar { padding: 0 0 0 8px; - height: 3rem; + height: $topBarHeight; font-size: 1.2em; display: flex; flex: 0 0 auto; diff --git a/packages/webviz-core/src/components/ValidatedInput.js b/packages/webviz-core/src/components/ValidatedInput.js index e4fd7f369..5470eb473 100644 --- a/packages/webviz-core/src/components/ValidatedInput.js +++ b/packages/webviz-core/src/components/ValidatedInput.js @@ -6,6 +6,7 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. +import { isEqual } from "lodash"; import * as React from "react"; import styled from "styled-components"; @@ -15,6 +16,8 @@ import { validationErrorToString, type ValidationResult } from "webviz-core/src/ import colors from "webviz-core/src/styles/colors.module.scss"; import YAML from "webviz-core/src/util/yaml"; +const { useState, useCallback, useRef, useLayoutEffect, useEffect } = React; + export const EDIT_FORMAT = { JSON: "json", YAML: "yaml" }; const SEditBox = styled.div` @@ -76,13 +79,14 @@ export function ValidatedInputBase({ stringify, value, }: BaseProps & ParseAndStringifyFn) { - const [error, setError] = React.useState(""); - const [inputStr, setInputStr] = React.useState(""); - const prevIncomingVal = React.useRef(""); - const inputRef = React.useRef(null); + const [error, setError] = useState(""); + const [inputStr, setInputStr] = useState(""); + const prevIncomingVal = useRef(""); + const inputRef = useRef(null); + const [isEditing, setIsEditing] = useState(false); // validate the input string, and setError or call onChange if needed - const memorizedInputValidation = React.useCallback( + const memorizedInputValidation = useCallback( (newInputVal: string, onChange?: OnChange) => { let newVal; let newError; @@ -114,10 +118,13 @@ export function ValidatedInputBase({ [dataValidator, parse, value] ); - // whenever the incoming value changes, we'll compare the new value with prevIncomingVal, and reset local state values if they are different - React.useEffect( + // when not in editing mode, whenever the incoming value changes, we'll compare the new value with prevIncomingVal, and reset local state values if they are different + useLayoutEffect( () => { - if (value !== prevIncomingVal.current) { + if (!isEditing && value !== prevIncomingVal.current) { + if (isEqual(value, prevIncomingVal.current)) { + return; + } let newVal = ""; let newError; try { @@ -135,10 +142,22 @@ export function ValidatedInputBase({ memorizedInputValidation(newVal); } }, - [value, prevIncomingVal, stringify, memorizedInputValidation] + [value, stringify, memorizedInputValidation, isEditing] + ); + + const handleChange = useCallback( + (e) => { + const val = e.currentTarget && e.currentTarget.value; + if (!isEditing) { + setIsEditing(true); + } + setInputStr(val); + memorizedInputValidation(val, onChange); + }, + [isEditing, memorizedInputValidation, onChange] ); - React.useEffect( + useEffect( () => { if (onError && error) { onError(error); @@ -147,25 +166,28 @@ export function ValidatedInputBase({ [error, onError] ); - function handleChange(e) { - setInputStr(e.target.value); - memorizedInputValidation(e.target.value, onChange); - } - // scroll to the bottom when the text gets too long - React.useEffect( + useLayoutEffect( () => { - const inputElem = inputRef.current; - if (inputElem) { - inputElem.scrollTop = inputElem.scrollHeight; + if (!isEditing) { + const inputElem = inputRef.current; + if (inputElem) { + inputElem.scrollTop = inputElem.scrollHeight; + } } }, - [inputStr] + [isEditing, inputStr] // update whenever inputStr changes ); return ( <> - + {error && {error}} ); diff --git a/packages/webviz-core/src/components/ValidatedInput.stories.js b/packages/webviz-core/src/components/ValidatedInput.stories.js index d9013daa2..fc1b9bc1f 100644 --- a/packages/webviz-core/src/components/ValidatedInput.stories.js +++ b/packages/webviz-core/src/components/ValidatedInput.stories.js @@ -8,13 +8,17 @@ import { storiesOf } from "@storybook/react"; import * as React from "react"; +import { DEFAULT_CAMERA_STATE } from "regl-worldview"; import { withScreenshot } from "storybook-chrome-screenshot"; import ValidatedInput, { EDIT_FORMAT, type EditFormat } from "./ValidatedInput"; import Flex from "webviz-core/src/components/Flex"; import { createValidator, isNumber, type ValidationResult } from "webviz-core/src/components/validators"; +import { triggerInputChange, triggerInputBlur } from "webviz-core/src/stories/PanelSetup"; const INPUT_OBJ = { id: 1, name: "foo" }; +const INPUT_OBJ1 = { id: 2, name: "bar" }; + const json = EDIT_FORMAT.JSON; const yaml = EDIT_FORMAT.YAML; @@ -29,17 +33,41 @@ function Box({ children }) { return
{children}
; } -function ControlExample({ format = EDIT_FORMAT.JSON }: { format?: EditFormat }) { - const [value, setValue] = React.useState(INPUT_OBJ); - React.useEffect(() => { - setTimeout(() => { - setValue({ id: 2, name: "bar" }); - }, 10); - }, []); +function Example({ + format = EDIT_FORMAT.JSON, + obj = INPUT_OBJ, + changedObj = INPUT_OBJ1, + onMount, +}: { + format?: EditFormat, + obj?: any, + changedObj?: any, + onMount?: (HTMLInputElement) => void, +}) { + const [value, setValue] = React.useState(obj); + + React.useEffect( + () => { + setTimeout(() => { + setValue(changedObj); + }, 10); + }, + [changedObj] + ); return ( - +
{ + if (el && onMount) { + const input = ((document.querySelector("[data-test='validated-input']"): any): HTMLInputElement | null); + if (input) { + onMount(input); + } + } + }}> + +
); } @@ -74,8 +102,73 @@ storiesOf("", module) .add("value change affects the input value", () => { return ( - ; - ; + + + + ); + }) + .add("prop change does not override the input string if object values are deeply equal ", () => { + // the input string does not change as `obj` and `changedObj` are deeply equal + return ( + + + + ); + }) + .add("scroll to bottom when input size grows", () => { + return ( + + + + ); + }) + .add("in editing mode, prop value change does not affect the input string", () => { + return ( + + { + // even though the prop object has changed, the input value is in sync with current editing value + triggerInputChange(input, "invalid_val"); + setTimeout(() => { + triggerInputChange(input, "another_invalid_val"); + }, 50); + }} + /> + + ); + }) + + .add("in editing mode, prop change does not cause the textarea to scroll to bottom", () => { + const changedObj = { ...DEFAULT_CAMERA_STATE, distance: 100000000 }; + return ( + + { + setImmediate(() => { + // scroll to the top and start editing + input.scrollTop = 0; + triggerInputChange(input, JSON.stringify(changedObj, null, 2)); + }); + }} + /> + + ); + }) + .add("upon blur, the validation error stays", () => { + return ( + + { + setImmediate(() => { + triggerInputChange(input, "invalid_val"); + setImmediate(() => { + triggerInputBlur(input); + }); + }); + }} + /> ); }); diff --git a/packages/webviz-core/src/components/renderToBody.js b/packages/webviz-core/src/components/renderToBody.js index d88825365..e81648fab 100644 --- a/packages/webviz-core/src/components/renderToBody.js +++ b/packages/webviz-core/src/components/renderToBody.js @@ -8,8 +8,10 @@ import * as React from "react"; import { render, unmountComponentAtNode } from "react-dom"; +import { Provider } from "react-redux"; import { Router } from "react-router-dom"; +import getGlobalStore from "webviz-core/src/store/getGlobalStore"; import history from "webviz-core/src/util/history"; type RenderedToBodyHandle = {| update: (React.Element<*>) => void, remove: () => void |}; @@ -23,8 +25,11 @@ export default function renderToBody(element: React.Element<*>): RenderedToBodyH document.body.appendChild(container); function ComponentToRender({ children }) { - // $FlowFixMe - somehow complains about `history` - return {children}; + return ( + + {children} + + ); } render({element}, container); diff --git a/packages/webviz-core/src/components/validators.js b/packages/webviz-core/src/components/validators.js index fa93c29ad..32fc427d8 100644 --- a/packages/webviz-core/src/components/validators.js +++ b/packages/webviz-core/src/components/validators.js @@ -42,8 +42,9 @@ export const isOrientation = (value: any): ?string => { return isNumberArrayErr; } if (value) { - const isValidQuaternion = value.reduce((memo, item) => memo + item * item, 0) === 1; - if (!isValidQuaternion) { + const quaternionSum = value.reduce((memo, item) => memo + item * item, 0); + // Very rough validation to make sure the quaternion numbers are not too far off + if (Math.abs(quaternionSum - 1) > 0.1) { return "must be valid quaternion"; } } @@ -67,6 +68,16 @@ export const maxLen = (maxLength: number = 0) => (value: any): ?string => { } }; +export const hasLen = (len: number = 0) => (value: string | any[]): ?string => { + if (Array.isArray(value)) { + return value.length !== len + ? `must contain exact ${len} array items (current item count: ${value.length})` + : undefined; + } else if (typeof value === "string") { + return value.length !== len ? `must contain ${len} characters (current count: ${value.length})` : undefined; + } +}; + export const isNotPrivate = (value: any): ?string => typeof value !== "string" && value.startsWith("_") ? "must not start with _" : undefined; diff --git a/packages/webviz-core/src/components/validators.test.js b/packages/webviz-core/src/components/validators.test.js index c618ea8ba..11ee2189c 100644 --- a/packages/webviz-core/src/components/validators.test.js +++ b/packages/webviz-core/src/components/validators.test.js @@ -25,6 +25,21 @@ describe("cameraStateValidator", () => { targetOrientation: "must contain 4 array items", }); }); + it("returns error if the quaternion number sum for targetOrientation is not between 0.9 and 1.1", () => { + let cameraState = { targetOrientation: [0, 0, 0, 0.94] }; + expect(cameraStateValidator(cameraState)).toEqual({ + targetOrientation: "must be valid quaternion", + }); + cameraState = { targetOrientation: [0.32, 1, 0, 0] }; + expect(cameraStateValidator(cameraState)).toEqual({ + targetOrientation: "must be valid quaternion", + }); + cameraState = { targetOrientation: [1.04, 0, 0, 0] }; + expect(cameraStateValidator(cameraState)).toEqual(undefined); + cameraState = { targetOrientation: [0.95, 0, 0, 0] }; + expect(cameraStateValidator(cameraState)).toEqual(undefined); + }); + it("returns error if the vec3/vec4 values are set but are invalid", () => { const cameraState = { targetOffset: ["invalid"] }; expect(cameraStateValidator(cameraState)).toEqual({ @@ -41,6 +56,7 @@ describe("cameraStateValidator", () => { targetOrientation: "must contain 4 array items", }); }); + it("combines errors from different fields", () => { const cameraState = { distance: "abc", targetOffset: [1, 12, "121"], targetOrientation: [1, 1, 1] }; expect(cameraStateValidator(cameraState)).toEqual({ diff --git a/packages/webviz-core/src/dataProviders/IdbCacheDataProviderDatabase.js b/packages/webviz-core/src/dataProviders/IdbCacheDataProviderDatabase.js index 678117971..dd7285b15 100644 --- a/packages/webviz-core/src/dataProviders/IdbCacheDataProviderDatabase.js +++ b/packages/webviz-core/src/dataProviders/IdbCacheDataProviderDatabase.js @@ -9,7 +9,7 @@ import Database from "webviz-core/src/util/indexeddb/Database"; import { updateMetaDatabases } from "webviz-core/src/util/indexeddb/MetaDatabase"; -const MAX_DATABASES = 3; +const MAX_DATABASES = 6; const DATABASE_NAME_PREFIX = "IdbCacheDataProviderDb-"; const META_DATABASE_NAME = "IdbCacheDataProviderMetaDb"; const DATABASE_VERSION = 1; diff --git a/packages/webviz-core/src/globals.js.flow b/packages/webviz-core/src/globals.js.flow index c64b5c5b4..9c7ef42a6 100644 --- a/packages/webviz-core/src/globals.js.flow +++ b/packages/webviz-core/src/globals.js.flow @@ -12,10 +12,9 @@ declare type GitInfo = { }; declare var GIT_INFO: ?GitInfo; - declare var CURRENT_VERSION: string; - declare var MINIMUM_CHROME_VERSION: number; +declare var SHOW_TEST_OUTPUT: boolean; // flow lacks definitions for abort controller declare type AbortSignal = any; diff --git a/packages/webviz-core/src/hooks/useGlobalData.js b/packages/webviz-core/src/hooks/useGlobalData.js deleted file mode 100644 index c3eccc84d..000000000 --- a/packages/webviz-core/src/hooks/useGlobalData.js +++ /dev/null @@ -1,27 +0,0 @@ -// @flow -// -// Copyright (c) 2018-present, GM Cruise LLC -// -// This source code is licensed under the Apache License, Version 2.0, -// found in the LICENSE file in the root directory of this source tree. -// You may not use this file except in compliance with the License. - -import { useSelector, useDispatch } from "react-redux"; -import { bindActionCreators } from "redux"; - -import { setGlobalData, overwriteGlobalData } from "webviz-core/src/actions/panels"; - -export type GlobalData = { [string]: any }; - -export default function useGlobalData(): {| - globalData: GlobalData, - setGlobalData: (GlobalData) => void, - overwriteGlobalData: (GlobalData) => void, -|} { - const globalData = useSelector((state) => state.panels.globalData); - const dispatch = useDispatch(); - return { - globalData, - ...bindActionCreators({ setGlobalData, overwriteGlobalData }, dispatch), - }; -} diff --git a/packages/webviz-core/src/hooks/useGlobalVariables.js b/packages/webviz-core/src/hooks/useGlobalVariables.js new file mode 100644 index 000000000..272b5358f --- /dev/null +++ b/packages/webviz-core/src/hooks/useGlobalVariables.js @@ -0,0 +1,27 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { useSelector, useDispatch } from "react-redux"; +import { bindActionCreators } from "redux"; + +import { setGlobalVariables, overwriteGlobalVariables } from "webviz-core/src/actions/panels"; + +export type GlobalVariables = { [string]: any }; + +export default function useGlobalVariables(): {| + globalVariables: GlobalVariables, + setGlobalVariables: (GlobalVariables) => void, + overwriteGlobalVariables: (GlobalVariables) => void, +|} { + const globalVariables = useSelector((state) => state.panels.globalVariables); + const dispatch = useDispatch(); + return { + globalVariables, + ...bindActionCreators({ setGlobalVariables, overwriteGlobalVariables }, dispatch), + }; +} diff --git a/packages/webviz-core/src/loadWebviz.js b/packages/webviz-core/src/loadWebviz.js index 1c096c887..31043d488 100644 --- a/packages/webviz-core/src/loadWebviz.js +++ b/packages/webviz-core/src/loadWebviz.js @@ -6,7 +6,7 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. -import { routerMiddleware } from "connected-react-router"; +import memoize from "lodash/memoize"; import React from "react"; import ReactDOM from "react-dom"; @@ -15,6 +15,7 @@ import ReactDOM from "react-dom"; const defaultHooks = { nodes: () => [], getDefaultGlobalStates() { + const { defaultPlaybackConfig } = require("webviz-core/src/reducers/panels"); return { layout: { direction: "row", @@ -28,14 +29,22 @@ const defaultHooks = { splitPercentage: 33.3333333333, }, savedProps: {}, - globalData: {}, + globalVariables: {}, userNodes: {}, linkedGlobalVariables: [], + playbackConfig: defaultPlaybackConfig, }; }, - migratePanels: (panels) => panels, + migratePanels(panels) { + const migratePanels = require("webviz-core/src/util/migratePanels").default; + return migratePanels(panels); + }, panelCategories() { - return [{ label: "General", key: "general" }, { label: "Utilities", key: "utilities" }]; + return [ + { label: "ROS", key: "ros" }, + { label: "Utilities", key: "utilities" }, + { label: "Debugging", key: "debugging" }, + ]; }, panelsByCategory() { const { ENABLE_NODE_PLAYGROUND_QUERY_KEY } = require("webviz-core/src/util/globalConstants"); @@ -46,17 +55,18 @@ const defaultHooks = { const DiagnosticSummary = require("webviz-core/src/panels/diagnostics/DiagnosticSummary").default; const ImageViewPanel = require("webviz-core/src/panels/ImageView").default; const Internals = require("webviz-core/src/panels/Internals").default; + const NodePlayground = require("webviz-core/src/panels/NodePlayground").default; + const Note = require("webviz-core/src/panels/Note").default; const NumberOfRenders = require("webviz-core/src/panels/NumberOfRenders").default; const Plot = require("webviz-core/src/panels/Plot").default; + const RawMessages = require("webviz-core/src/panels/RawMessages").default; const Rosout = require("webviz-core/src/panels/Rosout").default; const StateTransitions = require("webviz-core/src/panels/StateTransitions").default; + const SubscribeToList = require("webviz-core/src/panels/SubscribeToList").default; const ThreeDimensionalViz = require("webviz-core/src/panels/ThreeDimensionalViz").default; - const RawMessages = require("webviz-core/src/panels/RawMessages").default; - const NodePlayground = require("webviz-core/src/panels/NodePlayground").default; const { ndash } = require("webviz-core/src/util/entities"); - const Note = require("webviz-core/src/panels/Note").default; - const general = [ + const ros = [ { title: "3D", component: ThreeDimensionalViz }, { title: `Diagnostics ${ndash} Summary`, component: DiagnosticSummary }, { title: `Diagnostics ${ndash} Detail`, component: DiagnosticStatusPanel }, @@ -65,7 +75,6 @@ const defaultHooks = { { title: "Raw Messages", component: RawMessages }, { title: "rosout", component: Rosout }, { title: "State Transitions", component: StateTransitions }, - { title: "Number of Renders", component: NumberOfRenders, hideFromList: true }, ]; const utilities = [ @@ -74,10 +83,15 @@ const defaultHooks = { { title: "Webviz Internals", component: Internals }, ]; - return { general, utilities }; + const debugging = [ + { title: "Number of Renders", component: NumberOfRenders }, + { title: "Subscribe to List", component: SubscribeToList }, + ]; + + return { ros, utilities, debugging }; }, helpPageFootnote: () => null, - perPanelHooks: () => { + perPanelHooks: memoize(() => { const World = require("webviz-core/src/panels/ThreeDimensionalViz/World").default; const LaserScanVert = require("webviz-core/src/panels/ThreeDimensionalViz/LaserScanVert").default; const FileMultipleIcon = require("@mdi/svg/svg/file-multiple.svg").default; @@ -86,8 +100,6 @@ const defaultHooks = { const { SECOND_BAG_PREFIX } = require("webviz-core/src/util/globalConstants"); - const getMetadata = () => {}; - getMetadata.topics = []; return { Panel: { topicPrefixes: { @@ -104,7 +116,7 @@ const defaultHooks = { }, }, }, - DiagnosticSummary: { defaultConfig: { pinnedIds: [] } }, + DiagnosticSummary: { defaultConfig: { pinnedIds: [], hardwareIdFilter: "" } }, ImageView: { defaultConfig: { cameraTopic: "", @@ -132,8 +144,7 @@ const defaultHooks = { icons: {}, WorldComponent: World, LaserScanVert, - getMetadata, - setGlobalDataInSceneBuilder: (globalData, selectionState, topicsToRender) => ({ + setGlobalVariablesInSceneBuilder: (globalVariables, selectionState, topicsToRender) => ({ selectionState, topicsToRender, }), @@ -159,17 +170,12 @@ const defaultHooks = { }, RawMessages: { docLinkFunction: () => undefined }, }; - }, + }), Root({ store }) { const Root = require("webviz-core/src/components/Root").default; return ; }, topicsWithIncorrectHeaders: () => [], - heavyDatatypesWithNoTimeDependency: () => [ - "sensor_msgs/PointCloud2", - "sensor_msgs/LaserScan", - "nav_msgs/OccupancyGrid", - ], useRaven: () => true, load: () => {}, onPanelClose: () => {}, @@ -214,11 +220,6 @@ export function loadWebviz(hooksToSet) { const waitForFonts = require("webviz-core/src/styles/waitForFonts").default; const Confirm = require("webviz-core/src/components/Confirm").default; - const createRootReducer = require("webviz-core/src/reducers").default; - const configureStore = require("webviz-core/src/store").default; - const history = require("webviz-core/src/util/history").default; - - const store = configureStore(createRootReducer(history), [routerMiddleware(history)]); function render() { const rootEl = document.getElementById("root"); @@ -228,7 +229,7 @@ export function loadWebviz(hooksToSet) { } waitForFonts(() => { - ReactDOM.render(, rootEl); + ReactDOM.render(, rootEl); }); } diff --git a/packages/webviz-core/src/panels/GlobalVariables/index.help.md b/packages/webviz-core/src/panels/GlobalVariables/index.help.md index 11582d48c..f48254e4f 100644 --- a/packages/webviz-core/src/panels/GlobalVariables/index.help.md +++ b/packages/webviz-core/src/panels/GlobalVariables/index.help.md @@ -7,3 +7,5 @@ For example, instead of having to change a particular object ID that you want to - Set a `$my_object_ID` variable in the Global Variables panel - Type `/my_objects.objects[:]{id==$my_object_ID}.some_field` in your Plot Panel to plot a particular field in your specified object - Type `/my_objects.objects[:]{id==$my_object_ID}` in your Raw Messages Panel to see all the info for your specified object + +For numeric global variable values, use the up and down arrow keys to increment and decrement the values, respectively. diff --git a/packages/webviz-core/src/panels/GlobalVariables/index.js b/packages/webviz-core/src/panels/GlobalVariables/index.js index 66b3c6a96..b3807ac23 100644 --- a/packages/webviz-core/src/panels/GlobalVariables/index.js +++ b/packages/webviz-core/src/panels/GlobalVariables/index.js @@ -17,13 +17,14 @@ import Flex from "webviz-core/src/components/Flex"; import Icon from "webviz-core/src/components/Icon"; import Panel from "webviz-core/src/components/Panel"; import PanelToolbar from "webviz-core/src/components/PanelToolbar"; -import useGlobalData from "webviz-core/src/hooks/useGlobalData"; +import useGlobalVariables from "webviz-core/src/hooks/useGlobalVariables"; import { UnlinkGlobalVariables } from "webviz-core/src/panels/ThreeDimensionalViz/Interactions"; import { memoizedGetLinkedGlobalVariablesKeyByName } from "webviz-core/src/panels/ThreeDimensionalViz/Interactions/interactionUtils"; import useLinkedGlobalVariables from "webviz-core/src/panels/ThreeDimensionalViz/Interactions/useLinkedGlobalVariables"; import inScreenshotTests from "webviz-core/src/stories/inScreenshotTests"; import colors from "webviz-core/src/styles/colors.module.scss"; import clipboard from "webviz-core/src/util/clipboard"; +import { GLOBAL_VARIABLES_QUERY_KEY } from "webviz-core/src/util/globalConstants"; type Props = {}; const SGlobalVariables = styled.div` @@ -69,7 +70,7 @@ const canParseJSON = (val) => { type InputProps = { innerRef: { current: null | HTMLInputElement }, inputVal: string, - onChange: (SyntheticInputEvent) => void, + onChange: (newVal: string) => void, }; type State = { @@ -94,6 +95,7 @@ class EditableJSONInput extends React.Component { const { inputVal } = this.state; const { innerRef, onChange } = this.props; const isValid = canParseJSON(inputVal); + const keyValMap = { ArrowDown: -1, ArrowUp: 1 }; return ( { value={inputVal} onChange={(e) => { this.setState({ inputVal: e.target.value }); - onChange(e); + onChange(e.target.value); + }} + onKeyDown={(e) => { + if (!isValid) { + return; + } + const parsedVal = JSON.parse(inputVal); + if (isNaN(parsedVal) || !Object.keys(keyValMap).includes(e.key)) { + return; + } + const newVal = parsedVal + keyValMap[e.key]; + this.setState({ inputVal: newVal }); + onChange(newVal); }} /> ); } } -const changeGlobalKey = (newKey, oldKey, globalData, idx, overwriteGlobalData) => { - const keys = Object.keys(globalData); - overwriteGlobalData({ - ...pick(globalData, keys.slice(0, idx)), - [newKey]: globalData[oldKey], - ...pick(globalData, keys.slice(idx + 1)), +const changeGlobalKey = (newKey, oldKey, globalVariables, idx, overwriteGlobalVariables) => { + const keys = Object.keys(globalVariables); + overwriteGlobalVariables({ + ...pick(globalVariables, keys.slice(0, idx)), + [newKey]: globalVariables[oldKey], + ...pick(globalVariables, keys.slice(idx + 1)), }); }; -const changeGlobalVal = (newVal, name, setGlobalData) => { - setGlobalData({ [name]: newVal === undefined ? undefined : JSON.parse(String(newVal)) }); +const changeGlobalVal = (newVal, name, setGlobalVariables) => { + setGlobalVariables({ [name]: newVal === undefined ? undefined : JSON.parse(String(newVal)) }); }; -const getUpdatedURL = (globalData) => { +const getUpdatedURL = (globalVariables) => { const queryParams = new URLSearchParams(window.location.search); - queryParams.set("global-data", JSON.stringify(globalData)); + queryParams.set(GLOBAL_VARIABLES_QUERY_KEY, JSON.stringify(globalVariables)); if (inScreenshotTests()) { return `http://localhost:3000/?${queryParams.toString()}`; } @@ -139,10 +153,10 @@ function GlobalVariables(props: Props): Node { const [inputStr, setInputStr] = useState(""); const [editingField, setEditingField] = useState(null); - const { globalData, setGlobalData, overwriteGlobalData } = useGlobalData(); + const { globalVariables, setGlobalVariables, overwriteGlobalVariables } = useGlobalVariables(); const { linkedGlobalVariables } = useLinkedGlobalVariables(); - const globalVariableNames = Object.keys(globalData); + const globalVariableNames = Object.keys(globalVariables); const globalVariableNamesWithIdx = globalVariableNames.map((name, idx) => ({ name, idx })); const linkedGlobalVariablesKeyByName = memoizedGetLinkedGlobalVariablesKeyByName(linkedGlobalVariables); const [linked, unlinked] = partition( @@ -163,7 +177,7 @@ function GlobalVariables(props: Props): Node { if (newKey === "") { return "variable name must not be empty"; } - if (newKey in globalData && newKey !== editingField) { + if (newKey in globalVariables && newKey !== editingField) { return `variable $${newKey} already exists`; } } @@ -172,11 +186,10 @@ function GlobalVariables(props: Props): Node { return ( { - const newVal = e.target.value; + inputVal={JSON.stringify(globalVariables[name] || "")} + onChange={(newVal) => { if (canParseJSON(newVal)) { - changeGlobalVal(newVal, name, setGlobalData); + changeGlobalVal(newVal, name, setGlobalVariables); setBtnMessage("Copy"); } }} @@ -241,8 +254,8 @@ function GlobalVariables(props: Props): Node { ); } else { setError(null); - // update globalData right away if the field is valid - changeGlobalKey(newKey.trim(), name, globalData, idx, overwriteGlobalData); + // update globalVariables right away if the field is valid + changeGlobalKey(newKey.trim(), name, globalVariables, idx, overwriteGlobalVariables); setBtnMessage("Copy"); } }} @@ -252,7 +265,7 @@ function GlobalVariables(props: Props): Node { { - changeGlobalVal(undefined, name, setGlobalData); + changeGlobalVal(undefined, name, setGlobalVariables); setBtnMessage("Copy"); }}> @@ -271,7 +284,7 @@ function GlobalVariables(props: Props): Node { setInputStr(""); setError(""); setEditingField(""); - setGlobalData({ "": "" }); + setGlobalVariables({ "": "" }); }}> + Add variable @@ -282,16 +295,16 @@ function GlobalVariables(props: Props): Node { memo[name] = undefined; return memo; }, {}); - overwriteGlobalData(newGlobalVariables); + overwriteGlobalVariables(newGlobalVariables); }}> - Clear all - + {document.queryCommandSupported("copy") && ( - + )} diff --git a/packages/webviz-core/src/panels/GlobalVariables/index.stories.js b/packages/webviz-core/src/panels/GlobalVariables/index.stories.js index 10d6b5a6d..4c7ae5d2d 100644 --- a/packages/webviz-core/src/panels/GlobalVariables/index.stories.js +++ b/packages/webviz-core/src/panels/GlobalVariables/index.stories.js @@ -56,9 +56,9 @@ const linkedGlobalVariables = [ }, ]; -function setStorage(globalData) { +function setStorage(globalVariables) { const storage = new Storage(); - storage.set(GLOBAL_STATE_STORAGE_KEY, { ...defaultGlobalState, globalData }); + storage.set(GLOBAL_STATE_STORAGE_KEY, { ...defaultGlobalState, globalVariables }); } function PanelWithData({ linkedGlobalVariables = [], ...rest }: { linkedGlobalVariables?: LinkedGlobalVariable[] }) { diff --git a/packages/webviz-core/src/panels/ImageView/ImageCanvas.js b/packages/webviz-core/src/panels/ImageView/ImageCanvas.js index a0e4173ee..3399f0493 100644 --- a/packages/webviz-core/src/panels/ImageView/ImageCanvas.js +++ b/packages/webviz-core/src/panels/ImageView/ImageCanvas.js @@ -373,8 +373,8 @@ export default class ImageCanvas extends React.Component { // remove the leading / so the image name doesn't start with _ const topicName = topic.slice(1); const stamp = image.message.header ? image.message.header.stamp : { sec: 0, nsec: 0 }; - const filename = `${topicName}-${stamp.sec}-${stamp.nsec}`; - downloadFiles([blob], filename); + const fileName = `${topicName}-${stamp.sec}-${stamp.nsec}`; + downloadFiles([{ blob, fileName }]); }); }; diff --git a/packages/webviz-core/src/panels/Internals.js b/packages/webviz-core/src/panels/Internals.js index 13d8df15f..e0bf9b4c3 100644 --- a/packages/webviz-core/src/panels/Internals.js +++ b/packages/webviz-core/src/panels/Internals.js @@ -20,9 +20,12 @@ import { useMessagePipeline } from "webviz-core/src/components/MessagePipeline"; import Panel from "webviz-core/src/components/Panel"; import PanelToolbar from "webviz-core/src/components/PanelToolbar"; import filterMap from "webviz-core/src/filterMap"; +import * as PanelAPI from "webviz-core/src/PanelAPI"; import type { Topic, Message, SubscribePayload, AdvertisePayload } from "webviz-core/src/players/types"; import { downloadTextFile } from "webviz-core/src/util"; +const { useCallback } = React; + const RECORD_ALL = "RECORD_ALL"; const Container = styled.div` @@ -75,9 +78,11 @@ function getPublisherGroup({ advertiser }: AdvertisePayload): string { return ``; } -// Display webviz internal state for debugging and for QA to view topic dependencies. +// Display webviz internal state for debugging and viewing topic dependencies. function Internals(): React.Node { - const { subscriptions, publishers, sortedTopics: topics } = useMessagePipeline(); + const { topics } = PanelAPI.useDataSourceInfo(); + const subscriptions = useMessagePipeline(useCallback(({ subscriptions }) => subscriptions, [])); + const publishers = useMessagePipeline(useCallback(({ publishers }) => publishers, [])); const [groupedSubscriptions, subscriptionGroups] = React.useMemo( () => { diff --git a/packages/webviz-core/src/panels/NodePlayground/BottomBar/DiagnosticsSection.js b/packages/webviz-core/src/panels/NodePlayground/BottomBar/DiagnosticsSection.js new file mode 100644 index 000000000..590a00a31 --- /dev/null +++ b/packages/webviz-core/src/panels/NodePlayground/BottomBar/DiagnosticsSection.js @@ -0,0 +1,63 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. +import AlertCircleIcon from "@mdi/svg/svg/alert-circle.svg"; +import AlertIcon from "@mdi/svg/svg/alert.svg"; +import HelpCircleIcon from "@mdi/svg/svg/help-circle.svg"; +import InformationIcon from "@mdi/svg/svg/information.svg"; +import { invert } from "lodash"; +import React from "react"; + +import Icon from "webviz-core/src/components/Icon"; +import type { Diagnostic } from "webviz-core/src/players/UserNodePlayer/types"; +import { DiagnosticSeverity } from "webviz-core/src/players/UserNodePlayer/types"; +import { colors } from "webviz-core/src/util/colors"; + +const severityColors = { + Hint: colors.YELLOWL1, + Info: colors.BLUEL1, + Warning: colors.ORANGEL1, + Error: colors.REDL1, +}; + +const severityIcons = { + Hint: , + Info: , + Warning: , + Error: , +}; + +type Props = { + diagnostics: Diagnostic[], +}; + +const DiagnosticsSection = ({ diagnostics }: Props) => { + return diagnostics.length ? ( +
    + {diagnostics.map(({ severity, message, source, startColumn = null, startLineNumber = null }) => { + const severityLabel = invert(DiagnosticSeverity)[severity]; + const errorLoc = + startLineNumber != null && startColumn != null ? `[${startLineNumber + 1},${startColumn + 1}]` : null; + return ( +
  • + + {severityIcons[severityLabel]} + + {message} + + {source} {errorLoc} + +
  • + ); + })} +
+ ) : ( +

No problems to display.

+ ); +}; + +export default DiagnosticsSection; diff --git a/packages/webviz-core/src/panels/NodePlayground/BottomBar/LogsSection.js b/packages/webviz-core/src/panels/NodePlayground/BottomBar/LogsSection.js new file mode 100644 index 000000000..79b8d8296 --- /dev/null +++ b/packages/webviz-core/src/panels/NodePlayground/BottomBar/LogsSection.js @@ -0,0 +1,87 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. +import React from "react"; +import Tree from "react-json-tree"; +import styled from "styled-components"; + +import type { UserNodeLog } from "webviz-core/src/players/UserNodePlayer/types"; +import { colors } from "webviz-core/src/util/colors"; +import { jsonTreeTheme } from "webviz-core/src/util/globalConstants"; + +const SListItem = styled.li` + display: flex; + justify-content: space-between; + align-items: baseline; + cursor: default; + + :hover { + background-color: ${colors.DARK4}; + } +`; + +type Props = { + nodeId: ?string, + logs: UserNodeLog[], + clearLogs: (nodeId: string) => void, +}; + +const valueColorMap = { + string: jsonTreeTheme.base0B, + number: jsonTreeTheme.base09, + boolean: jsonTreeTheme.base09, + object: jsonTreeTheme.base08, // null + undefined: jsonTreeTheme.base08, +}; + +const LogsSection = ({ nodeId, logs, clearLogs }: Props) => { + if (logs.length === 0) { + return ( + <> +

No logs to display.

+

+ Invoke log(someValue) in your Webviz node code to see data printed here. +

+ + ); + } + return ( + <> + +
    + {logs.map(({ source, value }, idx) => { + const renderTreeObj = value != null && typeof value === "object"; + return ( + + {renderTreeObj ? ( + + ) : ( + + {value == null || value === false ? String(value) : value} + + )} +
    {source}
    +
    + ); + })} +
+ + ); +}; + +export default LogsSection; diff --git a/packages/webviz-core/src/panels/NodePlayground/BottomBar/index.js b/packages/webviz-core/src/panels/NodePlayground/BottomBar/index.js new file mode 100644 index 000000000..ae5a27dfc --- /dev/null +++ b/packages/webviz-core/src/panels/NodePlayground/BottomBar/index.js @@ -0,0 +1,142 @@ +// @flow +// +// Copyright (c) 2019-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. +import React, { useState, useRef, useEffect } from "react"; +import { useDispatch } from "react-redux"; +import styled from "styled-components"; + +import { clearUserNodeLogs } from "webviz-core/src/actions/userNodes"; +import Button from "webviz-core/src/components/Button"; +import Flex from "webviz-core/src/components/Flex"; +import DiagnosticsSection from "webviz-core/src/panels/NodePlayground/BottomBar/DiagnosticsSection"; +import LogsSection from "webviz-core/src/panels/NodePlayground/BottomBar/LogsSection"; +import type { Diagnostic, UserNodeLog } from "webviz-core/src/players/UserNodePlayer/types"; +import { colors } from "webviz-core/src/util/colors"; + +const SHeaderItem = styled.div` + cursor: pointer; + padding: 4px; + text-transform: uppercase; +`; + +type Props = { + nodeId: ?string, + isSaved: boolean, + save: () => void, + diagnostics: Diagnostic[], + logs: UserNodeLog[], +}; + +type HeaderItemProps = { + text: string, + isOpen: boolean, + numItems: number, +}; + +const HeaderItem = ({ isOpen, numItems, text }: HeaderItemProps) => ( + + {text} {numItems || ""} + +); + +type BottomBarDisplayState = "closed" | "diagnostics" | "logs"; + +const BottomBar = ({ nodeId, isSaved, save, diagnostics, logs }: Props) => { + const [bottomBarDisplay: BottomBarDisplayState, setBottomBarDisplay] = useState("closed"); + const [autoScroll, setAutoScroll] = useState(true); + + const dispatch = useDispatch(); + const clearLogs = React.useCallback((payload: string) => dispatch(clearUserNodeLogs(payload)), [dispatch]); + const scrollContainer = useRef(null); + + useEffect( + () => { + if (autoScroll) { + if (scrollContainer.current) { + scrollContainer.current.scrollTop = scrollContainer.current.scrollHeight; + } + } + }, + [autoScroll, logs] + ); + + return ( + + + { + if (bottomBarDisplay !== "diagnostics") { + setBottomBarDisplay("diagnostics"); + } else { + setBottomBarDisplay("closed"); + } + }}> + + + { + if (bottomBarDisplay !== "logs") { + setBottomBarDisplay("logs"); + } else { + setBottomBarDisplay("closed"); + } + }}> + + + + +
{ + const scrolledUp = target.scrollHeight - target.scrollTop > target.clientHeight; + if (scrolledUp && autoScroll) { + setAutoScroll(false); + } else if (!scrolledUp && !autoScroll) { + setAutoScroll(true); + } + }} + style={{ + overflowY: bottomBarDisplay !== "closed" ? "scroll" : "auto", + height: bottomBarDisplay !== "closed" ? 150 : 0, + color: colors.DARK9, + }}> + {bottomBarDisplay === "diagnostics" && } + {bottomBarDisplay === "logs" && } +
+
+ ); +}; + +export default BottomBar; diff --git a/packages/webviz-core/src/panels/NodePlayground/Editor.js b/packages/webviz-core/src/panels/NodePlayground/Editor.js index c28760dfe..96c2bdb09 100644 --- a/packages/webviz-core/src/panels/NodePlayground/Editor.js +++ b/packages/webviz-core/src/panels/NodePlayground/Editor.js @@ -6,55 +6,84 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. +import * as monacoApi from "monaco-editor/esm/vs/editor/editor.api"; +import { initVimMode } from "monaco-vim"; import * as React from "react"; import Dimensions from "react-container-dimensions"; +import MonacoEditor from "react-monaco-editor"; -import Flex from "webviz-core/src/components/Flex"; -import Icon from "webviz-core/src/components/Icon"; -import SpinningLoadingIcon from "webviz-core/src/components/SpinningLoadingIcon"; import vsWebvizTheme from "webviz-core/src/panels/NodePlayground/theme/vs-webviz.json"; +import { lib_filename, lib_es6_dts } from "webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/lib"; +import { + ros_lib_filename, + ros_lib_dts, +} from "webviz-core/src/players/UserNodePlayer/nodeTransformerWorker/typescript/ros"; const VS_WEBVIZ_THEME = "vs-webviz"; -const MonacoEditor = React.lazy(() => import(/* webpackChunkName: "react-monaco-editor" */ "react-monaco-editor")); -const Editor = ({ - script, - setScript, - editorForStorybook, // Currently this prop is only used for screenshot tests. -}: { - script: string, - setScript: (script: string) => void, - editorForStorybook?: React.Node, -}) => { +type Props = { script: string, setScript: (script: string) => void, vimMode: boolean }; +const Editor = ({ script, setScript, vimMode }: Props) => { + const editorRef = React.useRef(null); + const vimModeRef = React.useRef(null); + React.useEffect( + () => { + if (editorRef.current) { + if (vimMode) { + vimModeRef.current = initVimMode(editorRef.current); + } else if (vimModeRef.current) { + // Turn off VimMode. + vimModeRef.current.dispose(); + } + } + }, + [vimMode] + ); return ( {({ width, height }) => ( - - - - - - }> - {editorForStorybook || ( - { - monaco.editor.defineTheme(VS_WEBVIZ_THEME, vsWebvizTheme); - }} - options={{ - minimap: { - enabled: false, - }, - }} - value={script} - onChange={setScript} - /> - )} - + { + monaco.editor.defineTheme(VS_WEBVIZ_THEME, vsWebvizTheme); + // This line ensures the type defs we enforce in + // the 'compile' step match that of monaco. Adding the 'lib' + // this way (instead of specifying it in the compiler options) + // is a hack to overwrite the default type defs since the + // typescript language service does not expose such a method. + monaco.languages.typescript.typescriptDefaults.addExtraLib(lib_es6_dts, lib_filename); + monaco.languages.typescript.typescriptDefaults.addExtraLib( + ros_lib_dts, + `file:///node_modules/@types/${ros_lib_filename}` + ); + }} + editorDidMount={(editor) => { + editorRef.current = editor; + if (vimMode) { + vimModeRef.current = initVimMode(editorRef.current); + } + }} + options={{ + // A 'model' in monaco is the interface through which monaco + // (and consumers of monaco) update and refer to the text model. + // E.g. there is a method called `model.pushEditOperations()` + // which is how you set user input. If we do not explicitly set + // the model with our desired URI (in this case, + // 'file:///main.ts'), monaco will create a default model that + // is set to `inmemory://model/`, which, for some reason, + // blocks our ability to add custom modules (like `lib.d.ts` above) to the system. + model: + monacoApi.editor.getModel("file:///main.ts") || + monacoApi.editor.createModel(script, "typescript", monacoApi.Uri.parse("file:///main.ts")), + + minimap: { + enabled: false, + }, + }} + value={script} + onChange={setScript} + /> )} ); diff --git a/packages/webviz-core/src/panels/NodePlayground/Sidebar.js b/packages/webviz-core/src/panels/NodePlayground/Sidebar.js index b3aefe092..ed264bb12 100644 --- a/packages/webviz-core/src/panels/NodePlayground/Sidebar.js +++ b/packages/webviz-core/src/panels/NodePlayground/Sidebar.js @@ -8,117 +8,216 @@ import ArrowLeftBoldIcon from "@mdi/svg/svg/arrow-left-bold.svg"; import CloseIcon from "@mdi/svg/svg/close.svg"; -import FileIcon from "@mdi/svg/svg/file.svg"; -import PlusIcon from "@mdi/svg/svg/plus.svg"; -import React, { useState } from "react"; +import DeleteIcon from "@mdi/svg/svg/delete.svg"; +import FileMultipleIcon from "@mdi/svg/svg/file-multiple.svg"; +import HelpCircleIcon from "@mdi/svg/svg/help-circle.svg"; +import React from "react"; import styled from "styled-components"; -import style from "./index.module.scss"; import Flex from "webviz-core/src/components/Flex"; import Icon from "webviz-core/src/components/Icon"; -import IconButton from "webviz-core/src/components/IconButton"; +import TextContent from "webviz-core/src/components/TextContent"; +import type { Explorer } from "webviz-core/src/panels/NodePlayground"; +import nodePlaygroundDocs from "webviz-core/src/panels/NodePlayground/index.help.md"; +import type { UserNodesState } from "webviz-core/src/reducers/userNodes"; import { type UserNodes } from "webviz-core/src/types/panels"; +import { colors } from "webviz-core/src/util/colors"; -const SButton = styled.button` - position: absolute; - top: 10px; - left: 10px; - width: 30px; - z-index: 10; +const MenuWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 40px; + background-color: ${colors.DARK1}; + & > * { + margin: 10px; + } `; -const SListItem = styled.li` - padding: 10px; +const ExplorerWrapper = styled.div` + display: ${({ show }: { show: boolean }) => (show ? "initial" : "none")}; + background-color: ${colors.GRAY2}; + max-width: 325px; + min-width: 275px; + overflow: auto; +`; + +const ListItem = styled.li` + padding: 5px; cursor: pointer; display: flex; justify-content: space-between; word-break: break-all; + align-items: center; + color: ${({ trusted }) => (!trusted ? colors.REDL1 : "inherit")}; + background-color: ${({ selected }: { selected: boolean }) => (selected ? colors.DARK9 : "transparent")}; + span { + opacity: 0; + } + &:hover { + background-color: ${colors.DARK9}; + span { + opacity: 1; + } + } `; -type Props = { - onSelect: (nodeName: ?string) => void, - onUpdate: (name: string, value: ?string) => void, - selectedNodeName: ?string, - userNodes: UserNodes, -}; +const SFlex = styled.div` + display: flex; + flex-direction: column; + padding: 10px; -function Sidebar(props: Props) { - const { onSelect, selectedNodeName, userNodes, onUpdate } = props; - const [isModalOpen, setIsModalOpen] = useState(false); - const [newNodeOutputName, setNewNodeOutputName] = useState(""); - if (!isModalOpen) { - return ( - { - e.stopPropagation(); - setIsModalOpen(!isModalOpen); - }} - tooltip="Show Webviz nodes to edit"> - - - - - ); + pre { + white-space: pre-wrap; } +`; +type NodesListProps = { + nodes: UserNodes, + selectNode: (id: string) => void, + deleteNode: (id: string) => void, + collapse: () => void, + selectedNodeId: ?string, + nodeDiagnosticsAndLogs: UserNodesState, +}; + +const NodesList = ({ + nodes, + selectNode, + deleteNode, + collapse, + selectedNodeId, + nodeDiagnosticsAndLogs, +}: NodesListProps) => { + const [search, updateSearch] = React.useState(""); return ( - { - // Clicking to drag NodesListSidebar should not close it - e.stopPropagation(); - }} - className={style.filesModal}> - - Nodes - setIsModalOpen(!isModalOpen)}> + + + + updateSearch(e.target.value)} + style={{ backgroundColor: colors.DARK2, margin: 0, padding: "4px", width: "100%" }} + spellCheck={false} + /> + {search ? ( + updateSearch("")} + style={{ + color: colors.DARK9, + position: "absolute", + right: "5px", + top: "50%", + transform: "translateY(-50%)", + }}> + + + ) : null} + + -
-
    - - setNewNodeOutputName(e.target.value)} - /> - { - if (newNodeOutputName && !userNodes[newNodeOutputName]) { - onUpdate(newNodeOutputName, ""); - setNewNodeOutputName(""); - } - }} - icon={} - /> - - {Object.keys(userNodes).map((nodeName) => ( - onSelect(nodeName)}> - {nodeName} - { - onUpdate(nodeName, undefined); - if (selectedNodeName === nodeName) { - onSelect(null); - } - }}> - + {Object.keys(nodes) + .filter((nodeId) => !search || new RegExp(search).test(nodeId)) + .map((nodeId) => { + const trusted = nodeDiagnosticsAndLogs[nodeId] ? nodeDiagnosticsAndLogs[nodeId].trusted : true; + return ( + selectNode(nodeId)} + trusted={trusted}> + {nodes[nodeId].name} + deleteNode(nodeId)} medium> + - - ))} -
-
+ + ); + })}
); -} +}; + +type Props = {| + selectNode: (nodeId: string) => void, + deleteNode: (nodeId: string) => void, + userNodes: UserNodes, + selectedNodeId: ?string, + otherMarkdownDocsForTest?: string, + needsUserTrust: boolean, + nodeDiagnosticsAndLogs: UserNodesState, + explorer: Explorer, + updateExplorer: (explorer: Explorer) => void, +|}; + +const RedDot = styled.div` + width: 10px; + height: 10px; + border-radius: 50%; + background-color: ${colors.REDL1}; + position: absolute; + bottom: -2px; + right: -2px; +`; + +const Sidebar = ({ + userNodes, + selectNode, + deleteNode, + selectedNodeId, + otherMarkdownDocsForTest, + needsUserTrust, + nodeDiagnosticsAndLogs, + explorer, + updateExplorer, +}: Props) => { + const nodesSelected = explorer === "nodes"; + const docsSelected = explorer === "docs"; + return ( + <> + + updateExplorer(nodesSelected ? null : "nodes")} + large + tooltip={"node explorer"} + style={{ color: nodesSelected ? "inherit" : colors.DARK9, position: "relative" }}> + + {needsUserTrust && } + + updateExplorer(docsSelected ? null : "docs")} + large + tooltip={"docs"} + style={{ color: docsSelected ? "inherit" : colors.DARK9 }}> + + + + + {explorer === "nodes" ? ( + updateExplorer(null)} + selectedNodeId={selectedNodeId} + nodeDiagnosticsAndLogs={nodeDiagnosticsAndLogs} + /> + ) : ( + + + {otherMarkdownDocsForTest || nodePlaygroundDocs} + + + )} + + + ); +}; export default Sidebar; diff --git a/packages/webviz-core/src/panels/NodePlayground/index.help.md b/packages/webviz-core/src/panels/NodePlayground/index.help.md index d02771bbd..19e6403a1 100644 --- a/packages/webviz-core/src/panels/NodePlayground/index.help.md +++ b/packages/webviz-core/src/panels/NodePlayground/index.help.md @@ -1,3 +1,358 @@ # Node Playground -Allows users to create custom Webviz nodes that take in user-specified topics, transform these input topics based on a customizable script, and output the transformed result as a new topic for the rest of the application to consume. +Node Playground is a code editor sandbox in which you can write pseudo-ROS topics that get published within Webviz. Node Playground allows you to manipulate, reduce, and filter existing ROS messages and output them in a way that is useful to you. + +## Getting Started + +Node Playground uses TypeScript to typecheck messages coming in and out of your nodes. If you are already familiar with TypeScript, skip the "Learning TypeScript" section below. + +### Learning TypeScript + +TypeScript is a superset of JavaScript, so Google syntactic questions (e.g. how to manipulate arrays, or access object properties) using JavaScript terms, and semantic questions (e.g. how to make an object property optional) using TypeScript terms. + +Here are some resources to get yourself ramped up: + +- [Basic Types](https://www.typescriptlang.org/docs/handbook/basic-types.html) +- [Gitbook](https://basarat.gitbooks.io/typescript/content/docs/why-typescript.html) + +Post in #stay-typesafe for TypeScript questions. + +### Writing Your First Webviz Node + +Every webviz node must declare three exports that indicate how we should execute your node: + +- An inputs array of topic names. +- An output topic with an enforced prefix: `/webviz_node/`. +- A publisher function that takes messages from input topics and publishes messages under your output topic. + +Here is a basic node that echoes its input: + +```typescript +import { Message } from "ros"; + +type RosOutMsg = { + level: number, + name: string, + msg: string, + file: string, + function: string, + line: number, + topics: string[], +}; + +export const inputs = [ "/rosout" ]; +export const output = "/webviz_node/echo"; + +const publisher = (message: Message): RosOutMsg => { + return message.message; +}; + +export default publisher; +``` + +If you drag in a bag, you should now be able to subscribe to the “/webviz_node/echo” topic in the Raw Messages panel. + +But let’s say you wanted to render some markers in the 3D panel. When you create a new node, you’ll be presented with some boilerplate: + +```typescript +import { Message } from "ros"; + +type InputTopicMsg = { /* YOUR INPUT TOPIC TYPE HERE */ }; +type Output = { /* DEFINED YOUR OUTPUT HERE */ }; + +export const inputs = []; +export const output = "/webviz_node/"; + +const publisher = (message: Message): Output => { + return {}; +}; + +export default publisher; +``` + +You’ll notice a few things: + +- The type `Message` is being imported from a custom library called `ros`. For more information on these type definitions and where they come from, refer to the [Important Types to Know](#important-types-to-know) section below. + +- The type `InputTopicMsg` has no properties. + +You'll need to define your own types here from your input topics. + +- The type `Output` has no properties. + +You will need to fill in this type's definition to include the output properties you care about. For markers, you _must_ return a type of the form `{ markers: MarkerType[] }`. Reference the Markers section below for example types. + +Strictly typing your nodes will help you debug issues at compile time rather than at runtime. It's not always obvious in Webviz how message properties are affecting the visualized output. The more you strictly type your nodes, the less likely you will make mistakes. + +#### Using Multiple Input Topics + +In some cases, you will want to define multiple input topics: + +```typescript +import { Time, Message, Header, Pose, LineStripMarker } from "ros"; + +type BaseMessage = Message & { + topic: Topic +} + +type RosOutMsg = { + header: Header, + pose: Pose +}; +type RosOut = BaseMessage<"/rosout", RosOutMsg>; + +type TfMsg = { + transforms: { + header: Header, + child_frame_id: string, + /* ... */ + } +}; +type Tf = BaseMessage<"/tf", TfMsg>; + +type Marker = LineStripMarker; +type MarkerArray = { + markers: Marker[] +} + +export const inputs = [ "/rosout", "/tf" ]; +export const output = "/webviz_node/echo"; + +const publisher = (message: RosOut | Tf): MarkerArray => { + + if (message.topic === "/rosout") { + // type is now refined to `/rosout` -- you can use `message.message.pose` safely + } else { + // type is now refined to `/tf` -- you can use `message.message.transforms` safely + } + + return { markers: [] }; +}; + +export default publisher; +``` + +This snippet uses [union types](https://www.typescriptlang.org/docs/handbook/advanced-types.html#union-types) to assert that the message in the publisher function can take either a `/rosout` or `/tf` topic. Use an `if`/`else` clause to differentiate between incoming topic datatypes when manipulating messages. + +To combine messages from multiple topics, create a variable in your node's global scope to reference every time your publisher function is invoked. Check timestamps to make sure you are not publishing out-of-sync data. + +```typescript +let lastReceiveTime: Time | null = null; +const myScope: { tf: TfMsg, rosout: RosOutMsg } = { 'tf': null, 'rosout': null }; + +const publisher = (message: RosOut | Tf): MarkerArray => { + const { receiveTime } = message; + let inSync = true; + if (receiveTime.sec !== lastReceiveTime.sec || receiveTime.nsec !== lastReceiveTime.nsec) { + lastReceiveTime = receiveTime; + inSync = false; + } + + if (message.topic === "/rosout") { + myScope.rosout = message.message; + } else { + myScope.tf = message.message + } + + if (!inSync) { + return { markers: [] }; + } + ... rest of publishing logic... +``` + +## Important Types to Know + +By using types to publish your node messages, you can catch errors at compile time, rather than at runtime. + +The type definitions below are provided in the Node Playground environment by default, via Webviz's `ros` library. + +```typescript +// RGBA +type RGBA = { // all values are scaled between 0-1 instead of 0-255 + r: number, + g: number, + b: number, + a: number // opacity -- typically you should set this to 1. +}; + +// Time +type Time = { + sec: number, + nsec: number +}; + +// Message +type Message = { + topic: string, + datatype: string, + op: "message", + receiveTime: Time, + message: T, +} + +// Header +type Header = { + frame_id: string, + stamp: Time, +}; + +// Point +type Point = { + x: number, + y: number, + z: number +}; + +// Scale +type Scale = { + x: number, + y: number, + z: number +}; + +// Orientation +type Orientation = { + x: number, + y: number, + z: number, + w: number +}; + +// Pose +type Pose = { + position: Point, + orientation: Orientation +}; + +// Markers +// All marker types build on this base marker type. +type BaseMarker = { + header: Header, + ns: string, // namespace that your marker is published under. + id: string | number, // IMPORTANT: Needs to be unique. Duplicate ids will overwrite other markers. + action: 0 | 1 | 2 | 3, // In most cases, you will want to use '0' here. + pose: Pose, + scale: Scale, + color: RGBA +}; + +// MultiPointMarker +type MultiPointMarker = BaseMarker & { + points: Point[], + colors?: RGBA[] +}; + +// ArrowMarker +type ArrowMarker = BaseMarker & { + type: 0, + points?: Point[], + size?: ArrowSize, +}; + +type ArrowSize = { + shaftWidth: number, + headLength: number, + headWidth: number +}; + +// CubeMarker +type CubeMarker = BaseMarker & { + type: 1 +}; + +// CubeListMarker +type CubeListMarker = MultiPointMarker & { + type: 6 +}; + +// SphereMarker +type SphereMarker = BaseMarker & { + type: 2 +}; + +// SphereListMarker +type SphereListMarker = MultiPointMarker & { + type: 7 +}; + +// CylinderMarker +type CylinderMarker = BaseMarker & { + type: 3 +}; + +// LineStripMarker +type LineStripMarker = MultiPointMarker & { + type: 4 +}; + +// LineListMarker +type LineListMarker = MultiPointMarker & { + type: 5 +}; + +// PointsMarker +type PointsMarker = MultiPointMarker & { + type: 8 +}; + +// TextMarker +type TextMarker = BaseMarker & { + type: 9, + text: string +}; + +// TriangleListMarker +type TriangleListMarker = MultiPointMarker & { + type: 11 +}; + +// MeshMarker +type MeshMarker = MultiPointMarker & { + type: 11 +}; + +// FilledPolygonMarker +type FilledPolygonMarker = MultiPointMarker & { + type: 107 +}; +``` + +To use these predefined type definitions in your Webviz node code, import them from the `ros` library at the top of your code. + +```typescript +import { RGBA, Header, Message } from 'ros'; + +type MyCustomMsg = { header: Header, color: RGBA }; + +export const inputs = ["/some_input"]; +export const output = "/webviz_node/"; + +type Marker = {}; +type MarkerArray = { + markers: Marker[] +}; + +const publisher = (message: Message): MarkerArray => { + return { markers: [] }; +}; + +export default publisher; +``` + +## Debugging + +For easier debugging, invoke `log(someValue)` anywhere in your Webviz node code to print values to the `Logs` section at the bottom of the panel. The only value you cannot `log()` is one that is, or contains, a function definition. + +```typescript +const add = (a: number, b: number): number => a + b; + +// NO ERRORS +log(50, "ABC", null, undefined, 5 + 5, { "abc": 2, "def": false }, add(1, 2)); + +// ERRORS +log(() => {}); +log(add); +log({ "add": add, "subtract": (a: number, b: number): number => a - b }) +``` + +Invoking `log()` outside your publisher function will invoke it once, when your node is registered. Invoking `log()` inside your publisher function will log that value every time your publisher function is called. diff --git a/packages/webviz-core/src/panels/NodePlayground/index.js b/packages/webviz-core/src/panels/NodePlayground/index.js index 522eaf1d1..a443c9ecd 100644 --- a/packages/webviz-core/src/panels/NodePlayground/index.js +++ b/packages/webviz-core/src/panels/NodePlayground/index.js @@ -6,24 +6,56 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. +import CheckboxBlankOutlineIcon from "@mdi/svg/svg/checkbox-blank-outline.svg"; +import CheckboxOutlineIcon from "@mdi/svg/svg/checkbox-marked-circle-outline.svg"; +import CheckboxMarkedIcon from "@mdi/svg/svg/checkbox-marked.svg"; +import PlusIcon from "@mdi/svg/svg/plus.svg"; +import { some } from "lodash"; import * as React from "react"; +import Dimensions from "react-container-dimensions"; import { hot } from "react-hot-loader/root"; import { useSelector, useDispatch } from "react-redux"; +import styled from "styled-components"; +import uuid from "uuid"; -import helpContent from "./index.help.md"; import { setUserNodes as setUserNodesAction } from "webviz-core/src/actions/panels"; -import Button from "webviz-core/src/components/Button"; import Flex from "webviz-core/src/components/Flex"; +import Icon from "webviz-core/src/components/Icon"; +import Item from "webviz-core/src/components/Menu/Item"; import Panel from "webviz-core/src/components/Panel"; import PanelToolbar from "webviz-core/src/components/PanelToolbar"; -import Editor from "webviz-core/src/panels/NodePlayground/Editor"; +import SpinningLoadingIcon from "webviz-core/src/components/SpinningLoadingIcon"; +import BottomBar from "webviz-core/src/panels/NodePlayground/BottomBar"; import Sidebar from "webviz-core/src/panels/NodePlayground/Sidebar"; -import { type UserNodes } from "webviz-core/src/types/panels"; +import { trustUserNode } from "webviz-core/src/players/UserNodePlayer/nodeSecurity"; +import type { UserNodeState } from "webviz-core/src/reducers/userNodes"; +import type { UserNodes } from "webviz-core/src/types/panels"; import { colors } from "webviz-core/src/util/colors"; -import { DEFAULT_WEBVIZ_NODE_NAME } from "webviz-core/src/util/globalConstants"; +import { DEFAULT_WEBVIZ_NODE_PREFIX } from "webviz-core/src/util/globalConstants"; + +const Editor = React.lazy(() => + import(/* webpackChunkName: "node-playground-editor" */ "webviz-core/src/panels/NodePlayground/Editor") +); + +const skeletonBody = `import { Message } from "ros"; + +type InputTopicMsg = { /* YOUR INPUT TOPIC TYPE HERE */ }; +type Output = { /* DEFINED YOUR OUTPUT HERE */ }; + +export const inputs = []; +export const output = "${DEFAULT_WEBVIZ_NODE_PREFIX}"; + +const publisher = (message: Message): Output => { + return {}; +}; + +export default publisher;`; type Config = {| - selectedNodeName: ?string, + selectedNodeId: ?string, + // Used only for storybook screenshot testing. + editorForStorybook?: React.Node, + vimMode: boolean, |}; type Props = { @@ -31,92 +63,250 @@ type Props = { saveConfig: ($Shape) => void, }; -/* -TODO: - - more testing on user navigation -*/ +const UnsavedDot = styled.div` + display: ${({ isSaved }: { isSaved: boolean }) => (isSaved ? "none" : "initial")} + width: 6px; + height: 6px; + border-radius: 50%; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background-color: ${colors.DARK9}; +`; + +// Exported for screenshot testing. +export const NodePlaygroundSettings = ({ config, saveConfig }: Props) => ( + : } + onClick={() => saveConfig({ vimMode: !config.vimMode })}> + Vim Mode + +); + +const SecurityBarWrapper = styled.div` + width: 100%; + height: 40px; + background-color: ${colors.REDL1}; + display: flex; + justify-content: space-between; + padding: 8px; + align-items: center; + font-weight: bold; +`; + +const TrustButton = styled.button` + display: flex; + justify-content: space-between; + align-items: center; + width: 75px; + padding: 4px 8px; + vertical-align: middle; + font-weight: bold; + border: 2px solid ${colors.LIGHT1}; +`; + +const SecurityBar = ({ onClick }: { onClick: () => void }) => ( + +

+ Warning: This panel will execute user-defined code that is coming from a remote source. Make sure you trust it. +

+ + Trust + + + + +
+); + +export type Explorer = null | "docs" | "nodes"; + function NodePlayground(props: Props) { const { config, saveConfig } = props; - const { selectedNodeName } = config; + const { selectedNodeId, editorForStorybook, vimMode } = config; + + const [explorer, updateExplorer] = React.useState(null); const userNodes = useSelector((state) => state.panels.userNodes); - const nodeDiagnostics = useSelector((state) => state.nodeDiagnostics); + const nodeDiagnosticsAndLogs = useSelector((state) => state.userNodes); + const needsUserTrust = useSelector((state) => { + const nodes: UserNodeState[] = (Object.values(state.userNodes): any); + return some(nodes, ({ trusted }) => typeof trusted === "boolean" && !trusted); + }); + const dispatch = useDispatch(); const setUserNodes = React.useCallback((payload: UserNodes) => dispatch(setUserNodesAction(payload)), [dispatch]); - // Holds the not yet saved source code. const selectedNodeDiagnostics = - selectedNodeName && nodeDiagnostics[selectedNodeName] ? nodeDiagnostics[selectedNodeName] : { diagnostics: [] }; - const selectedNode = selectedNodeName ? userNodes[selectedNodeName] : ""; - const [stagedScript, setStagedScript] = React.useState(selectedNode); - const isNodeSaved = stagedScript === selectedNode; - const { diagnostics } = selectedNodeDiagnostics; + selectedNodeId && nodeDiagnosticsAndLogs[selectedNodeId] ? nodeDiagnosticsAndLogs[selectedNodeId].diagnostics : []; + const selectedNode = selectedNodeId ? userNodes[selectedNodeId] : undefined; + // Holds the not yet saved source code. + const [stagedScript, setStagedScript] = React.useState(selectedNode ? selectedNode.sourceCode : ""); + const isNodeSaved = !selectedNode || (!!selectedNode && stagedScript === selectedNode.sourceCode); + const selectedNodeLogs = + selectedNodeId && nodeDiagnosticsAndLogs[selectedNodeId] ? nodeDiagnosticsAndLogs[selectedNodeId].logs : []; React.useLayoutEffect( () => { - setStagedScript(selectedNode); + if (selectedNode) { + setStagedScript(selectedNode.sourceCode); + } }, [selectedNode] ); + // UX nicety so that the user can see which nodes need to be verified. + React.useLayoutEffect( + () => { + if (needsUserTrust) { + updateExplorer("nodes"); + } + }, + [needsUserTrust] + ); + + const addNewNode = React.useCallback( + () => { + const newNodeId = uuid.v4(); + setUserNodes({ + [newNodeId]: { + sourceCode: skeletonBody, + name: `${DEFAULT_WEBVIZ_NODE_PREFIX}${newNodeId.split("-")[0]}`, + }, + }); + saveConfig({ selectedNodeId: newNodeId }); + // TODO: Add integration test for this flow. + trustUserNode({ id: newNodeId, sourceCode: skeletonBody }); + }, + [saveConfig, setUserNodes] + ); + + const trustSelectedNode = React.useCallback( + () => { + if (!selectedNodeId || !selectedNode) { + return; + } + trustUserNode({ id: selectedNodeId, sourceCode: selectedNode.sourceCode }); + // no-op in order to trigger the useUserNodes hook. + setUserNodes({}); + }, + [selectedNode, selectedNodeId, setUserNodes] + ); + return ( - - - - { - // Save current state so that user can seamlessly go back to previous work. - setUserNodes({ [selectedNodeName || DEFAULT_WEBVIZ_NODE_NAME]: stagedScript }); - saveConfig({ selectedNodeName: nodeName }); - }} - onUpdate={(name, value) => { - setUserNodes({ [name]: value }); - // If value is undefined, that means we deleted the node. - const nodeName = typeof value === "string" ? name : ""; - saveConfig({ selectedNodeName: nodeName }); - }} - selectedNodeName={selectedNodeName} - userNodes={userNodes} - /> - - -
Currently editing:
- { - const newNodeName = e.target.value; - setUserNodes({ ...userNodes, [selectedNodeName]: undefined, [newNodeName]: selectedNode }); - saveConfig({ selectedNodeName: newNodeName }); + + {({ height, width }) => ( + + } /> + + { + if (selectedNodeId) { + // Save current state so that user can seamlessly go back to previous work. + setUserNodes({ + [selectedNodeId]: { ...selectedNode, sourceCode: stagedScript }, + }); + } + saveConfig({ selectedNodeId: nodeId }); + }} + deleteNode={(nodeId) => { + setUserNodes({ ...userNodes, [nodeId]: undefined }); + saveConfig({ selectedNodeId: undefined }); }} + selectedNodeId={selectedNodeId} + userNodes={userNodes} + needsUserTrust={needsUserTrust} + nodeDiagnosticsAndLogs={nodeDiagnosticsAndLogs} /> - - - - -
    - {diagnostics.map(({ message }) => ( -
  • {message}
  • - ))} -
+ + + {selectedNodeId && ( +
+ { + const newNodeName = e.target.value; + setUserNodes({ + ...userNodes, + [selectedNodeId]: { ...selectedNode, name: newNodeName }, + }); + }} + /> + +
+ )} + + + +
+ + {nodeDiagnosticsAndLogs[selectedNodeId] && + typeof nodeDiagnosticsAndLogs[selectedNodeId].trusted === "boolean" && + !nodeDiagnosticsAndLogs[selectedNodeId].trusted && } + +
+ + + + + + }> + {editorForStorybook || ( + + )} + +
+ { + if (!selectedNodeId) { + return; + } + setUserNodes({ [selectedNodeId]: { ...selectedNode, sourceCode: stagedScript } }); + trustUserNode({ id: selectedNodeId, sourceCode: stagedScript }); + }} + diagnostics={selectedNodeDiagnostics} + logs={selectedNodeLogs} + /> +
+
-
-
+ )} + ); } NodePlayground.panelType = "NodePlayground"; -// TODO: There should NOT be a default selected node name. Force user to choose. -NodePlayground.defaultConfig = { selectedNodeName: DEFAULT_WEBVIZ_NODE_NAME }; +NodePlayground.defaultConfig = { selectedNodeId: undefined, vimMode: false }; export default hot(Panel(NodePlayground)); diff --git a/packages/webviz-core/src/panels/NodePlayground/index.module.scss b/packages/webviz-core/src/panels/NodePlayground/index.module.scss deleted file mode 100644 index 496ea344f..000000000 --- a/packages/webviz-core/src/panels/NodePlayground/index.module.scss +++ /dev/null @@ -1,14 +0,0 @@ -@import "~webviz-core/src/styles/colors.module.scss"; -@import "~webviz-core/src/styles/mixins.module.scss"; - -.filesModal { - background-color: $panel-background; - width: 25%; - min-width: 250px; - max-width: 280px; - overflow: hidden; -} - -.highlight { - color: $highlight; -} diff --git a/packages/webviz-core/src/panels/NodePlayground/index.stories.js b/packages/webviz-core/src/panels/NodePlayground/index.stories.js index 10122b9af..a4ac455ee 100644 --- a/packages/webviz-core/src/panels/NodePlayground/index.stories.js +++ b/packages/webviz-core/src/panels/NodePlayground/index.stories.js @@ -10,13 +10,16 @@ import { storiesOf } from "@storybook/react"; import React from "react"; import { withScreenshot } from "storybook-chrome-screenshot"; -import NodePlayground from "webviz-core/src/panels/NodePlayground"; -import Editor from "webviz-core/src/panels/NodePlayground/Editor"; +import NodePlayground, { NodePlaygroundSettings } from "webviz-core/src/panels/NodePlayground"; +import type { Explorer } from "webviz-core/src/panels/NodePlayground"; +import testDocs from "webviz-core/src/panels/NodePlayground/index.test.md"; +import Sidebar from "webviz-core/src/panels/NodePlayground/Sidebar"; import PanelSetup from "webviz-core/src/stories/PanelSetup"; +import { DEFAULT_WEBVIZ_NODE_PREFIX } from "webviz-core/src/util/globalConstants"; const userNodes = { - "/some/custom/node": "const someVariableName = 1;", - "/another/custom/node": "const anotherVariableName = 2;", + nodeId1: { name: "/some/custom/node", sourceCode: "const someVariableName = 1;" }, + nodeId2: { name: "/another/custom/node", sourceCode: "const anotherVariableName = 2;" }, }; const fixture = { @@ -24,73 +27,333 @@ const fixture = { frame: {}, }; +const sourceCodeWithLogs = ` + import { Time, Message } from "ros"; + type InputTopicMsg = {header: {stamp: Time}}; + type Marker = {}; + type MarkerArray = { markers: Marker[] } + + export const inputs = ["/able_to_engage"]; + export const output = "${DEFAULT_WEBVIZ_NODE_PREFIX}"; + + const publisher = (message: Message): MarkerArray => { + log({ "someKey": { "nestedKey": "nestedValue" } }); + return { markers: [] }; + }; + + log(100, false, "abc", null, undefined); + export default publisher; +`; +const logs = [ + { source: "registerNode", value: 100, lineNum: 1, colNum: 0 }, + { source: "registerNode", value: false, lineNum: 2, colNum: 0 }, + { source: "registerNode", value: "abc", lineNum: 3, colNum: 0 }, + { source: "registerNode", value: null, lineNum: 4, colNum: 0 }, + { source: "registerNode", value: undefined, lineNum: 5, colNum: 0 }, + { source: "processMessage", value: { someKey: { nestedKey: "nestedValue" } }, lineNum: 6, colNum: 0 }, +]; + storiesOf("", module) - .addDecorator(withScreenshot()) - .add("default", () => { + .addDecorator(withScreenshot({ delay: 1000 })) + .add("welcome screen", () => { return ( ); }) - .add("sidebar open - Webviz nodes & user-added nodes", () => { + .add("sidebar open - node explorer", () => { return ( { setImmediate(() => { - const toggleElements = el.querySelectorAll("[data-test-node-playground-sidebar]"); - - for (const toggleElement of toggleElements) { - if (toggleElement) { - toggleElement.click(); - } - } + el.querySelectorAll("[data-test=node-explorer]")[0].click(); }); }}> ); }) - .add("sidebar open - selected node", () => { + .add("sidebar open - node explorer - selected node", () => { return ( { setImmediate(() => { - const toggleElements = el.querySelectorAll("[data-test-node-playground-sidebar]"); - - for (const toggleElement of toggleElements) { - if (toggleElement) { - toggleElement.click(); - } - } + el.querySelectorAll("[data-test=node-explorer]")[0].click(); }); }}> - + ); }) - .add("editor loading state", () => { - const NeverLoad = () => { - throw new Promise(() => {}); - }; + .add("sidebar open - docs explorer", () => { return ( - - {}} editorForStorybook={} /> + { + setImmediate(() => { + el.querySelectorAll("[data-test=docs-explorer]")[0].click(); + }); + }}> + ); }) - .add("typescript error", () => { + .add("sidebar - code snippets wrap", () => { + const Story = () => { + const [explorer, updateExplorer] = React.useState("docs"); + return ( + + {}} + selectNode={() => {}} + otherMarkdownDocsForTest={testDocs} + /> + + ); + }; + return ; + }) + .add("editor loading state", () => { + const NeverLoad = () => { + throw new Promise(() => {}); + }; return ( - - + + , vimMode: false }} /> ); }); + +storiesOf("NodePlayground - ", module) + .addDecorator(withScreenshot({ delay: 1000 })) + + .add("no errors or logs - closed", () => ( + + + + )) + .add("no errors - open", () => ( + { + setImmediate(() => { + const diagnosticsErrorsLabel = el.querySelector("[data-test=np-errors]"); + if (diagnosticsErrorsLabel) { + diagnosticsErrorsLabel.click(); + } + }); + }}> + + + )) + .add("no logs - open", () => ( + { + setImmediate(() => { + const logsLabel = el.querySelector("[data-test=np-logs]"); + if (logsLabel) { + logsLabel.click(); + } + }); + }}> + + + )) + .add("errors - closed", () => ( + + + + )) + .add("errors - open", () => ( + { + setImmediate(() => { + const diagnosticsErrorsLabel = el.querySelector("[data-test=np-errors]"); + if (diagnosticsErrorsLabel) { + diagnosticsErrorsLabel.click(); + } + }); + }}> + + + )) + .add("logs - closed", () => ( + + + + )) + .add("logs - open", () => ( + { + setImmediate(() => { + const logsLabel = el.querySelector("[data-test=np-logs]"); + if (logsLabel) { + logsLabel.click(); + } + }); + }}> + + + )) + .add("cleared logs", () => ( + { + setImmediate(() => { + const logsLabel = el.querySelector("[data-test=np-logs]"); + if (logsLabel) { + logsLabel.click(); + const clearBtn = el.querySelector("button[data-test=np-logs-clear]"); + if (clearBtn) { + clearBtn.click(); + } + } + }); + }}> + + + )) + .add("security pop up", () => ( + + + + )); + +storiesOf("", module) + .addDecorator(withScreenshot({ delay: 1000 })) + .add("enabled vim mode", () => ( + {}} /> + )) + .add("disabled vim mode", () => ( + {}} /> + )); diff --git a/packages/webviz-core/src/panels/NodePlayground/index.test.md b/packages/webviz-core/src/panels/NodePlayground/index.test.md new file mode 100644 index 000000000..4ae0d957b --- /dev/null +++ b/packages/webviz-core/src/panels/NodePlayground/index.test.md @@ -0,0 +1,21 @@ +# Node Playground + +The code below should wrap appropriately, instead of going outside the Sidebar's visible window. + +```typescript +import { RGBA, Header, Message } from 'ros'; + +type MyCustomMsg = { header: Header, color: RGBA }; + +export const inputs = ["/some_input"]; +export const output = "/webviz_node/"; + +type Marker = {}; +type MarkerArray = { markers: Marker[] }; + +const publisher = (message: Message): MarkerArray => { + return { markers: [] }; +}; + +export default publisher; +``` diff --git a/packages/webviz-core/src/panels/NodePlayground/theme/vs-webviz.json b/packages/webviz-core/src/panels/NodePlayground/theme/vs-webviz.json index c52a70975..64101309a 100644 --- a/packages/webviz-core/src/panels/NodePlayground/theme/vs-webviz.json +++ b/packages/webviz-core/src/panels/NodePlayground/theme/vs-webviz.json @@ -3,7 +3,7 @@ "inherit": true, "rules": [ { - "foreground": "f7f7f34d", + "foreground": "6c6783", "token": "comment" }, { diff --git a/packages/webviz-core/src/panels/NodePlayground/theme/vs-webviz.tmTheme b/packages/webviz-core/src/panels/NodePlayground/theme/vs-webviz.tmTheme index a8fd82724..6b2f738f1 100644 --- a/packages/webviz-core/src/panels/NodePlayground/theme/vs-webviz.tmTheme +++ b/packages/webviz-core/src/panels/NodePlayground/theme/vs-webviz.tmTheme @@ -35,7 +35,7 @@ settings foreground - #F7F7F34D + #6C6783 diff --git a/packages/webviz-core/src/panels/NumberOfRenders.js b/packages/webviz-core/src/panels/NumberOfRenders.js index dbccddd07..be8b267d0 100644 --- a/packages/webviz-core/src/panels/NumberOfRenders.js +++ b/packages/webviz-core/src/panels/NumberOfRenders.js @@ -6,29 +6,68 @@ // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. import * as React from "react"; +import { hot } from "react-hot-loader/root"; import Flex from "webviz-core/src/components/Flex"; import MessageHistory from "webviz-core/src/components/MessageHistory"; +import { type MessagePipelineContext, useMessagePipeline } from "webviz-core/src/components/MessagePipeline"; import Panel from "webviz-core/src/components/Panel"; import PanelToolbar from "webviz-core/src/components/PanelToolbar"; +import * as PanelAPI from "webviz-core/src/PanelAPI"; +import inScreenshotTests from "webviz-core/src/stories/inScreenshotTests"; // Little dummy panel that just shows the number of renders that happen when not subscribing // to anything. Useful for debugging performance issues. let panelRenderRenderCount = 0; let messageHistoryRenderCount = 0; +let useMessagesRenderCount = 0; +let messagePipelineRenderCount = 0; + +window.getNumberOfRendersCountsForTests = function() { + return { + panelRenderRenderCount, + messageHistoryRenderCount, + useMessagesRenderCount, + messagePipelineRenderCount, + }; +}; + +function HooksComponent() { + PanelAPI.useMessages({ + topics: [], + restore: React.useCallback(() => null, []), + addMessage: React.useCallback(() => null, []), + }); + return `useMessagesRenderCount: ${++useMessagesRenderCount}`; +} + +function MessagePipelineRendersComponent() { + // This is a private API, so panels should not be using it, but it's still bad if it renders too + // much. And in practice there might still be some panels that use it directly. :-( + useMessagePipeline((context: MessagePipelineContext) => context); + return `messagePipelineRenderCount: ${++messagePipelineRenderCount}`; +} + function NumberOfRenders(): React.Node { panelRenderRenderCount++; return ( - - {() => ( - - panelRenderRenderCount: {panelRenderRenderCount}
- messageHistoryRenderCount: {++messageHistoryRenderCount} -
- )} -
+ + + {() => ( + <> + panelRenderRenderCount: {panelRenderRenderCount}
+ messageHistoryRenderCount: {++messageHistoryRenderCount} + + )} +
+
+ +
+ {!inScreenshotTests() && // Too flakey for screenshots. + } +
); } @@ -36,4 +75,4 @@ function NumberOfRenders(): React.Node { NumberOfRenders.panelType = "NumberOfRenders"; NumberOfRenders.defaultConfig = {}; -export default Panel<{}>(NumberOfRenders); +export default hot(Panel<{}>(NumberOfRenders)); diff --git a/packages/webviz-core/src/panels/PanelList.js b/packages/webviz-core/src/panels/PanelList/index.js similarity index 84% rename from packages/webviz-core/src/panels/PanelList.js rename to packages/webviz-core/src/panels/PanelList/index.js index 499c363a3..48c3f98ae 100644 --- a/packages/webviz-core/src/panels/PanelList.js +++ b/packages/webviz-core/src/panels/PanelList/index.js @@ -10,6 +10,7 @@ import * as React from "react"; import { DragSource } from "react-dnd"; import { MosaicDragType, getNodeAtPath, updateTree } from "react-mosaic-component"; import { connect } from "react-redux"; +import styled from "styled-components"; import { changePanelLayout, savePanelConfig } from "webviz-core/src/actions/panels"; import { Item } from "webviz-core/src/components/Menu"; @@ -18,13 +19,25 @@ import { getGlobalHooks } from "webviz-core/src/loadWebviz"; import type { State } from "webviz-core/src/reducers"; import type { PanelConfig, SaveConfigPayload } from "webviz-core/src/types/panels"; import { getPanelIdForType } from "webviz-core/src/util"; +import { colors } from "webviz-core/src/util/colors"; import naturalSort from "webviz-core/src/util/naturalSort"; +const SearchInput = styled.input` + width: 100%; + min-width: 200px; + background-color: ${colors.DARK2} !important; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + margin: 0; + position: sticky; + top: 0; + z-index: 2; +`; + type PanelListItem = {| title: string, component: React.ComponentType, presets?: {| title: string, panelConfig?: PanelConfig |}[], - hideFromList?: boolean, |}; // getPanelsByCategory() and getPanelsByType() are functions rather than top-level constants @@ -71,6 +84,7 @@ type PanelItemProps = { title: string, panelConfig?: PanelConfig, |}, + searchQuery: string, checked?: boolean, // this comes from react-dnd connectDragSource: (any) => React.Node, @@ -84,11 +98,20 @@ type PanelItemProps = { class PanelItem extends React.Component { render() { - const { connectDragSource, panel, onClick, checked } = this.props; + const { connectDragSource, searchQuery, panel, onClick, checked } = this.props; + const searchQueryIndex = !!searchQuery && panel.title.toLowerCase().indexOf(searchQuery); return connectDragSource(
- {panel.title} + {searchQueryIndex !== false ? ( + + {panel.title.substring(0, searchQueryIndex)} + {panel.title.substring(searchQueryIndex, searchQueryIndex + searchQuery.length)} + {panel.title.substring(searchQueryIndex + searchQuery.length)} + + ) : ( + panel.title + )}
); @@ -135,7 +158,8 @@ type Props = { changePanelLayout: (panelLayout: any) => void, savePanelConfig: (SaveConfigPayload) => void, }; -class PanelList extends React.Component { +class PanelList extends React.Component { + state = { searchQuery: "" }; static getComponentForType(type: string): any | void { const panelsByCategory = getPanelsByCategory(); const allPanels = flatten(Object.keys(panelsByCategory).map((category) => panelsByCategory[category])); @@ -197,17 +221,31 @@ class PanelList extends React.Component { } } + _handleSearchChange = (e: SyntheticInputEvent) => { + // TODO(Audrey): press enter to select the first item, allow using arrow key to go up and down + this.setState({ searchQuery: e.target.value }); + }; + render() { this._verifyPanels(); const { mosaicId, onPanelSelect, selectedPanelType } = this.props; + const { searchQuery } = this.state; const panelCategories = getGlobalHooks().panelCategories(); const panelsByCategory = getPanelsByCategory(); + const lowerCaseSearchQuery = searchQuery.toLowerCase(); return (
+ {panelCategories.map(({ label, key }, categoryIdx) => panelsByCategory[key] - .filter(({ hideFromList }) => !hideFromList) + .filter(({ title }) => !title || title.toLowerCase().includes(lowerCaseSearchQuery)) .sort(naturalSort("title")) .map( // $FlowFixMe - bug prevents requiring panelType: https://stackoverflow.com/q/52508434/23649 @@ -225,6 +263,7 @@ class PanelList extends React.Component { }} onDrop={this.onPanelMenuItemDrop} onClick={() => onPanelSelect(panelType, subPanelListItem.panelConfig)} + searchQuery="" /> ))} @@ -237,6 +276,7 @@ class PanelList extends React.Component { onDrop={this.onPanelMenuItemDrop} onClick={() => onPanelSelect(panelType)} checked={panelType === selectedPanelType} + searchQuery={lowerCaseSearchQuery} />
) diff --git a/packages/webviz-core/src/panels/PanelList/index.stories.js b/packages/webviz-core/src/panels/PanelList/index.stories.js new file mode 100644 index 000000000..06aacb8a2 --- /dev/null +++ b/packages/webviz-core/src/panels/PanelList/index.stories.js @@ -0,0 +1,58 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { storiesOf } from "@storybook/react"; +import { createMemoryHistory } from "history"; +import * as React from "react"; +import { DragDropContextProvider } from "react-dnd"; +import HTML5Backend from "react-dnd-html5-backend"; +import TestUtils from "react-dom/test-utils"; +import { Provider } from "react-redux"; +import { withScreenshot } from "storybook-chrome-screenshot"; + +import PanelList from "webviz-core/src/panels/PanelList"; +import createRootReducer from "webviz-core/src/reducers"; +import configureStore from "webviz-core/src/store/configureStore.testing"; + +storiesOf("", module) + .addDecorator(withScreenshot()) + .addDecorator((childrenRenderFcn) => ( + + {childrenRenderFcn()} + + )) + .add("panel list", () => ( + {}} + mosaicId="" + mosaicLayout="" + changePanelLayout={() => {}} + savePanelConfig={() => {}} + /> + )) + .add("filtered panel list", () => ( +
{ + if (el) { + const input: ?HTMLInputElement = (el.querySelector("input"): any); + if (input) { + input.focus(); + input.value = "h"; + TestUtils.Simulate.change(input); + } + } + }}> + {}} + mosaicId="" + mosaicLayout="" + changePanelLayout={() => {}} + savePanelConfig={() => {}} + /> +
+ )); diff --git a/packages/webviz-core/src/panels/Plot/PlotChart.js b/packages/webviz-core/src/panels/Plot/PlotChart.js index acb537e16..cd1f7b881 100644 --- a/packages/webviz-core/src/panels/Plot/PlotChart.js +++ b/packages/webviz-core/src/panels/Plot/PlotChart.js @@ -12,17 +12,13 @@ import { createSelector } from "reselect"; import { Time } from "rosbag"; import styles from "./PlotChart.module.scss"; -import MessageHistory, { - getTimestampForMessage, - type MessageHistoryData, - type MessageHistoryItemsByPath, -} from "webviz-core/src/components/MessageHistory"; +import { getTimestampForMessage, type MessageHistoryItemsByPath } from "webviz-core/src/components/MessageHistory"; import TimeBasedChart, { type TimeBasedChartTooltipData } from "webviz-core/src/components/TimeBasedChart"; import filterMap from "webviz-core/src/filterMap"; import derivative from "webviz-core/src/panels/Plot/derivative"; import { type PlotPath, isReferenceLinePlotPathType } from "webviz-core/src/panels/Plot/internalTypes"; import { lightColor, lineColors } from "webviz-core/src/util/plotColors"; -import { subtractTimes, toSec } from "webviz-core/src/util/time"; +import { subtractTimes, format, formatTimeRaw, toSec } from "webviz-core/src/util/time"; export type PlotChartPoint = {| x: number, @@ -30,6 +26,20 @@ export type PlotChartPoint = {| tooltip: TimeBasedChartTooltipData, |}; +export type DataSet = {| + borderColor: string, + borderWidth: number, + data: Array, + fill: boolean, + key: string, + label: string, + pointBackgroundColor: string, + pointBorderColor: string, + pointHoverRadius: number, + pointRadius: number, + showLine: boolean, +|}; + const Y_AXIS_ID = "Y_AXIS_ID"; function getDatasetFromMessagePlotPath( @@ -48,12 +58,21 @@ function getDatasetFromMessagePlotPath( } for (const { value, path, constantName } of item.queriedData) { + const isTimeObj = value && typeof value === "object" && !isNaN(value.sec) && !isNaN(value.nsec); if (typeof value === "number" || typeof value === "boolean") { points.push({ x: toSec(subtractTimes(timestamp, startTime)), y: Number(value), tooltip: { item, path, value, constantName, startTime }, }); + } else if (isTimeObj) { + const timeObj = { ...value }; + const valueStr = `${format(timeObj)} (${formatTimeRaw(timeObj)})`; + points.push({ + x: toSec(subtractTimes(timestamp, startTime)), + y: timeObj.sec + timeObj.nsec / 1e9, + tooltip: { item, path, value: valueStr, constantName, startTime }, + }); } } // If we have added more than one point for this message, make it a scatter plot. @@ -102,7 +121,7 @@ function getAnnotationFromReferenceLine(path: PlotPath, index: number) { }; } -function getDatasets(paths: PlotPath[], itemsByPath: MessageHistoryItemsByPath, startTime: Time) { +export function getDatasets(paths: PlotPath[], itemsByPath: MessageHistoryItemsByPath, startTime: Time): DataSet[] { return filterMap(paths, (path: PlotPath, index: number) => { if (!path.enabled) { return null; @@ -155,39 +174,30 @@ type PlotChartProps = {| minYValue: number, maxYValue: number, saveCurrentYs: (minY: number, maxY: number) => void, + datasets: DataSet[], |}; export default class PlotChart extends PureComponent { render() { - const { paths, minYValue, maxYValue, saveCurrentYs } = this.props; + const { paths, minYValue, maxYValue, saveCurrentYs, datasets } = this.props; + const annotations = getAnnotations(paths); return ( - // Don't filter out disabled paths when passing into , because we still want - // easy access to the history when turning the disabled paths back on. - path.value)}> - {({ itemsByPath, startTime }: MessageHistoryData) => { - const datasets = getDatasets(paths, itemsByPath, startTime); - const annotations = getAnnotations(paths); - - return ( -
- - {({ width, height }) => ( - - )} - -
- ); - }} -
+
+ + {({ width, height }) => ( + + )} + +
); } } diff --git a/packages/webviz-core/src/panels/Plot/PlotLegend.js b/packages/webviz-core/src/panels/Plot/PlotLegend.js index 2d8d4a971..9cb800141 100644 --- a/packages/webviz-core/src/panels/Plot/PlotLegend.js +++ b/packages/webviz-core/src/panels/Plot/PlotLegend.js @@ -7,6 +7,7 @@ // You may not use this file except in compliance with the License. import cx from "classnames"; +import { last } from "lodash"; import React, { PureComponent } from "react"; import { plotableRosTypes } from "./index"; @@ -41,6 +42,8 @@ export default class PlotLegend extends PureComponent { render() { const { paths, onChange } = this.props; + const lastPath = last(paths); + return (
{paths.map((path: PlotPath, index: number) => { @@ -98,7 +101,19 @@ export default class PlotLegend extends PureComponent { })}
onChange({ paths: [...paths, { value: "", enabled: true, timestampMethod: "receiveTime" }] })}> + onClick={() => + onChange({ + paths: [ + ...paths, + { + value: "", + enabled: true, + // For convenience, default to the `timestampMethod` of the last path. + timestampMethod: lastPath ? lastPath.timestampMethod : "receiveTime", + }, + ], + }) + }> + add line
diff --git a/packages/webviz-core/src/panels/Plot/PlotMenu.js b/packages/webviz-core/src/panels/Plot/PlotMenu.js index a5c40e6a7..3de110ee4 100644 --- a/packages/webviz-core/src/panels/Plot/PlotMenu.js +++ b/packages/webviz-core/src/panels/Plot/PlotMenu.js @@ -12,21 +12,63 @@ import React from "react"; import styles from "./PlotMenu.module.scss"; import Item from "webviz-core/src/components/Menu/Item"; import type { PlotConfig } from "webviz-core/src/panels/Plot"; +import { type DataSet } from "webviz-core/src/panels/Plot/PlotChart"; +import { downloadFiles } from "webviz-core/src/util"; +import { formatTimeRaw } from "webviz-core/src/util/time"; function isValidInput(value: string) { return value === "" || !isNaN(parseFloat(value)); } +export function getHeader(message: any) { + let header = null; + for (const key in message) { + if (key.includes("header")) { + header = message[key]; + } + } + return header; +} + +function formatData(data: any, label: string) { + const { x, y } = data; + const { receiveTime, message } = data.tooltip.item.message; + const receiveTimeFloat = formatTimeRaw(receiveTime); + const header = getHeader(message); + const stampTime = header ? formatTimeRaw(header.stamp) : ""; + return [x, receiveTimeFloat, stampTime, label, y]; +} + +export function getCSVData(datasets: DataSet[]): string { + const headLine = ["elapsed time", "receive time", "header.stamp", "topic", "value"]; + const combinedLines = []; + combinedLines.push(headLine); + datasets.forEach((dataset, idx) => { + dataset.data.forEach((data) => { + combinedLines.push(formatData(data, dataset.label)); + }); + }); + return combinedLines.join("\n"); +} + +function downloadCsvFile(datasets: DataSet[]) { + const csv = getCSVData(datasets); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + downloadFiles([{ blob, fileName: `plot_data_export.csv` }]); +} + export default function PlotMenu({ minYValue, maxYValue, saveConfig, setMinMax, + datasets, }: { minYValue: string, maxYValue: string, saveConfig: ($Shape) => void, setMinMax: () => void, + datasets: DataSet[], }) { return ( <> @@ -57,6 +99,9 @@ export default function PlotMenu({ + + + ); } diff --git a/packages/webviz-core/src/panels/Plot/PlotMenu.stories.js b/packages/webviz-core/src/panels/Plot/PlotMenu.stories.js index cdfe8ae5d..b3286f4ec 100644 --- a/packages/webviz-core/src/panels/Plot/PlotMenu.stories.js +++ b/packages/webviz-core/src/panels/Plot/PlotMenu.stories.js @@ -5,7 +5,6 @@ // This source code is licensed under the Apache License, Version 2.0, // found in the LICENSE file in the root directory of this source tree. // You may not use this file except in compliance with the License. - import { storiesOf } from "@storybook/react"; import { noop } from "lodash"; import * as React from "react"; @@ -21,11 +20,11 @@ storiesOf("", module) .addDecorator(withScreenshot()) .add("With min and max y set", () => ( - + )) .add("With min and max y not set", () => ( - + )); diff --git a/packages/webviz-core/src/panels/Plot/PlotMenu.test.js b/packages/webviz-core/src/panels/Plot/PlotMenu.test.js new file mode 100644 index 000000000..df2b38d56 --- /dev/null +++ b/packages/webviz-core/src/panels/Plot/PlotMenu.test.js @@ -0,0 +1,222 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. + +import { cloneDeep } from "lodash"; + +import { getCSVData, getHeader } from "./PlotMenu"; + +const data = [ + { + x: 0.010651803000000001, + y: 0, + tooltip: { + constantName: "", + item: { + message: { + op: "message", + topic: "/accel_vector_calibrated", + datatype: "imu_messages/IMUSensor", + receiveTime: { + sec: 1547062466, + nsec: 10664222, + }, + message: { + header: { + seq: 475838, + stamp: { + sec: 1547062466, + nsec: 9726015, + }, + frame_id: "", + }, + using_gps_time: false, + vector: { + x: -1.6502913414892997, + y: 0.013979224999911806, + z: 9.956832079451333, + }, + }, + }, + queriedData: [ + { + constantName: "", + value: false, + path: "/accel_vector_calibrated.using_gps_time", + }, + ], + }, + path: "/accel_vector_calibrated.using_gps_time", + value: false, + startTime: { + sec: 1547062466, + nsec: 12419, + }, + }, + }, + { + x: 0.031882799, + y: 0, + tooltip: { + constantName: "", + item: { + message: { + op: "message", + topic: "/accel_vector_calibrated", + datatype: "imu_messages/IMUSensor", + receiveTime: { + sec: 1547062466, + nsec: 31895218, + }, + message: { + header: { + seq: 475839, + stamp: { + sec: 1547062466, + nsec: 31719273, + }, + frame_id: "", + }, + using_gps_time: false, + vector: { + x: -1.6793369565819012, + y: 0.04327456632615036, + z: 10.0864057092908, + }, + }, + }, + queriedData: [ + { + constantName: "", + value: false, + path: "/accel_vector_calibrated.using_gps_time", + }, + ], + }, + path: "/accel_vector_calibrated.using_gps_time", + value: false, + startTime: { + sec: 1547062466, + nsec: 12419, + }, + }, + }, +]; + +const datasets_single_topic = [ + { + borderColor: "#4e98e2", + label: "/accel_vector_calibrated.using_gps_time", + key: "0", + showLine: true, + fill: false, + borderWidth: 1, + pointRadius: 1.5, + pointHoverRadius: 3, + pointBackgroundColor: "#74beff", + pointBorderColor: "transparent", + data, + }, +]; + +const datasets_multiple_topics = [1, 2, 3].map((num) => { + const numData = data.map((perData) => { + const newData = cloneDeep(perData); + newData.x = perData.x + num; + newData.y = perData.y + num; + return newData; + }); + return { + borderColor: "#4e98e2", + label: `/topic${num}`, + key: "0", + showLine: true, + fill: false, + borderWidth: 1, + pointRadius: 1.5, + pointHoverRadius: 3, + pointBackgroundColor: "#74beff", + pointBorderColor: "transparent", + data: numData, + }; +}); + +const datasets_no_header = [1, 2, 3].map((num) => { + const numData = data.map((perData) => { + const newData = cloneDeep(perData); + newData.x = perData.x + num; + newData.y = perData.y + num; + if (num === 3) { + delete newData.tooltip.item.message.message.header; + } + return newData; + }); + return { + borderColor: "#4e98e2", + label: `/topic${num}`, + key: "0", + showLine: true, + fill: false, + borderWidth: 1, + pointRadius: 1.5, + pointHoverRadius: 3, + pointBackgroundColor: "#74beff", + pointBorderColor: "transparent", + data: numData, + }; +}); + +const datasets_diff_timestamp = [1, 2, 3].map((num) => { + const numData = data.map((perData) => { + const newData = cloneDeep(perData); + newData.x = perData.x + num; + newData.y = perData.y + num; + newData.tooltip.item.message.message.header.stamp.sec = perData.tooltip.item.message.message.header.stamp.sec + num; + return newData; + }); + return { + borderColor: "#4e98e2", + label: `/topic${num}`, + key: "0", + showLine: true, + fill: false, + borderWidth: 1, + pointRadius: 1.5, + pointHoverRadius: 3, + pointBackgroundColor: "#74beff", + pointBorderColor: "transparent", + data: numData, + }; +}); + +describe("PlotMenu", () => { + it("Single topic", () => { + expect(getCSVData(datasets_single_topic)).toMatchSnapshot(); + }); + + it("Multiple topics", () => { + expect(getCSVData(datasets_multiple_topics)).toMatchSnapshot(); + }); + + it("Multiple topics with one topic don't have header.stamp", () => { + expect(getCSVData(datasets_no_header)).toMatchSnapshot(); + }); + + it("Multiple topics with different header.stamp", () => { + expect(getCSVData(datasets_diff_timestamp)).toMatchSnapshot(); + }); + + it("get right header", () => { + const message = { + whatever_header: { + sec: 123, + nsec: 456, + }, + }; + expect(getHeader(message)).toEqual({ sec: 123, nsec: 456 }); + }); +}); diff --git a/packages/webviz-core/src/panels/Plot/__snapshots__/PlotMenu.test.js.snap b/packages/webviz-core/src/panels/Plot/__snapshots__/PlotMenu.test.js.snap new file mode 100644 index 000000000..5be3de19f --- /dev/null +++ b/packages/webviz-core/src/panels/Plot/__snapshots__/PlotMenu.test.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PlotMenu Multiple topics 1`] = ` +"elapsed time,receive time,header.stamp,topic,value +1.010651803,1547062466.010664222,1547062466.009726015,/topic1,1 +1.031882799,1547062466.031895218,1547062466.031719273,/topic1,1 +2.010651803,1547062466.010664222,1547062466.009726015,/topic2,2 +2.031882799,1547062466.031895218,1547062466.031719273,/topic2,2 +3.010651803,1547062466.010664222,1547062466.009726015,/topic3,3 +3.031882799,1547062466.031895218,1547062466.031719273,/topic3,3" +`; + +exports[`PlotMenu Multiple topics with different header.stamp 1`] = ` +"elapsed time,receive time,header.stamp,topic,value +1.010651803,1547062466.010664222,1547062467.009726015,/topic1,1 +1.031882799,1547062466.031895218,1547062467.031719273,/topic1,1 +2.010651803,1547062466.010664222,1547062468.009726015,/topic2,2 +2.031882799,1547062466.031895218,1547062468.031719273,/topic2,2 +3.010651803,1547062466.010664222,1547062469.009726015,/topic3,3 +3.031882799,1547062466.031895218,1547062469.031719273,/topic3,3" +`; + +exports[`PlotMenu Multiple topics with one topic don't have header.stamp 1`] = ` +"elapsed time,receive time,header.stamp,topic,value +1.010651803,1547062466.010664222,1547062466.009726015,/topic1,1 +1.031882799,1547062466.031895218,1547062466.031719273,/topic1,1 +2.010651803,1547062466.010664222,1547062466.009726015,/topic2,2 +2.031882799,1547062466.031895218,1547062466.031719273,/topic2,2 +3.010651803,1547062466.010664222,,/topic3,3 +3.031882799,1547062466.031895218,,/topic3,3" +`; + +exports[`PlotMenu Single topic 1`] = ` +"elapsed time,receive time,header.stamp,topic,value +0.010651803000000001,1547062466.010664222,1547062466.009726015,/accel_vector_calibrated.using_gps_time,0 +0.031882799,1547062466.031895218,1547062466.031719273,/accel_vector_calibrated.using_gps_time,0" +`; diff --git a/packages/webviz-core/src/panels/Plot/index.js b/packages/webviz-core/src/panels/Plot/index.js index ba4fbb7a3..45a278024 100644 --- a/packages/webviz-core/src/panels/Plot/index.js +++ b/packages/webviz-core/src/panels/Plot/index.js @@ -11,10 +11,11 @@ import { hot } from "react-hot-loader/root"; import helpContent from "./index.help.md"; import Flex from "webviz-core/src/components/Flex"; +import MessageHistory, { type MessageHistoryData } from "webviz-core/src/components/MessageHistory"; import Panel from "webviz-core/src/components/Panel"; import PanelToolbar from "webviz-core/src/components/PanelToolbar"; import type { PlotPath } from "webviz-core/src/panels/Plot/internalTypes"; -import PlotChart from "webviz-core/src/panels/Plot/PlotChart"; +import PlotChart, { getDatasets } from "webviz-core/src/panels/Plot/PlotChart"; import PlotLegend from "webviz-core/src/panels/Plot/PlotLegend"; import PlotMenu from "webviz-core/src/panels/Plot/PlotMenu"; @@ -30,6 +31,8 @@ export const plotableRosTypes = [ "uint64", "float32", "float64", + "time", + "duration", ]; export type PlotConfig = { @@ -81,24 +84,37 @@ class Plot extends PureComponent { return ( - - } - /> - + {/* Don't filter out disabled paths when passing into , because we still want + easy access to the history when turning the disabled paths back on. */} + path.value)}> + {({ itemsByPath, startTime }: MessageHistoryData) => { + const datasets = getDatasets(paths, itemsByPath, startTime); + return ( + <> + + } + /> + + + ); + }} + ); diff --git a/packages/webviz-core/src/panels/Plot/index.stories.js b/packages/webviz-core/src/panels/Plot/index.stories.js index e770e6a2b..59b9d20ce 100644 --- a/packages/webviz-core/src/panels/Plot/index.stories.js +++ b/packages/webviz-core/src/panels/Plot/index.stories.js @@ -11,130 +11,130 @@ import * as React from "react"; import { withScreenshot } from "storybook-chrome-screenshot"; import Plot from "webviz-core/src/panels/Plot"; -import PanelSetup from "webviz-core/src/stories/PanelSetup"; +import PanelSetup, { triggerWheel } from "webviz-core/src/stories/PanelSetup"; const locationMessages = [ { - header: { stamp: { sec: 1539, nsec: 574635076 } }, + header: { stamp: { sec: 0, nsec: 574635076 } }, pose: { acceleration: -0.00116662939, velocity: 1.184182664 }, }, { - header: { stamp: { sec: 1539, nsec: 673758203 } }, + header: { stamp: { sec: 0, nsec: 673758203 } }, pose: { acceleration: -0.0072101709, velocity: 1.182555127 }, }, { - header: { stamp: { sec: 1539, nsec: 770527187 } }, + header: { stamp: { sec: 0, nsec: 770527187 } }, pose: { acceleration: 0.0079536558, velocity: 1.185625054 }, }, { - header: { stamp: { sec: 1539, nsec: 871076484 } }, + header: { stamp: { sec: 0, nsec: 871076484 } }, pose: { acceleration: 0.037758707, velocity: 1.193871954 }, }, { - header: { stamp: { sec: 1539, nsec: 995802312 } }, + header: { stamp: { sec: 0, nsec: 995802312 } }, pose: { acceleration: 0.085267948, velocity: 1.210280466 }, }, { - header: { stamp: { sec: 1540, nsec: 81700551 } }, + header: { stamp: { sec: 1, nsec: 81700551 } }, pose: { acceleration: 0.34490595, velocity: 1.28371423 }, }, { - header: { stamp: { sec: 1540, nsec: 184463111 } }, + header: { stamp: { sec: 1, nsec: 184463111 } }, pose: { acceleration: 0.59131456, velocity: 1.379807198 }, }, { - header: { stamp: { sec: 1540, nsec: 285808851 } }, + header: { stamp: { sec: 1, nsec: 285808851 } }, pose: { acceleration: 0.78738064, velocity: 1.487955727 }, }, { - header: { stamp: { sec: 1540, nsec: 371183619 } }, + header: { stamp: { sec: 1, nsec: 371183619 } }, pose: { acceleration: 0.91150866, velocity: 1.581979428 }, }, { - header: { stamp: { sec: 1540, nsec: 479369260 } }, + header: { stamp: { sec: 1, nsec: 479369260 } }, pose: { acceleration: 1.03091162, velocity: 1.70297429 }, }, { - header: { stamp: { sec: 1540, nsec: 587095370 } }, + header: { stamp: { sec: 1, nsec: 587095370 } }, pose: { acceleration: 1.15341371, velocity: 1.857311045 }, }, { - header: { stamp: { sec: 1540, nsec: 685730694 } }, + header: { stamp: { sec: 1, nsec: 685730694 } }, pose: { acceleration: 1.06827219, velocity: 1.951372604 }, }, { - header: { stamp: { sec: 1540, nsec: 785737230 } }, + header: { stamp: { sec: 1, nsec: 785737230 } }, pose: { acceleration: 0.76826461, velocity: 1.98319952 }, }, { - header: { stamp: { sec: 1540, nsec: 869057829 } }, + header: { stamp: { sec: 1, nsec: 869057829 } }, pose: { acceleration: 0.52827271, velocity: 1.984654942 }, }, { - header: { stamp: { sec: 1540, nsec: 984145879 } }, + header: { stamp: { sec: 1, nsec: 984145879 } }, pose: { acceleration: 0.16827019, velocity: 1.958059206 }, }, { - header: { stamp: { sec: 1541, nsec: 85765716 } }, + header: { stamp: { sec: 2, nsec: 85765716 } }, pose: { acceleration: -0.13173667, velocity: 1.899877099 }, }, { - header: { stamp: { sec: 1541, nsec: 182717960 } }, + header: { stamp: { sec: 2, nsec: 182717960 } }, pose: { acceleration: -0.196482967, velocity: 1.87051731 }, }, { - header: { stamp: { sec: 1541, nsec: 286998440 } }, + header: { stamp: { sec: 2, nsec: 286998440 } }, pose: { acceleration: -0.204713665, velocity: 1.848811251 }, }, { - header: { stamp: { sec: 1541, nsec: 370689856 } }, + header: { stamp: { sec: 2, nsec: 370689856 } }, pose: { acceleration: -0.18596813, velocity: 1.837120153 }, }, { - header: { stamp: { sec: 1541, nsec: 483672422 } }, + header: { stamp: { sec: 2, nsec: 483672422 } }, pose: { acceleration: -0.13091373, velocity: 1.828568433 }, }, { - header: { stamp: { sec: 1541, nsec: 578787057 } }, + header: { stamp: { sec: 2, nsec: 578787057 } }, pose: { acceleration: -0.119039923, velocity: 1.82106361 }, }, { - header: { stamp: { sec: 1541, nsec: 677515597 } }, + header: { stamp: { sec: 2, nsec: 677515597 } }, pose: { acceleration: -0.419040352, velocity: 1.734159507 }, }, { - header: { stamp: { sec: 1541, nsec: 789110904 } }, + header: { stamp: { sec: 2, nsec: 789110904 } }, pose: { acceleration: -0.48790808, velocity: 1.666657974 }, }, ]; const otherStateMessages = [ { - header: { stamp: { sec: 1539, nsec: 574635076 } }, + header: { stamp: { sec: 0, nsec: 574635076 } }, items: [{ id: 42, speed: 0.1 }], }, { - header: { stamp: { sec: 1539, nsec: 871076484 } }, + header: { stamp: { sec: 0, nsec: 871076484 } }, items: [{ id: 42, speed: 0.2 }], }, { - header: { stamp: { sec: 1540, nsec: 81700551 } }, + header: { stamp: { sec: 1, nsec: 81700551 } }, items: [{ id: 42, speed: 0.3 }], }, { - header: { stamp: { sec: 1540, nsec: 479369260 } }, + header: { stamp: { sec: 1, nsec: 479369260 } }, items: [{ id: 10, speed: 1.4 }, { id: 42, speed: 0.2 }], }, { - header: { stamp: { sec: 1540, nsec: 785737230 } }, + header: { stamp: { sec: 1, nsec: 785737230 } }, items: [{ id: 10, speed: 1.5 }, { id: 42, speed: 0.1 }], }, { - header: { stamp: { sec: 1541, nsec: 182717960 } }, + header: { stamp: { sec: 2, nsec: 182717960 } }, items: [{ id: 10, speed: 1.57 }, { id: 42, speed: 0.08 }], }, { - header: { stamp: { sec: 1541, nsec: 578787057 } }, + header: { stamp: { sec: 2, nsec: 578787057 } }, items: [{ id: 10, speed: 1.63 }, { id: 42, speed: 0.06 }], }, ]; @@ -204,51 +204,71 @@ const fixture = { op: "message", datatype: "std_msgs/Bool", topic: "/boolean_topic", - receiveTime: { sec: 1540, nsec: 0 }, + receiveTime: { sec: 1, nsec: 0 }, message: { data: true }, }, ], }, }; +const paths = [ + { + value: "/some_topic/location.pose.velocity", + enabled: true, + timestampMethod: "receiveTime", + }, + { + value: "/some_topic/location.pose.acceleration", + enabled: true, + timestampMethod: "receiveTime", + }, + { + value: "/some_topic/location.pose.acceleration.@derivative", + enabled: true, + timestampMethod: "receiveTime", + }, + { + value: "/boolean_topic.data", + enabled: true, + timestampMethod: "receiveTime", + }, + { + value: "/some_topic/state.items[0].speed", + enabled: true, + timestampMethod: "receiveTime", + }, + { + value: "/some_topic/location.header.stamp", + enabled: true, + timestampMethod: "receiveTime", + }, +]; + storiesOf("", module) - .addDecorator(withScreenshot()) + .addDecorator(withScreenshot({ delay: 1000 })) .add("line graph", () => { return ( - + + + ); + }) + .add("line graph after zoom", () => { + return ( + { + setTimeout(() => { + const canvasEl = el.querySelector("canvas"); + // Zoom is a continuous event, so we need to simulate wheel multiple times + if (canvasEl) { + for (let i = 0; i < 10; i++) { + triggerWheel(canvasEl, 1); + } + } + }, 100); + }}> + ); }) diff --git a/packages/webviz-core/src/panels/SubscribeToList.js b/packages/webviz-core/src/panels/SubscribeToList.js new file mode 100644 index 000000000..bc02eac8f --- /dev/null +++ b/packages/webviz-core/src/panels/SubscribeToList.js @@ -0,0 +1,47 @@ +// @flow +// +// Copyright (c) 2018-present, GM Cruise LLC +// +// This source code is licensed under the Apache License, Version 2.0, +// found in the LICENSE file in the root directory of this source tree. +// You may not use this file except in compliance with the License. +import * as React from "react"; +import { hot } from "react-hot-loader/root"; + +import Flex from "webviz-core/src/components/Flex"; +import Panel from "webviz-core/src/components/Panel"; +import PanelToolbar from "webviz-core/src/components/PanelToolbar"; +import * as PanelAPI from "webviz-core/src/PanelAPI"; +import type { SaveConfig } from "webviz-core/src/types/panels"; + +// Little dummy panel that just subscribes to a bunch of topics. Doesn't actually +// do anything with them. + +type Config = { topics: string }; +type Props = { config: Config, saveConfig: SaveConfig }; + +function SubscribeToList({ config, saveConfig }: Props): React.Node { + const topics = config.topics.split(/\s*(?:\n|,|\s)\s*/); + const { reducedValue: messagesSeen } = PanelAPI.useMessages({ + topics, + restore: React.useCallback(() => 0, []), + addMessage: React.useCallback((seenBefore) => seenBefore + 1, []), + }); + return ( + + +