From 243cd0f5dba0bb4db6619a31174977326f3a40ce Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:24:18 -0400 Subject: [PATCH] fix(dashboards): Persist legend selection to URL for new chart widgets New TimeSeriesWidgetVisualization charts in dashboards did not update the URL with legend selection state, so toggling series visibility was lost on page refresh. Wire widgetLegendState to VisualizationWidget and disambiguate plottable names with widget ID for the shared ECharts chart group. Refs BROWSE-199 Co-Authored-By: Claude --- .../app/views/dashboards/widgetCard/index.tsx | 20 +++++++- .../widgetCard/visualizationWidget.tsx | 48 ++++++++++++++++++- .../formatters/formatTimeSeriesLabel.spec.tsx | 31 ------------ .../formatters/formatTimeSeriesLabel.tsx | 4 -- 4 files changed, 65 insertions(+), 38 deletions(-) diff --git a/static/app/views/dashboards/widgetCard/index.tsx b/static/app/views/dashboards/widgetCard/index.tsx index 9e7286db4b7319..ceeb1daab9b590 100644 --- a/static/app/views/dashboards/widgetCard/index.tsx +++ b/static/app/views/dashboards/widgetCard/index.tsx @@ -50,7 +50,10 @@ import { import {widgetCanUseTimeSeriesVisualization} from 'sentry/views/dashboards/utils/widgetCanUseTimeSeriesVisualization'; import {WidgetCardChartContainer} from 'sentry/views/dashboards/widgetCard/widgetCardChartContainer'; import type {WidgetLegendSelectionState} from 'sentry/views/dashboards/widgetLegendSelectionState'; -import type {TabularColumn} from 'sentry/views/dashboards/widgets/common/types'; +import type { + LegendSelection, + TabularColumn, +} from 'sentry/views/dashboards/widgets/common/types'; import {Widget} from 'sentry/views/dashboards/widgets/widget/widget'; import {useLLMContext} from 'sentry/views/seerExplorer/contexts/llmContext'; import {registerLLMContext} from 'sentry/views/seerExplorer/contexts/registerLLMContext'; @@ -356,6 +359,19 @@ function WidgetCard(props: Props) { const canUseTimeseriesVisualization = widgetCanUseTimeSeriesVisualization(widget); if (canUseTimeseriesVisualization) { + // Only pass legend selection when there's explicit URL state. + // getWidgetSelectionState returns a default that hides Releases for + // LINE/AREA widgets, which was appropriate for the old overlay-line + // rendering but not for the new bubble markers. + const legendSelectionForWidget = location.query.unselectedSeries + ? widgetLegendState.getWidgetSelectionState(widget) + : undefined; + + const handleLegendSelectionChange = (legendState: LegendSelection) => { + widgetLegendState.setWidgetSelectionState(legendState, widget); + onLegendSelectChanged?.(); + }; + return ( diff --git a/static/app/views/dashboards/widgetCard/visualizationWidget.tsx b/static/app/views/dashboards/widgetCard/visualizationWidget.tsx index 2f2bb523a60c2d..51b1d1758a060b 100644 --- a/static/app/views/dashboards/widgetCard/visualizationWidget.tsx +++ b/static/app/views/dashboards/widgetCard/visualizationWidget.tsx @@ -31,6 +31,7 @@ import { import {getChartType} from 'sentry/views/dashboards/utils/getWidgetExploreUrl'; import {matchTimeSeriesToTableRowValue} from 'sentry/views/dashboards/widgetCard/matchTimeSeriesToTableRowValue'; import {transformWidgetSeriesToTimeSeries} from 'sentry/views/dashboards/widgetCard/transformWidgetSeriesToTimeSeries'; +import {WidgetLegendNameEncoderDecoder} from 'sentry/views/dashboards/widgetLegendNameEncoderDecoder'; import { MISSING_DATA_MESSAGE, NUMBER_MIN_VALUE, @@ -103,6 +104,20 @@ export function VisualizationWidget({ }: VisualizationWidgetProps) { const onWidgetError = useWidgetErrorCallback(); + // WidgetLegendSelectionState persists legend selection to the URL with + // keys in `seriesName|~|widgetId` format so each widget's selection is + // tracked independently. TimeSeriesWidgetVisualization uses plain + // series names. Decode on the way in and encode on the way out. + const decodedLegendSelection = legendSelection + ? decodeLegendSelection(legendSelection) + : undefined; + + const handleLegendSelectionChange = onLegendSelectionChange + ? (plain: LegendSelection) => { + onLegendSelectionChange(encodeLegendSelection(plain, widget.id)); + } + : undefined; + const {releases: releasesWithDate} = useReleaseStats(selection, { enabled: showReleaseAs !== 'none', }); @@ -157,8 +172,8 @@ export function VisualizationWidget({ isSampled={isSampled} sampleCount={sampleCount} onZoom={onZoom} - legendSelection={legendSelection} - onLegendSelectionChange={onLegendSelectionChange} + legendSelection={decodedLegendSelection} + onLegendSelectionChange={handleLegendSelectionChange} isFullScreen={isFullScreen} /> ); @@ -500,3 +515,32 @@ function renderBreakdownLabel( return fallbackLabel; } + +/** + * Decodes legend selection keys from `seriesName|~|widgetId` format to + * plain `seriesName` keys used by `TimeSeriesWidgetVisualization`. + */ +function decodeLegendSelection(encoded: LegendSelection): LegendSelection { + const decoded: LegendSelection = {}; + for (const key in encoded) { + decoded[WidgetLegendNameEncoderDecoder.decodeSeriesNameForLegend(key, true)] = + encoded[key]!; + } + return decoded; +} + +/** + * Encodes legend selection keys from plain `seriesName` format back to + * `seriesName|~|widgetId` format used by `WidgetLegendSelectionState`. + */ +function encodeLegendSelection( + plain: LegendSelection, + widgetId: string | undefined +): LegendSelection { + const encoded: LegendSelection = {}; + for (const key in plain) { + encoded[WidgetLegendNameEncoderDecoder.encodeSeriesNameForLegend(key, widgetId)] = + plain[key]!; + } + return encoded; +} diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesLabel.spec.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesLabel.spec.tsx index 356eda1ece973a..cd8f14e756df1e 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesLabel.spec.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesLabel.spec.tsx @@ -3,19 +3,6 @@ import {TimeSeriesFixture} from 'sentry-fixture/timeSeries'; import {formatTimeSeriesLabel} from './formatTimeSeriesLabel'; describe('formatSeriesName', () => { - describe('releases', () => { - it.each([ - ['p75(span.duration)|~|11762', 'p75(span.duration)'], - ['Releases|~|', 'Releases'], - ])('Formats %s as %s', (name, result) => { - const timeSeries = TimeSeriesFixture({ - yAxis: name, - }); - - expect(formatTimeSeriesLabel(timeSeries)).toEqual(result); - }); - }); - describe('aggregates', () => { it.each([ ['user_misery()', 'user_misery()'], @@ -70,26 +57,8 @@ describe('formatSeriesName', () => { }); }); - describe('combinations', () => { - it.each([ - ['equation|p75(measurements.cls) + 1|~|76123', 'p75(measurements.cls) + 1'], - ['equation|p75(measurements.cls)|~|76123', 'p75(measurements.cls)'], - ])('Formats %s as %s', (name, result) => { - const timeSeries = TimeSeriesFixture({ - yAxis: name, - }); - - expect(formatTimeSeriesLabel(timeSeries)).toEqual(result); - }); - }); - describe('groupBy', () => { it.each([ - [ - 'equation|p75(measurements.cls)|~|76123', - [{key: 'release', value: 'v0.0.2'}], - 'v0.0.2', - ], ['p95(span.duration)', [{key: 'release', value: 'v0.0.2'}], 'v0.0.2'], ['p95(span.duration)', [{key: 'gen_ai.request.model', value: null}], '(no value)'], [ diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesLabel.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesLabel.tsx index 8897947291d8ed..fca3b9c7451d90 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesLabel.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesLabel.tsx @@ -1,7 +1,6 @@ import {t} from 'sentry/locale'; import {maybeEquationAlias, stripEquationPrefix} from 'sentry/utils/discover/fields'; import {formatVersion} from 'sentry/utils/versions/formatVersion'; -import {WidgetLegendNameEncoderDecoder} from 'sentry/views/dashboards/widgetLegendNameEncoderDecoder'; import type {TimeSeries} from 'sentry/views/dashboards/widgets/common/types'; export function formatTimeSeriesLabel(timeSeries: TimeSeries): string { @@ -34,9 +33,6 @@ export function formatTimeSeriesLabel(timeSeries: TimeSeries): string { let {yAxis: seriesName} = timeSeries; - // Decode from series name disambiguation - seriesName = WidgetLegendNameEncoderDecoder.decodeSeriesNameForLegend(seriesName)!; - // Attempt to parse the `seriesName` as a version. A correct `TimeSeries` // would have a `yAxis` like `p50(span.duration)` with a `groupBy` like // `[{key: "release", value: "proj@1.2.3"}]`. `groupBy` was only introduced