From ccf7c813fa87f5e62646a728ed7a99b2229d8761 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 13 Nov 2026 11:07:18 -0400 Subject: [PATCH 1/5] feat(charts): Support muted inline legend suffixes --- .../charts/chartLegend.snapshots.tsx | 25 +++++++++ .../components/charts/chartLegend.spec.tsx | 52 +++++++++++++++++++ static/app/components/charts/chartLegend.tsx | 32 ++++++++++-- 3 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 static/app/components/charts/chartLegend.snapshots.tsx diff --git a/static/app/components/charts/chartLegend.snapshots.tsx b/static/app/components/charts/chartLegend.snapshots.tsx new file mode 100644 index 000000000000..bcec2a8ef1ba --- /dev/null +++ b/static/app/components/charts/chartLegend.snapshots.tsx @@ -0,0 +1,25 @@ +import {Container} from '@sentry/scraps/layout'; + +import {ChartLegend} from './chartLegend'; + +describe('ChartLegend', () => { + it.snapshot.each([false, true])('transformed-long-label-%s', longLabel => ( + + {}} + /> + + )); +}); diff --git a/static/app/components/charts/chartLegend.spec.tsx b/static/app/components/charts/chartLegend.spec.tsx index 9f2fa25c99cd..8dee0318b048 100644 --- a/static/app/components/charts/chartLegend.spec.tsx +++ b/static/app/components/charts/chartLegend.spec.tsx @@ -65,6 +65,58 @@ describe('ChartLegend', () => { expect(container).toBeEmptyDOMElement(); }); + it('includes the suffix in accessible labels and preserves selection keys', async () => { + jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000); + const onSelectionChange = jest.fn(); + render( + + ); + + await userEvent.click( + screen.getByRole('button', {name: 'Toggle Duration · fill(linear) → SMA(5)'}) + ); + + expect(onSelectionChange).toHaveBeenCalledWith({'transformed-series': false}); + expect( + screen.getByRole('checkbox', {name: 'Duration · fill(linear) → SMA(5)'}) + ).toBeChecked(); + }); + + it('includes suffixes in overflow options', async () => { + jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(100); + jest + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockReturnValue({width: 80} as DOMRect); + const onSelectionChange = jest.fn(); + render( + + ); + + await userEvent.click(screen.getByRole('button', {name: '+2 more'})); + await userEvent.click(screen.getByRole('option', {name: 'Duration · SMA(5)'})); + + expect(onSelectionChange).toHaveBeenCalledWith({original: true, smoothed: false}); + }); + it('toggles item selection when clicked', async () => { mockDimensions(1000); const onSelectionChange = jest.fn(); diff --git a/static/app/components/charts/chartLegend.tsx b/static/app/components/charts/chartLegend.tsx index d57bbb4bf822..9275cfb5b50d 100644 --- a/static/app/components/charts/chartLegend.tsx +++ b/static/app/components/charts/chartLegend.tsx @@ -19,6 +19,7 @@ export interface LegendItem { color: string; label: string; name: string; + suffix?: string; } interface ChartLegendProps { @@ -156,7 +157,19 @@ export function ChartLegend({ // No `size` here, unlike the row: the menu sets its own option type, and // overriding it would leave this one menu's labels smaller than every // other one in the app. - label: truncateMenuLabels ? ( + label: item.suffix ? ( + + + {item.label} + + + · {item.suffix} + + + ) : truncateMenuLabels ? ( {item.label} @@ -165,7 +178,7 @@ export function ChartLegend({ ), // React-aria needs the plain string once the label stops being one, or // typeahead and screen readers lose the option's name. - textValue: item.label, + textValue: getLegendItemText(item), // Suppress the built-in checkmark; we render a custom LegendCheckbox via leadingItems hideCheck: true, leadingItems: renderLeadingCheckbox(item), @@ -216,18 +229,23 @@ export function ChartLegend({ }} onClick={() => toggleItem(item.name)} role="button" - aria-label={t('Toggle %s', item.label)} + aria-label={t('Toggle %s', getLegendItemText(item))} > {}} - aria-label={item.label} + aria-label={getLegendItemText(item)} /> {item.label} + {item.suffix && ( + + · {item.suffix} + + )} ))} @@ -260,6 +278,10 @@ export function ChartLegend({ ); } +function getLegendItemText(item: LegendItem): string { + return item.suffix ? `${item.label} · ${item.suffix}` : item.label; +} + /** * Renders a leading checkbox for a legend item inside the overflow dropdown. * Stateless — only depends on its arguments, so it lives outside the component. @@ -271,7 +293,7 @@ function renderLeadingCheckbox(item: LegendItem) { color={item.color} checked={isSelected} onChange={() => {}} - aria-label={item.label} + aria-label={getLegendItemText(item)} /> ); }; From af97fb73883efed4971ec797040aabf81b305639 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 13 Nov 2026 11:07:42 -0400 Subject: [PATCH 2/5] feat(charts): Show transformation suffixes in time series --- .../formatTimeSeriesTransformations.spec.tsx | 30 ++++++ .../formatTimeSeriesTransformations.tsx | 22 ++++ .../plottables/continuousTimeSeries.spec.tsx | 22 ++++ .../plottables/continuousTimeSeries.tsx | 5 + .../timeSeriesWidget/plottables/plottable.tsx | 1 + .../timeSeriesWidgetVisualization.spec.tsx | 102 ++++++++++++++++++ .../timeSeriesWidgetVisualization.tsx | 16 +-- 7 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.spec.tsx create mode 100644 static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.tsx create mode 100644 static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.spec.tsx diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.spec.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.spec.tsx new file mode 100644 index 000000000000..4dd31f7103be --- /dev/null +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.spec.tsx @@ -0,0 +1,30 @@ +import {formatTimeSeriesTransformations} from './formatTimeSeriesTransformations'; + +describe('formatTimeSeriesTransformations', () => { + it.each([ + {transformations: undefined, expected: ''}, + {transformations: [], expected: ''}, + {transformations: ['fill(zero)'], expected: 'fill(zero)'}, + {transformations: ['fill(locf)'], expected: 'fill(locf)'}, + {transformations: ['fill(linear)'], expected: 'fill(linear)'}, + {transformations: ['smooth(sma,5)'], expected: 'SMA(5)'}, + {transformations: ['smooth(ema,10)'], expected: 'EMA(10)'}, + {transformations: ['smooth(ema,10,0.25)'], expected: 'EMA(10,0.25)'}, + {transformations: ['smooth(asap)'], expected: 'ASAP'}, + {transformations: ['smooth(asap,5)'], expected: 'ASAP(5)'}, + { + transformations: ['fill(linear)', 'smooth(sma,5)'], + expected: 'fill(linear) → SMA(5)', + }, + { + transformations: ['smooth(sma,5)', 'fill(linear)'], + expected: 'SMA(5) → fill(linear)', + }, + { + transformations: ['smooth(future,5)', 'unsupported'], + expected: 'smooth(future,5) → unsupported', + }, + ])('formats $transformations as $expected', ({transformations, expected}) => { + expect(formatTimeSeriesTransformations(transformations)).toBe(expected); + }); +}); diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.tsx new file mode 100644 index 000000000000..11fafafdeac0 --- /dev/null +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.tsx @@ -0,0 +1,22 @@ +import {parseFunction} from 'sentry/utils/discover/fields'; + +export function formatTimeSeriesTransformations( + transformations: readonly string[] = [] +): string { + return transformations + .map(transformation => { + const parsed = parseFunction(transformation); + if (parsed?.name !== 'smooth') { + return transformation; + } + + const [method, ...parameters] = parsed.arguments; + if (method !== 'sma' && method !== 'ema' && method !== 'asap') { + return transformation; + } + + const label = method.toUpperCase(); + return parameters.length ? `${label}(${parameters.join(',')})` : label; + }) + .join(' → '); +} diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/continuousTimeSeries.spec.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/continuousTimeSeries.spec.tsx index 433cbf4b993e..3fc7eee0fcc9 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/continuousTimeSeries.spec.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/continuousTimeSeries.spec.tsx @@ -6,6 +6,28 @@ import {ContinuousTimeSeries} from './continuousTimeSeries'; import type {Plottable} from './plottable'; describe('ContinuousTimeSeries', () => { + describe('labelSuffix', () => { + it.each([ + {alias: undefined, groupBy: undefined, expectedLabel: 'avg(span.duration)'}, + {alias: 'Duration', groupBy: undefined, expectedLabel: 'Duration'}, + { + alias: undefined, + groupBy: [{key: 'span.op', value: 'db'}], + expectedLabel: 'db', + }, + ])('keeps $expectedLabel separate from transformations', config => { + const series = TimeSeriesFixture({ + yAxis: 'avg(span.duration)', + groupBy: config.groupBy, + requestedTransformations: ['fill(linear)', 'smooth(sma,5)'], + }); + const plottable = new Dots(series, {alias: config.alias}); + + expect(plottable.label).toBe(config.expectedLabel); + expect(plottable.labelSuffix).toBe('fill(linear) → SMA(5)'); + }); + }); + describe('isEmpty', () => { it('marks normal plottables as not empty', () => { const timeSeries = TimeSeriesFixture(); diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/continuousTimeSeries.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/continuousTimeSeries.tsx index 7265a6be7d42..72aff59a5184 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/continuousTimeSeries.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/continuousTimeSeries.tsx @@ -12,6 +12,7 @@ import type { } from 'sentry/views/dashboards/widgets/common/types'; import {formatTimeSeriesLabel} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesLabel'; import {formatTimeSeriesName} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesName'; +import {formatTimeSeriesTransformations} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations'; import {FALLBACK_TYPE} from 'sentry/views/dashboards/widgets/timeSeriesWidget/settings'; import type {PlottableConfidence, PlottableTimeSeriesValueType} from './plottable'; @@ -87,6 +88,10 @@ export abstract class ContinuousTimeSeries< return this.config?.alias ?? formatTimeSeriesLabel(this.timeSeries); } + get labelSuffix(): string { + return formatTimeSeriesTransformations(this.timeSeries.requestedTransformations); + } + get isEmpty(): boolean { return this.timeSeries.values.every(datum => datum.value === null); } diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/plottable.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/plottable.tsx index cb3fe4ced0cf..e44355046788 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/plottable.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/plottable.tsx @@ -65,6 +65,7 @@ export interface Plottable { * Optional callback to get access to the chart `ref`. Some Plottables implement this to allow dispatching events to the chart */ handleChartRef?: (ref: ReactEchartsRef) => void; + labelSuffix?: string; /** * `TimeSeriesWidgetVisualization` will call this function if the user clicks a point on a series that originated from this plottable. */ diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.spec.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.spec.tsx new file mode 100644 index 000000000000..3b93bef2ca14 --- /dev/null +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.spec.tsx @@ -0,0 +1,102 @@ +import type {Ref} from 'react'; +import {TimeSeriesFixture} from 'sentry-fixture/timeSeries'; + +import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary'; + +import {Line} from './plottables/line'; +import {TimeSeriesWidgetVisualization} from './timeSeriesWidgetVisualization'; + +jest.mock('echarts-for-react/lib/core', () => { + const {useImperativeHandle, useRef, useState} = + jest.requireActual('react'); + + return function Chart({ + option, + ref, + }: { + option: { + series: Array<{ + data: Array<{value: [number, number]}>; + name: string; + type: string; + }>; + tooltip: {formatter: (params: unknown[], ticket: string) => string}; + }; + ref: Ref; + }) { + const elementRef = useRef(null); + const [tooltip, setTooltip] = useState(''); + useImperativeHandle(ref, () => ({ele: elementRef.current}), []); + + return ( +
{ + const params = option.series.flatMap((series, seriesIndex) => { + const data = series.data[0]; + return data + ? [ + { + data, + value: data.value, + seriesName: series.name, + seriesType: series.type, + seriesIndex, + dataIndex: 0, + marker: '', + }, + ] + : []; + }); + setTooltip(option.tooltip.formatter(params, 'test')); + }} + > +
+
+ ); + }; +}); + +describe('TimeSeriesWidgetVisualization', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('shows ordered transformation suffixes beside aliases in the tooltip', async () => { + jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000); + const original = new Line(TimeSeriesFixture(), {alias: 'Duration'}); + const transformed = new Line( + TimeSeriesFixture({ + requestedTransformations: ['fill(linear)', 'smooth(sma,5)'], + }), + {alias: 'Duration'} + ); + render(); + + expect( + screen.getByRole('button', {name: 'Toggle Duration · fill(linear) → SMA(5)'}) + ).toBeInTheDocument(); + await userEvent.hover(screen.getByLabelText('Time series chart')); + + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toHaveTextContent('Duration · fill(linear) → SMA(5)'); + expect(within(tooltip).getAllByText('Duration')).toHaveLength(2); + expect(tooltip).toHaveTextContent('7.46K/s'); + }); + + it('escapes alias and suffix text when rendering tooltip HTML', async () => { + const plottable = new Line( + TimeSeriesFixture({requestedTransformations: ['fill()']}), + {alias: 'Duration'} + ); + render(); + + await userEvent.hover(screen.getByLabelText('Time series chart')); + + const tooltip = screen.getByRole('tooltip'); + expect(tooltip).toHaveTextContent('Duration · fill()'); + expect(within(tooltip).queryByRole('img')).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx index ec45146de208..075fed709295 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx @@ -306,11 +306,8 @@ export function TimeSeriesWidgetVisualization(props: TimeSeriesWidgetVisualizati [paletteSize, theme.chart] ); - // Create a lookup of series names (given to ECharts) to labels (from - // Plottable). This makes it easier to look up alises when rendering tooltips - // and legends - const aliases = Object.fromEntries( - props.plottables.map(plottable => [plottable.name, plottable.label]) + const plottablesByName = Object.fromEntries( + props.plottables.map(plottable => [plottable.name, plottable]) ); // Create tooltip formatter @@ -383,12 +380,16 @@ export function TimeSeriesWidgetVisualization(props: TimeSeriesWidgetVisualizati // seriesName may be HTML-escaped, so we need to unescape it before looking up the alias // to ensure the lookup works correctly for series with characters that are escaped. - const alias = aliases[unescape(seriesName)]; + const plottable = plottablesByName[unescape(seriesName)]; + const alias = plottable?.label; if (alias) { // The alias value comes from `plottable.label` and is not // HTML-escaped. Escape it for safe insertion into raw HTML // tooltip strings. - return escape(truncationFormatter(alias, true, false)); + const label = escape(truncationFormatter(alias, true, false)); + return plottable.labelSuffix + ? `${label} · ${escape(plottable.labelSuffix)}` + : label; } // `seriesName` is already HTML-escaped by `getFormatter`, so // skip escaping to avoid double-encoding (e.g., `>` → `>` @@ -680,6 +681,7 @@ export function TimeSeriesWidgetVisualization(props: TimeSeriesWidgetVisualizati return { name: plottable.name, label: plottable.label, + suffix: plottable.labelSuffix, color, }; }); From ea63381157517326a14dc487fa13c6c98cb6bd90 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 13 Nov 2026 11:14:24 -0400 Subject: [PATCH 3/5] test(dashboards): Expect suffixes in widget preview labels --- .../widgetBuilder/components/widgetPreview.spec.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/static/app/views/dashboards/widgetBuilder/components/widgetPreview.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/widgetPreview.spec.tsx index 88c863fb9a21..555849771b1a 100644 --- a/static/app/views/dashboards/widgetBuilder/components/widgetPreview.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/widgetPreview.spec.tsx @@ -119,9 +119,12 @@ describe('WidgetPreview', () => { }) ); }); - expect(await screen.findAllByLabelText('Toggle p90(span.duration)')).toHaveLength( - 2 - ); + expect( + await screen.findByLabelText('Toggle p90(span.duration)') + ).toBeInTheDocument(); + expect( + await screen.findByLabelText('Toggle p90(span.duration) · SMA(5)') + ).toBeInTheDocument(); } ); }); From 5c419fe8fe0881192f20151caa5f2e0697ad2ac9 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 13 Nov 2026 11:32:00 -0400 Subject: [PATCH 4/5] fix(charts): Preserve configured transformation notation --- .../charts/chartLegend.snapshots.tsx | 2 +- .../components/charts/chartLegend.spec.tsx | 19 ++++++++++++++----- .../components/widgetPreview.spec.tsx | 2 +- .../formatTimeSeriesTransformations.spec.tsx | 14 +++++++------- .../formatTimeSeriesTransformations.tsx | 19 +------------------ .../plottables/continuousTimeSeries.spec.tsx | 2 +- .../timeSeriesWidgetVisualization.spec.tsx | 6 ++++-- 7 files changed, 29 insertions(+), 35 deletions(-) diff --git a/static/app/components/charts/chartLegend.snapshots.tsx b/static/app/components/charts/chartLegend.snapshots.tsx index bcec2a8ef1ba..313b67f96a63 100644 --- a/static/app/components/charts/chartLegend.snapshots.tsx +++ b/static/app/components/charts/chartLegend.snapshots.tsx @@ -13,7 +13,7 @@ describe('ChartLegend', () => { label: longLabel ? 'Average duration for database operations grouped by transaction' : 'avg(span.duration)', - suffix: 'fill(linear) → SMA(5)', + suffix: 'fill(linear) → smooth(sma,5)', color: '#069fba', }, ]} diff --git a/static/app/components/charts/chartLegend.spec.tsx b/static/app/components/charts/chartLegend.spec.tsx index 8dee0318b048..f1485ad0d941 100644 --- a/static/app/components/charts/chartLegend.spec.tsx +++ b/static/app/components/charts/chartLegend.spec.tsx @@ -74,7 +74,7 @@ describe('ChartLegend', () => { { name: 'transformed-series', label: 'Duration', - suffix: 'fill(linear) → SMA(5)', + suffix: 'fill(linear) → smooth(sma,5)', color: '#ff0000', }, ]} @@ -84,12 +84,16 @@ describe('ChartLegend', () => { ); await userEvent.click( - screen.getByRole('button', {name: 'Toggle Duration · fill(linear) → SMA(5)'}) + screen.getByRole('button', { + name: 'Toggle Duration · fill(linear) → smooth(sma,5)', + }) ); expect(onSelectionChange).toHaveBeenCalledWith({'transformed-series': false}); expect( - screen.getByRole('checkbox', {name: 'Duration · fill(linear) → SMA(5)'}) + screen.getByRole('checkbox', { + name: 'Duration · fill(linear) → smooth(sma,5)', + }) ).toBeChecked(); }); @@ -104,7 +108,12 @@ describe('ChartLegend', () => { truncateMenuLabels items={[ {name: 'original', label: 'Duration', color: '#ff0000'}, - {name: 'smoothed', label: 'Duration', suffix: 'SMA(5)', color: '#0000ff'}, + { + name: 'smoothed', + label: 'Duration', + suffix: 'smooth(sma,5)', + color: '#0000ff', + }, ]} selected={{}} onSelectionChange={onSelectionChange} @@ -112,7 +121,7 @@ describe('ChartLegend', () => { ); await userEvent.click(screen.getByRole('button', {name: '+2 more'})); - await userEvent.click(screen.getByRole('option', {name: 'Duration · SMA(5)'})); + await userEvent.click(screen.getByRole('option', {name: 'Duration · smooth(sma,5)'})); expect(onSelectionChange).toHaveBeenCalledWith({original: true, smoothed: false}); }); diff --git a/static/app/views/dashboards/widgetBuilder/components/widgetPreview.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/widgetPreview.spec.tsx index 555849771b1a..94fe0e4d6cc9 100644 --- a/static/app/views/dashboards/widgetBuilder/components/widgetPreview.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/widgetPreview.spec.tsx @@ -123,7 +123,7 @@ describe('WidgetPreview', () => { await screen.findByLabelText('Toggle p90(span.duration)') ).toBeInTheDocument(); expect( - await screen.findByLabelText('Toggle p90(span.duration) · SMA(5)') + await screen.findByLabelText('Toggle p90(span.duration) · smooth(sma,5)') ).toBeInTheDocument(); } ); diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.spec.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.spec.tsx index 4dd31f7103be..fa2e8c4181a8 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.spec.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.spec.tsx @@ -7,18 +7,18 @@ describe('formatTimeSeriesTransformations', () => { {transformations: ['fill(zero)'], expected: 'fill(zero)'}, {transformations: ['fill(locf)'], expected: 'fill(locf)'}, {transformations: ['fill(linear)'], expected: 'fill(linear)'}, - {transformations: ['smooth(sma,5)'], expected: 'SMA(5)'}, - {transformations: ['smooth(ema,10)'], expected: 'EMA(10)'}, - {transformations: ['smooth(ema,10,0.25)'], expected: 'EMA(10,0.25)'}, - {transformations: ['smooth(asap)'], expected: 'ASAP'}, - {transformations: ['smooth(asap,5)'], expected: 'ASAP(5)'}, + {transformations: ['smooth(sma,5)'], expected: 'smooth(sma,5)'}, + {transformations: ['smooth(ema,10)'], expected: 'smooth(ema,10)'}, + {transformations: ['smooth(ema,10,0.25)'], expected: 'smooth(ema,10,0.25)'}, + {transformations: ['smooth(asap)'], expected: 'smooth(asap)'}, + {transformations: ['smooth(asap,5)'], expected: 'smooth(asap,5)'}, { transformations: ['fill(linear)', 'smooth(sma,5)'], - expected: 'fill(linear) → SMA(5)', + expected: 'fill(linear) → smooth(sma,5)', }, { transformations: ['smooth(sma,5)', 'fill(linear)'], - expected: 'SMA(5) → fill(linear)', + expected: 'smooth(sma,5) → fill(linear)', }, { transformations: ['smooth(future,5)', 'unsupported'], diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.tsx index 11fafafdeac0..18c5093fb72a 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatTimeSeriesTransformations.tsx @@ -1,22 +1,5 @@ -import {parseFunction} from 'sentry/utils/discover/fields'; - export function formatTimeSeriesTransformations( transformations: readonly string[] = [] ): string { - return transformations - .map(transformation => { - const parsed = parseFunction(transformation); - if (parsed?.name !== 'smooth') { - return transformation; - } - - const [method, ...parameters] = parsed.arguments; - if (method !== 'sma' && method !== 'ema' && method !== 'asap') { - return transformation; - } - - const label = method.toUpperCase(); - return parameters.length ? `${label}(${parameters.join(',')})` : label; - }) - .join(' → '); + return transformations.join(' → '); } diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/continuousTimeSeries.spec.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/continuousTimeSeries.spec.tsx index 3fc7eee0fcc9..d51f2d3aed40 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/continuousTimeSeries.spec.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/plottables/continuousTimeSeries.spec.tsx @@ -24,7 +24,7 @@ describe('ContinuousTimeSeries', () => { const plottable = new Dots(series, {alias: config.alias}); expect(plottable.label).toBe(config.expectedLabel); - expect(plottable.labelSuffix).toBe('fill(linear) → SMA(5)'); + expect(plottable.labelSuffix).toBe('fill(linear) → smooth(sma,5)'); }); }); diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.spec.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.spec.tsx index 3b93bef2ca14..7a6e1b71c2e3 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.spec.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.spec.tsx @@ -76,12 +76,14 @@ describe('TimeSeriesWidgetVisualization', () => { render(); expect( - screen.getByRole('button', {name: 'Toggle Duration · fill(linear) → SMA(5)'}) + screen.getByRole('button', { + name: 'Toggle Duration · fill(linear) → smooth(sma,5)', + }) ).toBeInTheDocument(); await userEvent.hover(screen.getByLabelText('Time series chart')); const tooltip = screen.getByRole('tooltip'); - expect(tooltip).toHaveTextContent('Duration · fill(linear) → SMA(5)'); + expect(tooltip).toHaveTextContent('Duration · fill(linear) → smooth(sma,5)'); expect(within(tooltip).getAllByText('Duration')).toHaveLength(2); expect(tooltip).toHaveTextContent('7.46K/s'); }); From 2bba292ee5ac7a746a9a3d84d54b31e3a9cbfe05 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 13 Nov 2026 12:02:01 -0400 Subject: [PATCH 5/5] test(charts): Remove visualization and snapshot tests --- .../charts/chartLegend.snapshots.tsx | 25 ----- .../timeSeriesWidgetVisualization.spec.tsx | 104 ------------------ 2 files changed, 129 deletions(-) delete mode 100644 static/app/components/charts/chartLegend.snapshots.tsx delete mode 100644 static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.spec.tsx diff --git a/static/app/components/charts/chartLegend.snapshots.tsx b/static/app/components/charts/chartLegend.snapshots.tsx deleted file mode 100644 index 313b67f96a63..000000000000 --- a/static/app/components/charts/chartLegend.snapshots.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import {Container} from '@sentry/scraps/layout'; - -import {ChartLegend} from './chartLegend'; - -describe('ChartLegend', () => { - it.snapshot.each([false, true])('transformed-long-label-%s', longLabel => ( - - {}} - /> - - )); -}); diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.spec.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.spec.tsx deleted file mode 100644 index 7a6e1b71c2e3..000000000000 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.spec.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import type {Ref} from 'react'; -import {TimeSeriesFixture} from 'sentry-fixture/timeSeries'; - -import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary'; - -import {Line} from './plottables/line'; -import {TimeSeriesWidgetVisualization} from './timeSeriesWidgetVisualization'; - -jest.mock('echarts-for-react/lib/core', () => { - const {useImperativeHandle, useRef, useState} = - jest.requireActual('react'); - - return function Chart({ - option, - ref, - }: { - option: { - series: Array<{ - data: Array<{value: [number, number]}>; - name: string; - type: string; - }>; - tooltip: {formatter: (params: unknown[], ticket: string) => string}; - }; - ref: Ref; - }) { - const elementRef = useRef(null); - const [tooltip, setTooltip] = useState(''); - useImperativeHandle(ref, () => ({ele: elementRef.current}), []); - - return ( -
{ - const params = option.series.flatMap((series, seriesIndex) => { - const data = series.data[0]; - return data - ? [ - { - data, - value: data.value, - seriesName: series.name, - seriesType: series.type, - seriesIndex, - dataIndex: 0, - marker: '', - }, - ] - : []; - }); - setTooltip(option.tooltip.formatter(params, 'test')); - }} - > -
-
- ); - }; -}); - -describe('TimeSeriesWidgetVisualization', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('shows ordered transformation suffixes beside aliases in the tooltip', async () => { - jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000); - const original = new Line(TimeSeriesFixture(), {alias: 'Duration'}); - const transformed = new Line( - TimeSeriesFixture({ - requestedTransformations: ['fill(linear)', 'smooth(sma,5)'], - }), - {alias: 'Duration'} - ); - render(); - - expect( - screen.getByRole('button', { - name: 'Toggle Duration · fill(linear) → smooth(sma,5)', - }) - ).toBeInTheDocument(); - await userEvent.hover(screen.getByLabelText('Time series chart')); - - const tooltip = screen.getByRole('tooltip'); - expect(tooltip).toHaveTextContent('Duration · fill(linear) → smooth(sma,5)'); - expect(within(tooltip).getAllByText('Duration')).toHaveLength(2); - expect(tooltip).toHaveTextContent('7.46K/s'); - }); - - it('escapes alias and suffix text when rendering tooltip HTML', async () => { - const plottable = new Line( - TimeSeriesFixture({requestedTransformations: ['fill()']}), - {alias: 'Duration'} - ); - render(); - - await userEvent.hover(screen.getByLabelText('Time series chart')); - - const tooltip = screen.getByRole('tooltip'); - expect(tooltip).toHaveTextContent('Duration · fill()'); - expect(within(tooltip).queryByRole('img')).not.toBeInTheDocument(); - }); -});