Skip to content

Commit 243cd0f

Browse files
gggritsoclaude
andcommitted
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 <noreply@anthropic.com>
1 parent ff568d2 commit 243cd0f

File tree

4 files changed

+65
-38
lines changed

4 files changed

+65
-38
lines changed

static/app/views/dashboards/widgetCard/index.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ import {
5050
import {widgetCanUseTimeSeriesVisualization} from 'sentry/views/dashboards/utils/widgetCanUseTimeSeriesVisualization';
5151
import {WidgetCardChartContainer} from 'sentry/views/dashboards/widgetCard/widgetCardChartContainer';
5252
import type {WidgetLegendSelectionState} from 'sentry/views/dashboards/widgetLegendSelectionState';
53-
import type {TabularColumn} from 'sentry/views/dashboards/widgets/common/types';
53+
import type {
54+
LegendSelection,
55+
TabularColumn,
56+
} from 'sentry/views/dashboards/widgets/common/types';
5457
import {Widget} from 'sentry/views/dashboards/widgets/widget/widget';
5558
import {useLLMContext} from 'sentry/views/seerExplorer/contexts/llmContext';
5659
import {registerLLMContext} from 'sentry/views/seerExplorer/contexts/registerLLMContext';
@@ -356,6 +359,19 @@ function WidgetCard(props: Props) {
356359

357360
const canUseTimeseriesVisualization = widgetCanUseTimeSeriesVisualization(widget);
358361
if (canUseTimeseriesVisualization) {
362+
// Only pass legend selection when there's explicit URL state.
363+
// getWidgetSelectionState returns a default that hides Releases for
364+
// LINE/AREA widgets, which was appropriate for the old overlay-line
365+
// rendering but not for the new bubble markers.
366+
const legendSelectionForWidget = location.query.unselectedSeries
367+
? widgetLegendState.getWidgetSelectionState(widget)
368+
: undefined;
369+
370+
const handleLegendSelectionChange = (legendState: LegendSelection) => {
371+
widgetLegendState.setWidgetSelectionState(legendState, widget);
372+
onLegendSelectChanged?.();
373+
};
374+
359375
return (
360376
<ErrorBoundary customComponent={errorBoundaryHandler}>
361377
<VisuallyCompleteWithData
@@ -390,6 +406,8 @@ function WidgetCard(props: Props) {
390406
tableItemLimit={tableItemLimit}
391407
widgetInterval={widgetInterval}
392408
showConfidenceWarning={showConfidenceWarning}
409+
legendSelection={legendSelectionForWidget}
410+
onLegendSelectionChange={handleLegendSelectionChange}
393411
/>
394412
</WidgetFrame>
395413
</VisuallyCompleteWithData>

static/app/views/dashboards/widgetCard/visualizationWidget.tsx

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
import {getChartType} from 'sentry/views/dashboards/utils/getWidgetExploreUrl';
3232
import {matchTimeSeriesToTableRowValue} from 'sentry/views/dashboards/widgetCard/matchTimeSeriesToTableRowValue';
3333
import {transformWidgetSeriesToTimeSeries} from 'sentry/views/dashboards/widgetCard/transformWidgetSeriesToTimeSeries';
34+
import {WidgetLegendNameEncoderDecoder} from 'sentry/views/dashboards/widgetLegendNameEncoderDecoder';
3435
import {
3536
MISSING_DATA_MESSAGE,
3637
NUMBER_MIN_VALUE,
@@ -103,6 +104,20 @@ export function VisualizationWidget({
103104
}: VisualizationWidgetProps) {
104105
const onWidgetError = useWidgetErrorCallback();
105106

107+
// WidgetLegendSelectionState persists legend selection to the URL with
108+
// keys in `seriesName|~|widgetId` format so each widget's selection is
109+
// tracked independently. TimeSeriesWidgetVisualization uses plain
110+
// series names. Decode on the way in and encode on the way out.
111+
const decodedLegendSelection = legendSelection
112+
? decodeLegendSelection(legendSelection)
113+
: undefined;
114+
115+
const handleLegendSelectionChange = onLegendSelectionChange
116+
? (plain: LegendSelection) => {
117+
onLegendSelectionChange(encodeLegendSelection(plain, widget.id));
118+
}
119+
: undefined;
120+
106121
const {releases: releasesWithDate} = useReleaseStats(selection, {
107122
enabled: showReleaseAs !== 'none',
108123
});
@@ -157,8 +172,8 @@ export function VisualizationWidget({
157172
isSampled={isSampled}
158173
sampleCount={sampleCount}
159174
onZoom={onZoom}
160-
legendSelection={legendSelection}
161-
onLegendSelectionChange={onLegendSelectionChange}
175+
legendSelection={decodedLegendSelection}
176+
onLegendSelectionChange={handleLegendSelectionChange}
162177
isFullScreen={isFullScreen}
163178
/>
164179
);
@@ -500,3 +515,32 @@ function renderBreakdownLabel(
500515

501516
return fallbackLabel;
502517
}
518+
519+
/**
520+
* Decodes legend selection keys from `seriesName|~|widgetId` format to
521+
* plain `seriesName` keys used by `TimeSeriesWidgetVisualization`.
522+
*/
523+
function decodeLegendSelection(encoded: LegendSelection): LegendSelection {
524+
const decoded: LegendSelection = {};
525+
for (const key in encoded) {
526+
decoded[WidgetLegendNameEncoderDecoder.decodeSeriesNameForLegend(key, true)] =
527+
encoded[key]!;
528+
}
529+
return decoded;
530+
}
531+
532+
/**
533+
* Encodes legend selection keys from plain `seriesName` format back to
534+
* `seriesName|~|widgetId` format used by `WidgetLegendSelectionState`.
535+
*/
536+
function encodeLegendSelection(
537+
plain: LegendSelection,
538+
widgetId: string | undefined
539+
): LegendSelection {
540+
const encoded: LegendSelection = {};
541+
for (const key in plain) {
542+
encoded[WidgetLegendNameEncoderDecoder.encodeSeriesNameForLegend(key, widgetId)] =
543+
plain[key]!;
544+
}
545+
return encoded;
546+
}

static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesLabel.spec.tsx

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,6 @@ import {TimeSeriesFixture} from 'sentry-fixture/timeSeries';
33
import {formatTimeSeriesLabel} from './formatTimeSeriesLabel';
44

55
describe('formatSeriesName', () => {
6-
describe('releases', () => {
7-
it.each([
8-
['p75(span.duration)|~|11762', 'p75(span.duration)'],
9-
['Releases|~|', 'Releases'],
10-
])('Formats %s as %s', (name, result) => {
11-
const timeSeries = TimeSeriesFixture({
12-
yAxis: name,
13-
});
14-
15-
expect(formatTimeSeriesLabel(timeSeries)).toEqual(result);
16-
});
17-
});
18-
196
describe('aggregates', () => {
207
it.each([
218
['user_misery()', 'user_misery()'],
@@ -70,26 +57,8 @@ describe('formatSeriesName', () => {
7057
});
7158
});
7259

73-
describe('combinations', () => {
74-
it.each([
75-
['equation|p75(measurements.cls) + 1|~|76123', 'p75(measurements.cls) + 1'],
76-
['equation|p75(measurements.cls)|~|76123', 'p75(measurements.cls)'],
77-
])('Formats %s as %s', (name, result) => {
78-
const timeSeries = TimeSeriesFixture({
79-
yAxis: name,
80-
});
81-
82-
expect(formatTimeSeriesLabel(timeSeries)).toEqual(result);
83-
});
84-
});
85-
8660
describe('groupBy', () => {
8761
it.each([
88-
[
89-
'equation|p75(measurements.cls)|~|76123',
90-
[{key: 'release', value: 'v0.0.2'}],
91-
'v0.0.2',
92-
],
9362
['p95(span.duration)', [{key: 'release', value: 'v0.0.2'}], 'v0.0.2'],
9463
['p95(span.duration)', [{key: 'gen_ai.request.model', value: null}], '(no value)'],
9564
[

static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesLabel.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {t} from 'sentry/locale';
22
import {maybeEquationAlias, stripEquationPrefix} from 'sentry/utils/discover/fields';
33
import {formatVersion} from 'sentry/utils/versions/formatVersion';
4-
import {WidgetLegendNameEncoderDecoder} from 'sentry/views/dashboards/widgetLegendNameEncoderDecoder';
54
import type {TimeSeries} from 'sentry/views/dashboards/widgets/common/types';
65

76
export function formatTimeSeriesLabel(timeSeries: TimeSeries): string {
@@ -34,9 +33,6 @@ export function formatTimeSeriesLabel(timeSeries: TimeSeries): string {
3433

3534
let {yAxis: seriesName} = timeSeries;
3635

37-
// Decode from series name disambiguation
38-
seriesName = WidgetLegendNameEncoderDecoder.decodeSeriesNameForLegend(seriesName)!;
39-
4036
// Attempt to parse the `seriesName` as a version. A correct `TimeSeries`
4137
// would have a `yAxis` like `p50(span.duration)` with a `groupBy` like
4238
// `[{key: "release", value: "proj@1.2.3"}]`. `groupBy` was only introduced

0 commit comments

Comments
 (0)