From 9ccd365652ae7a4e628b265d373cece7996450f6 Mon Sep 17 00:00:00 2001 From: Evan Rusackas Date: Fri, 17 Jul 2026 09:41:04 -0700 Subject: [PATCH] feat(echarts): respect time grain in time-series tooltips (#41350) Co-authored-by: Michael Gerber Co-authored-by: Claude Opus 4.8 --- UPDATING.md | 4 + .../src/MixedTimeseries/transformProps.ts | 26 ++++-- .../src/Timeseries/transformProps.ts | 26 ++++-- .../src/utils/formatters.ts | 20 ++-- .../MixedTimeseries/transformProps.test.ts | 93 +++++++++++++++++++ .../test/Timeseries/transformProps.test.ts | 72 ++++++++++++++ .../test/utils/formatters.test.ts | 65 +++++++++++++ 7 files changed, 282 insertions(+), 24 deletions(-) diff --git a/UPDATING.md b/UPDATING.md index 138fe882375..766994c3296 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -192,6 +192,10 @@ helm upgrade superset/superset Alternatively, perform a fresh install. This is a one-time migration; subsequent upgrades are unaffected. +### Time-series tooltips follow the selected time grain + +Tooltips on the Time-series and Mixed Time-series ECharts plugins now respect the chart's time grain (and any dashboard-level time-grain override delivered via `extra_form_data`) when the tooltip time format is left on Adaptive formatting (the default). Tooltips read grain-appropriate labels such as `Jan 2021` (month), `2021 Q1` (quarter), `2021` (year), and weekly date ranges, becoming grain-aware like the x-axis, though the two are formatted independently and their labels may not always match exactly. Only a custom, explicitly-set tooltip time format (a d3 format string) is unaffected — that always wins over the grain. + ### Pivot table First/Last aggregations follow data order The pivot table chart's `First` and `Last` aggregations now return the first and last value in data (query result) order, instead of effectively returning the minimum and maximum. Existing pivot tables that use these aggregations for totals/subtotals may show different values after upgrading. For deterministic results, ensure the underlying query has a stable sort order. diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts index 189d3b47c2c..5e3599234a5 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -619,17 +619,25 @@ export default function transformProps( if (maxSecondary === undefined) maxSecondary = 1; } + // A dashboard-level time grain override (e.g. via a filter or the temporal + // range control) is delivered in extraFormData and should take precedence + // over the chart's own time grain when formatting temporal axes/tooltips. + const resolvedTimeGrain = + formData.extraFormData?.time_grain_sqla ?? timeGrainSqla; + const tooltipFormatter = xAxisDataType === GenericDataType.Temporal - ? getTooltipTimeFormatter(tooltipTimeFormat) + ? getTooltipTimeFormatter(tooltipTimeFormat, resolvedTimeGrain) : String; const xAxisFormatter = xAxisDataType === GenericDataType.Temporal - ? getXAxisFormatter(xAxisTimeFormat, timeGrainSqla) + ? getXAxisFormatter(xAxisTimeFormat, resolvedTimeGrain) : String; const showMaxLabel = - xAxisType === AxisType.Time && xAxisLabelRotation === 0 && !!timeGrainSqla; + xAxisType === AxisType.Time && + xAxisLabelRotation === 0 && + !!resolvedTimeGrain; const deduplicatedFormatter = showMaxLabel ? (() => { let lastLabel: string | undefined; @@ -739,15 +747,15 @@ export default function transformProps( }, minorTick: { show: minorTicks }, minInterval: - xAxisType === AxisType.Time && timeGrainSqla && !forceMaxInterval - ? TIMEGRAIN_TO_TIMESTAMP[ - timeGrainSqla as keyof typeof TIMEGRAIN_TO_TIMESTAMP - ] + xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval + ? (TIMEGRAIN_TO_TIMESTAMP[ + resolvedTimeGrain as keyof typeof TIMEGRAIN_TO_TIMESTAMP + ] ?? 0) : 0, maxInterval: - xAxisType === AxisType.Time && timeGrainSqla && forceMaxInterval + xAxisType === AxisType.Time && resolvedTimeGrain && forceMaxInterval ? TIMEGRAIN_TO_TIMESTAMP[ - timeGrainSqla as keyof typeof TIMEGRAIN_TO_TIMESTAMP + resolvedTimeGrain as keyof typeof TIMEGRAIN_TO_TIMESTAMP ] : undefined, ...getMinAndMaxFromBounds( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index c8335fb727d..a2ad93e9578 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -724,13 +724,19 @@ export default function transformProps( } } + // A dashboard-level time grain override (e.g. via a filter or the temporal + // range control) is delivered in extraFormData and should take precedence + // over the chart's own time grain when formatting temporal axes/tooltips. + const resolvedTimeGrain = + formData.extraFormData?.time_grain_sqla ?? timeGrainSqla; + const tooltipFormatter = xAxisDataType === GenericDataType.Temporal - ? getTooltipTimeFormatter(tooltipTimeFormat) + ? getTooltipTimeFormatter(tooltipTimeFormat, resolvedTimeGrain) : String; const xAxisFormatter = xAxisDataType === GenericDataType.Temporal - ? getXAxisFormatter(xAxisTimeFormat, timeGrainSqla) + ? getXAxisFormatter(xAxisTimeFormat, resolvedTimeGrain) : xAxisDataType === GenericDataType.Numeric ? getNumberFormatter(xAxisNumberFormat) : String; @@ -871,7 +877,9 @@ export default function transformProps( // "2005" appears twice with Year grain). Wrap the formatter to suppress // consecutive duplicate labels. const showMaxLabel = - xAxisType === AxisType.Time && xAxisLabelRotation === 0 && !!timeGrainSqla; + xAxisType === AxisType.Time && + xAxisLabelRotation === 0 && + !!resolvedTimeGrain; const deduplicatedFormatter = showMaxLabel ? (() => { let lastLabel: string | undefined; @@ -923,15 +931,15 @@ export default function transformProps( }, minorTick: { show: minorTicks }, minInterval: - xAxisType === AxisType.Time && timeGrainSqla && !forceMaxInterval - ? TIMEGRAIN_TO_TIMESTAMP[ - timeGrainSqla as keyof typeof TIMEGRAIN_TO_TIMESTAMP - ] + xAxisType === AxisType.Time && resolvedTimeGrain && !forceMaxInterval + ? (TIMEGRAIN_TO_TIMESTAMP[ + resolvedTimeGrain as keyof typeof TIMEGRAIN_TO_TIMESTAMP + ] ?? 0) : 0, maxInterval: - xAxisType === AxisType.Time && timeGrainSqla && forceMaxInterval + xAxisType === AxisType.Time && resolvedTimeGrain && forceMaxInterval ? TIMEGRAIN_TO_TIMESTAMP[ - timeGrainSqla as keyof typeof TIMEGRAIN_TO_TIMESTAMP + resolvedTimeGrain as keyof typeof TIMEGRAIN_TO_TIMESTAMP ] : undefined, ...getMinAndMaxFromBounds( diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/formatters.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/formatters.ts index 4582253a496..e076b664f0f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/formatters.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/formatters.ts @@ -162,14 +162,22 @@ export const getYAxisFormatter = ( export function getTooltipTimeFormatter( format?: string, + timeGrain?: TimeGranularity, ): TimeFormatter | StringConstructor { - if (format === SMART_DATE_ID) { - return getSmartDateVerboseFormatter(); + // When a time grain is active and the user hasn't pinned an explicit format, + // honor the grain so tooltips read "Jan 2021", "2021 Q1", "2021", weekly + // ranges, etc. instead of a fixed timestamp. An explicit custom format is + // always respected verbatim. + if (!format || format === SMART_DATE_ID) { + if (timeGrain) { + return getTimeFormatter(undefined, timeGrain); + } + if (format === SMART_DATE_ID) { + return getSmartDateVerboseFormatter(); + } + return String; } - if (format) { - return getTimeFormatter(format); - } - return String; + return getTimeFormatter(format); } export function getXAxisFormatter( diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts index 8892124f5c3..fc189cb8358 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts @@ -26,6 +26,7 @@ import { IntervalAnnotationLayer, VizType, ChartDataResponseResult, + TimeGranularity, } from '@superset-ui/core'; import { GenericDataType } from '@apache-superset/core/common'; import { @@ -55,6 +56,12 @@ type SeriesWithLabelFormatter = SeriesOption & { }; }; +type TooltipFormatterOptions = { + tooltip: { + formatter: (params: unknown) => string; + }; +}; + /** * Creates a partial ChartDataResponseResult for testing. * Only includes the fields needed for tests, with sensible defaults for required fields. @@ -1013,3 +1020,89 @@ test('temporal x coltype wires the time formatter and Time axis', () => { expect(typeof label).toBe('string'); expect(label).not.toMatch(/NaN/); }); + +test('tooltip time grain wiring: dashboard-level extraFormData time grain overrides the chart-level grain in the tooltip', () => { + const ts = Date.UTC(2021, 0, 7); + const temporalRows = [{ __timestamp: ts, metric: 100 }]; + const temporalQueryData = createTestQueryData(temporalRows, { + colnames: ['__timestamp', 'metric'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + label_map: { __timestamp: ['__timestamp'], metric: ['metric'] }, + }); + + const chartProps = createEchartsTimeseriesTestChartProps< + EchartsMixedTimeseriesFormData, + EchartsMixedTimeseriesProps + >({ + ...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS, + defaultQueriesData: [temporalQueryData, temporalQueryData], + formData: { + ...formData, + x_axis: '__timestamp', + metrics: ['metric'], + metricsB: ['metric'], + groupby: [], + groupbyB: [], + // The chart itself is configured with a Day grain... + timeGrainSqla: TimeGranularity.DAY, + // ...but a dashboard-level filter/override resolves to Month. + extraFormData: { time_grain_sqla: TimeGranularity.MONTH }, + }, + queriesData: [temporalQueryData, temporalQueryData], + }); + + const { echartOptions } = transformProps(chartProps); + const tooltipFormatter = (echartOptions as unknown as TooltipFormatterOptions) + .tooltip.formatter; + + const result = tooltipFormatter({ + value: [ts, 100], + seriesName: 'metric', + }); + + // Month grain (the dashboard override) should win, so the tooltip title + // reads "Jan 2021" rather than the Day-grain "2021-01-07". + expect(result).toContain('Jan'); + expect(result).toContain('2021'); + expect(result).not.toContain('2021-01-07'); +}); + +test('tooltip time grain wiring: chart-level time grain drives the tooltip when there is no dashboard override', () => { + const ts = Date.UTC(2021, 0, 7); + const temporalRows = [{ __timestamp: ts, metric: 100 }]; + const temporalQueryData = createTestQueryData(temporalRows, { + colnames: ['__timestamp', 'metric'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + label_map: { __timestamp: ['__timestamp'], metric: ['metric'] }, + }); + + const chartProps = createEchartsTimeseriesTestChartProps< + EchartsMixedTimeseriesFormData, + EchartsMixedTimeseriesProps + >({ + ...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS, + defaultQueriesData: [temporalQueryData, temporalQueryData], + formData: { + ...formData, + x_axis: '__timestamp', + metrics: ['metric'], + metricsB: ['metric'], + groupby: [], + groupbyB: [], + timeGrainSqla: TimeGranularity.YEAR, + }, + queriesData: [temporalQueryData, temporalQueryData], + }); + + const { echartOptions } = transformProps(chartProps); + const tooltipFormatter = (echartOptions as unknown as TooltipFormatterOptions) + .tooltip.formatter; + + const result = tooltipFormatter({ + value: [ts, 100], + seriesName: 'metric', + }); + + expect(result).toContain('2021'); + expect(result).not.toContain('2021-01-07'); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts index aef63c1dabe..a219c22f56c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts @@ -84,6 +84,12 @@ function createTestQueryData( type YAxisFormatter = (value: number, index: number) => string; +type TooltipFormatterOptions = { + tooltip: { + formatter: (params: unknown) => string; + }; +}; + function getYAxisFormatter( transformed: ReturnType, ): YAxisFormatter { @@ -1837,3 +1843,69 @@ describe('Tooltip with long labels', () => { expect(result).toContain('599616000000'); }); }); + +test('tooltip time grain wiring: dashboard-level extraFormData time grain overrides the chart-level grain in the tooltip', () => { + const ts = Date.UTC(2021, 0, 7); + const chartProps = createTestChartProps({ + formData: { + granularity_sqla: 'ds', + richTooltip: false, + // The chart itself is configured with a Day grain... + timeGrainSqla: TimeGranularity.DAY, + // ...but a dashboard-level filter/override resolves to Month. + extraFormData: { time_grain_sqla: TimeGranularity.MONTH }, + }, + queriesData: [ + createTestQueryData([{ __timestamp: ts, sales: 100 }], { + colnames: ['__timestamp', 'sales'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + }), + ], + }); + + const transformedProps = transformProps(chartProps); + const tooltipFormatter = ( + transformedProps.echartOptions as unknown as TooltipFormatterOptions + ).tooltip.formatter; + + const result = tooltipFormatter({ + value: [ts, 100], + seriesName: 'sales', + }); + + // Month grain (the dashboard override) should win, so the tooltip title + // reads "Jan 2021" rather than the Day-grain "2021-01-07". + expect(result).toContain('Jan'); + expect(result).toContain('2021'); + expect(result).not.toContain('2021-01-07'); +}); + +test('tooltip time grain wiring: chart-level time grain drives the tooltip when there is no dashboard override', () => { + const ts = Date.UTC(2021, 0, 7); + const chartProps = createTestChartProps({ + formData: { + granularity_sqla: 'ds', + richTooltip: false, + timeGrainSqla: TimeGranularity.YEAR, + }, + queriesData: [ + createTestQueryData([{ __timestamp: ts, sales: 100 }], { + colnames: ['__timestamp', 'sales'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + }), + ], + }); + + const transformedProps = transformProps(chartProps); + const tooltipFormatter = ( + transformedProps.echartOptions as unknown as TooltipFormatterOptions + ).tooltip.formatter; + + const result = tooltipFormatter({ + value: [ts, 100], + seriesName: 'sales', + }); + + expect(result).toContain('2021'); + expect(result).not.toContain('2021-01-07'); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/utils/formatters.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/utils/formatters.test.ts index d60c6308717..112f00ddd23 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/utils/formatters.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/formatters.test.ts @@ -206,6 +206,71 @@ test('getTooltipTimeFormatter falls back to the String constructor when no forma expect(getTooltipTimeFormatter(undefined)).toBe(String); }); +test('getTooltipTimeFormatter respects the time grain for the SMART_DATE path', () => { + // With a time grain active and no explicit format, the tooltip should read + // grain-appropriate labels rather than a raw timestamp. UTC-based dates keep + // the assertions deterministic across CI/developer timezones. + const date = new Date(Date.UTC(2021, 0, 7)); + + const dayFormatter = getTooltipTimeFormatter( + SMART_DATE_ID, + TimeGranularity.DAY, + ) as TimeFormatter; + expect(dayFormatter.format(date)).toEqual('2021-01-07'); + + const weekFormatter = getTooltipTimeFormatter( + SMART_DATE_ID, + TimeGranularity.WEEK, + ) as TimeFormatter; + expect(weekFormatter.format(date)).toEqual('2021-01-07 — 2021-01-13'); + + const monthFormatter = getTooltipTimeFormatter( + SMART_DATE_ID, + TimeGranularity.MONTH, + ) as TimeFormatter; + expect(monthFormatter.format(date)).toEqual(expect.stringContaining('Jan')); + expect(monthFormatter.format(date)).toEqual(expect.stringContaining('2021')); + + const quarterFormatter = getTooltipTimeFormatter( + SMART_DATE_ID, + TimeGranularity.QUARTER, + ) as TimeFormatter; + expect(quarterFormatter.format(date)).toEqual(expect.stringContaining('Q1')); + expect(quarterFormatter.format(date)).toEqual( + expect.stringContaining('2021'), + ); + + const yearFormatter = getTooltipTimeFormatter( + SMART_DATE_ID, + TimeGranularity.YEAR, + ) as TimeFormatter; + expect(yearFormatter.format(date)).toEqual('2021'); +}); + +test('getTooltipTimeFormatter applies the time grain even without an explicit format', () => { + const date = new Date(Date.UTC(2021, 0, 7)); + const monthFormatter = getTooltipTimeFormatter( + undefined, + TimeGranularity.MONTH, + ) as TimeFormatter; + expect(monthFormatter).toBeInstanceOf(TimeFormatter); + expect(monthFormatter.format(date)).toEqual(expect.stringContaining('2021')); +}); + +test('getTooltipTimeFormatter honors an explicit custom format over the time grain', () => { + // A user-pinned format must win, so the grain does not turn a single date + // into a range or otherwise override the requested format. + const formatter = getTooltipTimeFormatter( + '%Y-%m-%d', + TimeGranularity.YEAR, + ) as TimeFormatter; + expect(formatter).toBeInstanceOf(TimeFormatter); + expect(formatter.id).toBe('%Y-%m-%d'); + expect(formatter.format(new Date(Date.UTC(2021, 0, 7)))).toEqual( + '2021-01-07', + ); +}); + test('getXAxisFormatter produces stable SMART_DATE output for a valid Date', () => { // Documents the current happy-path output format so unexpected changes are // caught during review.