Repository: getsentry/sentry
Pull request #87621: feat(releases): Highlight release line in chart when hovering over row in table
Author: login: billyvg
Labels: name: Scope: Frontend
description: Automatically applied to PRs that change frontend components
Created: 2025-03-21T19:53:13Z | Merged: 2025-03-27T19:06:54Z

In releases drawer list, when user hovers over a release in the table -- highlight the corresponding release in the chart.


https://github.com/user-attachments/assets/a8837fd8-47aa-4966-9839-1c56dc7fb373




Diff:
diff --git a/static/app/components/charts/releaseSeries.tsx b/static/app/components/charts/releaseSeries.tsx
index c07eee5c84d27f..2e4ba145eec0f9 100644
--- a/static/app/components/charts/releaseSeries.tsx
+++ b/static/app/components/charts/releaseSeries.tsx
@@ -308,6 +308,7 @@ class ReleaseSeries extends Component<ReleaseSeriesProps, State> {
     });
 
     return {
+      id: 'release-lines',
       seriesName: 'Releases',
       color: theme.purple200,
       data: [],
diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/releaseSeries.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/releaseSeries.tsx
index dfe2693a665cd0..0f0f471692d044 100644
--- a/static/app/views/dashboards/widgets/timeSeriesWidget/releaseSeries.tsx
+++ b/static/app/views/dashboards/widgets/timeSeriesWidget/releaseSeries.tsx
@@ -17,6 +17,7 @@ export function ReleaseSeries(
 ): CustomSeriesOption {
   return {
     type: 'custom',
+    id: 'release-lines',
     name: t('Releases'),
     renderItem: () => null,
     color: theme.purple200,
diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx
index aa4df4a6e35dd0..500246524d0db5 100644
--- a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx
+++ b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx
@@ -2,6 +2,7 @@ import {useCallback, useRef} from 'react';
 import {useNavigate} from 'react-router-dom';
 import {useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
+import {mergeRefs} from '@react-aria/utils';
 import * as Sentry from '@sentry/react';
 import type {SeriesOption, YAXisComponentOption} from 'echarts';
 import type {
@@ -66,10 +67,14 @@ export interface TimeSeriesWidgetVisualizationProps {
    * Callback that returns an updated ECharts zoom selection. If omitted, the default behavior is to update the URL with updated `start` and `end` query parameters.
    */
   onZoom?: EChartDataZoomHandler;
+
+  ref?: React.Ref<ReactEchartsRef>;
+
   /**
    * Array of `Release` objects. If provided, they are plotted on line and area visualizations as vertical lines
    */
   releases?: Release[];
+
   /**
    * Show releases as either lines per release or a bubble for a group of releases.
    */
@@ -112,11 +117,12 @@ export function TimeSeriesWidgetVisualization(props: TimeSeriesWidgetVisualizati
     releaseBubbleXAxis,
     releaseBubbleGrid,
   } = useReleaseBubbles({
-    chartRenderer: ({start: trimStart, end: trimEnd}) => {
+    chartRenderer: ({start: trimStart, end: trimEnd, ref: chartRendererRef}) => {
       return (
         <DrawerWidgetWrapper>
           <TimeSeriesWidgetVisualization
             {...props}
+            ref={chartRendererRef}
             disableReleaseNavigation
             plottables={props.plottables.map(plottable =>
               plottable.constrain(trimStart, trimEnd)
@@ -157,9 +163,7 @@ export function TimeSeriesWidgetVisualization(props: TimeSeriesWidgetVisualizati
   const hasReleaseBubblesSeries = hasReleaseBubbles && releaseSeries;
 
   const handleChartRef = useCallback(
-    (e: ReactEchartsRef) => {
-      chartRef.current = e;
-
+    (e: ReactEchartsRef | null) => {
       if (!e?.getEchartsInstance) {
         return;
       }
@@ -449,7 +453,7 @@ export function TimeSeriesWidgetVisualization(props: TimeSeriesWidgetVisualizati
 
   return (
     <BaseChart
-      ref={handleChartRef}
+      ref={mergeRefs(props.ref, chartRef, handleChartRef)}
       {...releaseBubbleEventHandlers}
       autoHeightResize
       series={[...series, releaseSeries].filter(defined)}
diff --git a/static/app/views/issueDetails/streamline/eventGraph.tsx b/static/app/views/issueDetails/streamline/eventGraph.tsx
index 510db9a6203ec6..52b9ca88e7f9ff 100644
--- a/static/app/views/issueDetails/streamline/eventGraph.tsx
+++ b/static/app/views/issueDetails/streamline/eventGraph.tsx
@@ -266,7 +266,7 @@ export function EventGraph({
     releaseBubbleXAxis,
     releaseBubbleGrid,
   } = useReleaseBubbles({
-    chartRenderer: ({chartRef}) => {
+    chartRenderer: ({ref: chartRef}) => {
       return (
         <EventGraph
           ref={chartRef}
diff --git a/static/app/views/issueDetails/streamline/hooks/useReleaseMarkLineSeries.tsx b/static/app/views/issueDetails/streamline/hooks/useReleaseMarkLineSeries.tsx
index cfa395b6e7929d..7f9eea9ea9041e 100644
--- a/static/app/views/issueDetails/streamline/hooks/useReleaseMarkLineSeries.tsx
+++ b/static/app/views/issueDetails/streamline/hooks/useReleaseMarkLineSeries.tsx
@@ -77,6 +77,7 @@ export function useReleaseMarkLineSeries({
   });
 
   return {
+    id: 'release-lines',
     seriesName: t('Releases'),
     data: [],
     markLine,
diff --git a/static/app/views/releases/drawer/releasesDrawerList.tsx b/static/app/views/releases/drawer/releasesDrawerList.tsx
index f6e1ec471e2a0f..fe71de8c135189 100644
--- a/static/app/views/releases/drawer/releasesDrawerList.tsx
+++ b/static/app/views/releases/drawer/releasesDrawerList.tsx
@@ -1,11 +1,15 @@
-import {Fragment, type ReactElement} from 'react';
+import {Fragment, type ReactElement, useCallback, useRef} from 'react';
 import styled from '@emotion/styled';
+import type {SeriesOption} from 'echarts';
+import type {MarkLineOption} from 'echarts/types/dist/shared';
+import type {EChartsInstance} from 'echarts-for-react';
 
 import {DateTime} from 'sentry/components/dateTime';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
-import type {ReactEchartsRef} from 'sentry/types/echarts';
+import type {ReactEchartsRef, SeriesDataUnit} from 'sentry/types/echarts';
 import type {ReleaseMetaBasic} from 'sentry/types/release';
+import {formatVersion} from 'sentry/utils/versions/formatVersion';
 import {Widget} from 'sentry/views/dashboards/widgets/widget/widget';
 import type {
   Bucket,
@@ -43,6 +47,55 @@ interface ReleasesDrawerListProps {
   chartRenderer?: (rendererProps: ChartRendererProps) => ReactElement;
 }
 
+type MarkLineDataCallbackFn = (item: SeriesDataUnit) => boolean;
+
+function createMarkLineUpdater(lineStyle: Partial<MarkLineOption['lineStyle']>) {
+  return (
+    echartsInstance: EChartsInstance,
+    seriesId: string,
+    callbackFn: MarkLineDataCallbackFn
+  ) => {
+    const opts = echartsInstance.getOption();
+    const series = (opts.series as SeriesOption[]).find(({id}) => id === seriesId);
+
+    // We need to return all markLines (I could not get merges working on it,
+    // even when I added the release version as id), otherwise the other lines
+    // will be removed.
+    const updatedData = series?.markLine.data.map((d: SeriesDataUnit) => {
+      // Update the style of the lines that is currently being hovered over so
+      // that it is more visible than other lines on the chart
+      if (callbackFn(d)) {
+        return {
+          ...d,
+          lineStyle,
+        };
+      }
+
+      return d;
+    });
+
+    echartsInstance.setOption({
+      series: {
+        id: seriesId,
+        markLine: {
+          data: updatedData,
+        },
+      },
+    });
+  };
+}
+
+/**
+ * Find markLine(s) to highlight
+ * Note: We can't use ECharts `highlight` event because it only works for series
+ * (not markLines)
+ */
+const highlightMarkLines = createMarkLineUpdater({width: 2, opacity: 1});
+/**
+ * Unhighlight all markLines
+ */
+const unhighlightMarkLines = createMarkLineUpdater({});
+
 /**
  * Renders the a chart + releases table for use in the Global Drawer.
  * Allows users to view releases of a specific timebucket.
@@ -58,6 +111,31 @@ export function ReleasesDrawerList({
 }: ReleasesDrawerListProps) {
   const start = new Date(startTs);
   const end = new Date(endTs);
+  const chartRef = useRef<ReactEchartsRef | null>(null);
+
+  const handleMouseOverRelease = useCallback((release: string) => {
+    if (!chartRef.current) {
+      return;
+    }
+
+    highlightMarkLines(
+      chartRef.current.getEchartsInstance(),
+      'release-lines',
+      (d: SeriesDataUnit) => d.name === formatVersion(release, true)
+    );
+  }, []);
+
+  const handleMouseOutRelease = useCallback((release: string) => {
+    if (!chartRef.current) {
+      return;
+    }
+
+    unhighlightMarkLines(
+      chartRef.current.getEchartsInstance(),
+      'release-lines',
+      (d: SeriesDataUnit) => d.name === formatVersion(release, true)
+    );
+  }, []);
 
   return (
     <Fragment>
@@ -71,7 +149,9 @@ export function ReleasesDrawerList({
               </Fragment>
             }
             Visualization={chartRenderer?.({
-              chartRef: (e: ReactEchartsRef | null) => {
+              ref: (e: ReactEchartsRef | null) => {
+                chartRef.current = e;
+
                 if (e) {
                   // When chart is mounted, zoom the chart into the relevant
                   // bucket
@@ -101,6 +181,8 @@ export function ReleasesDrawerList({
         start={start.toISOString()}
         end={end.toISOString()}
         onSelectRelease={onSelectRelease}
+        onMouseOverRelease={handleMouseOverRelease}
+        onMouseOutRelease={handleMouseOutRelease}
       />
     </Fragment>
   );
diff --git a/static/app/views/releases/drawer/releasesDrawerTable.tsx b/static/app/views/releases/drawer/releasesDrawerTable.tsx
index c828ca122223cc..e4c00ba924e42c 100644
--- a/static/app/views/releases/drawer/releasesDrawerTable.tsx
+++ b/static/app/views/releases/drawer/releasesDrawerTable.tsx
@@ -36,6 +36,8 @@ type ReleaseHealthItem = {
 interface Props {
   end: string;
   environments: readonly string[];
+  onMouseOutRelease: (release: string) => void;
+  onMouseOverRelease: (release: string) => void;
   onSelectRelease: (release: string, projectId: string) => void;
   projects: readonly number[];
   start: string;
@@ -61,6 +63,8 @@ export function ReleaseDrawerTable({
   environments,
   projects,
   start,
+  onMouseOverRelease,
+  onMouseOutRelease,
   onSelectRelease,
 }: Props) {
   const location = useLocation();
@@ -133,6 +137,12 @@ export function ReleaseDrawerTable({
         return (
           <ReleaseLink
             to="#"
+            onMouseOver={() => {
+              onMouseOverRelease(dataRow.release);
+            }}
+            onMouseOut={() => {
+              onMouseOutRelease(dataRow.release);
+            }}
             onClick={e => {
               e.preventDefault();
               onSelectRelease(String(value), String(dataRow.project_id));
@@ -178,7 +188,7 @@ export function ReleaseDrawerTable({
         </CellWrapper>
       );
     },
-    [organization, location, onSelectRelease]
+    [organization, location, onSelectRelease, onMouseOutRelease, onMouseOverRelease]
   );
 
   const tableEmptyMessage = (
diff --git a/static/app/views/releases/releaseBubbles/types.tsx b/static/app/views/releases/releaseBubbles/types.tsx
index 0381018aa2ab06..d88451cc32203a 100644
--- a/static/app/views/releases/releaseBubbles/types.tsx
+++ b/static/app/views/releases/releaseBubbles/types.tsx
@@ -1,20 +1,16 @@
-import type {MutableRefObject} from 'react';
-
 import type {ReactEchartsRef} from 'sentry/types/echarts';
 import type {ReleaseMetaBasic} from 'sentry/types/release';
 
 export interface ChartRendererProps {
-  /**
-   * This needs to be passed as a `ref` to the chart being rendered. The chart
-   * needs to forward the ref to ECharts component.
-   */
-  chartRef:
-    | MutableRefObject<ReactEchartsRef | null>
-    | ((e: ReactEchartsRef | null) => void);
   /**
    * The ending Date object of the release group to render
    */
   end: Date;
+  /**
+   * This needs to be passed as a `ref` to the chart being rendered. The chart
+   * needs to forward the ref to ECharts component.
+   */
+  ref: React.Ref<ReactEchartsRef>;
   /**
    * The list of releases in the current release group to render
    */


Reviews:
user: login: billyvg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-21T19:56:14Z
user: login: gggritso
body: 🤔 I'm good with this, but I'm trying to think ahead to more people implementing stuff like this.

What do you think about [useImperativeHandle](https://react.dev/reference/react/useImperativeHandle) for the ref instead? It'd be nice if the ref had methods like `highlightRelease` and `unhighlightRelease` that the caller could use without having to know how to highlighting works. We could add more handles, too, for highlighting series, and so on. That way other users of the `ref` couldn't create any weird custom styling for highlighted items
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T15:48:57Z
user: login: gggritso
body: 👍🏻 makes sense! Some other teams also wanted access to the `ref` so this'll help them out.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-26T14:04:03Z

Review comments:
diff_hunk: @@ -54,6 +54,9 @@ export interface TimeSeriesWidgetVisualizationProps {
    * TODO(billy): temporary until we implement route based nav
    */
   disableReleaseNavigation?: boolean;
+  forwardedRef?:
+    | MutableRefObject<ReactEchartsRef | null>
+    | ((e: ReactEchartsRef | null) => void);
path: static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx
user: login: billyvg
body: We can change this to `ref` in react 19
created_at: 2025-03-21T19:55:50Z
author_association: MEMBER
diff_hunk: @@ -54,6 +66,75 @@ export function ReleasesDrawerList({
 }: ReleasesDrawerListProps) {
   const start = new Date(startTs);
   const end = new Date(endTs);
+  const chartRef = useRef<ReactEchartsRef | null>(null);
+
+  const handleMouseOverRelease = useCallback((release: string) => {
+    if (!chartRef.current) {
+      return;
+    }
+
+    const echartsInstance = chartRef.current.getEchartsInstance();
+    const opts = echartsInstance.getOption();
+    const releaseSeries = (opts.series as SeriesOption[]).find(
+      ({id}) => id === 'release-lines'
+    );
+    const updatedData = releaseSeries?.markLine.data.map((d: SeriesDataUnit) => {
+      // Update the style of the series that is
+      // currently being hovered over so that it
+      // is more visible than other series on the
+      // chart
+      if (d.name === formatVersion(release, true)) {
+        return {
+          ...d,
+          lineStyle: {
+            width: 2,
+            opacity: 1,
+          },
+        };
+      }
+      return d;
+    });
+    echartsInstance.setOption({
+      series: {
+        id: 'release-lines',
+        markLine: {
+          data: updatedData,
+        },
+      },
+    });
path: static/app/views/releases/drawer/releasesDrawerList.tsx
user: login: gggritso
body: No way to call the highlight action on a Markline, eh? Bummer!
created_at: 2025-03-24T15:45:31Z
author_association: MEMBER

PR comments:
user: login: billyvg
created_at: 2025-03-25T15:58:54Z
body: > 🤔 I'm good with this, but I'm trying to think ahead to more people implementing stuff like this.
> 
> What do you think about [useImperativeHandle](https://react.dev/reference/react/useImperativeHandle) for the ref instead? It'd be nice if the ref had methods like `highlightRelease` and `unhighlightRelease` that the caller could use without having to know how to highlighting works. We could add more handles, too, for highlighting series, and so on. That way other users of the `ref` couldn't create any weird custom styling for highlighted items

@gggritso Were you thinking exposing the methods on TimeSeriesWidgetVisualization or BaseCharts?

author_association: MEMBER
user: login: gggritso
created_at: 2025-03-25T16:27:56Z
body: @billyvg dang, good question. I'm imagining that `BaseChart` exposes a plain `ref` and `TimeSeriesWidgetVisualization` exposes the imperative handle with the methods on it, since the time series visualization is where the fancy release handling is happening
author_association: MEMBER
user: login: billyvg
created_at: 2025-03-25T20:02:09Z
body: > @billyvg dang, good question. I'm imagining that `BaseChart` exposes a plain `ref` and `TimeSeriesWidgetVisualization` exposes the imperative handle with the methods on it, since the time series visualization is where the fancy release handling is happening

@gggritso unfortunately, fancy release handling will also appear in `eventGraph.tsx` which renders a `<BarChart>` 😬 
author_association: MEMBER
user: login: gggritso
created_at: 2025-03-26T14:03:23Z
body: @billyvg dang again. Well, exposing an imperative handle is just a quick call to a hook, right? It should be pretty easy for both `BarChart` and `TimeSeriesWidgetVisualization` to expose an imperative handle, while `BaseChart` exposes a plain ref. In theory, anyway.

If that's not much trouble IMO it's worth it! If it's really annoying, don't bother and I'll come back to this in a little bit and try to move more of the behaviour into the charts.
author_association: MEMBER
user: login: billyvg
created_at: 2025-03-26T23:33:56Z
body: @gggritso After thinking about it more, I don't think that we should have *release* specific APIs exposed because (in theory) all release series will be in the new bubble form, and only inside the drawer will we have the lines to highlight (if we keep them at all).

However, I do agree that it would be nice to expose APIs in the chart's ref handles so I've [refactored into these cute lil' functions](https://github.com/getsentry/sentry/pull/87621/files#diff-b4cafa3a7a07668ef808d5712280fc4980f176e360d11a45a3a522933d9b81d2R60-R106) for you to later move and use as you see fit. You have a broader view of how these charts are used, so I'll leave it to you 🫡 
author_association: MEMBER
user: login: gggritso
created_at: 2025-03-27T14:56:17Z
body: @billyvg roger, thanks!
author_association: MEMBER