This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new b5c8d4a5df3 fix(mixed-timeseries): stop duplicating first metric in
multi-metric + group-by series names (#40146)
b5c8d4a5df3 is described below
commit b5c8d4a5df35686d4cd76b83cd5cc46ec17c04ca
Author: Evan Rusackas <[email protected]>
AuthorDate: Tue Jul 28 09:15:26 2026 -0700
fix(mixed-timeseries): stop duplicating first metric in multi-metric +
group-by series names (#40146)
Co-authored-by: Claude Code <[email protected]>
---
.../src/MixedTimeseries/transformProps.ts | 36 +++++++---
.../test/MixedTimeseries/transformProps.test.ts | 80 ++++++++++++++++++++++
2 files changed, 106 insertions(+), 10 deletions(-)
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 ee01fb6f89c..96b1a39897a 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
@@ -457,13 +457,23 @@ export default function transformProps(
const seriesName = inverted[entryName] || entryName;
const colorScaleKey = getOriginalSeries(seriesName, array);
+ const labelMapValues = rawLabelMap?.[seriesName];
+
let displayName: string;
if (groupby.length > 0) {
- // When we have groupby, format as "metric, dimension"
+ // When we have groupby, format as "metric, dimension". Each series
+ // belongs to the metric recorded in its label-map tuple
+ // ([metric, ...dimensions]) — always using the first metric would
+ // prepend it to every other metric's series (#37921). Tuples without
+ // a metric part fall back to the first metric as before.
+ const metricDisplayName =
+ labelMapValues && labelMapValues.length > 1
+ ? getMetricDisplayName(labelMapValues[0], verboseMap)
+ : MetricDisplayNameA;
const metricPart: string = showQueryIdentifiers
- ? `${MetricDisplayNameA} (Query A)`
- : MetricDisplayNameA;
+ ? `${metricDisplayName} (Query A)`
+ : metricDisplayName;
displayName = entryName.includes(metricPart)
? entryName
: `${metricPart}, ${entryName}`;
@@ -471,8 +481,6 @@ export default function transformProps(
// When no groupby, format as just the entry name with optional query
identifier
displayName = showQueryIdentifiers ? `${entryName} (Query A)` :
entryName;
}
-
- const labelMapValues = rawLabelMap?.[seriesName];
if (labelMapValues) {
displayLabelMap[displayName] = labelMapValues;
}
@@ -536,13 +544,23 @@ export default function transformProps(
const seriesEntry = inverted[entryName] || entryName;
const colorScaleKey = getOriginalSeries(seriesEntry, array);
+ const labelMapValuesB = rawLabelMapB?.[seriesEntry];
+
let displayName: string;
if (groupbyB.length > 0) {
- // When we have groupby, format as "metric, dimension"
+ // When we have groupby, format as "metric, dimension". Each series
+ // belongs to the metric recorded in its label-map tuple
+ // ([metric, ...dimensions]) — always using the first metric would
+ // prepend it to every other metric's series (#37921). Tuples without
+ // a metric part fall back to the first metric as before.
+ const metricDisplayName =
+ labelMapValuesB && labelMapValuesB.length > 1
+ ? getMetricDisplayName(labelMapValuesB[0], verboseMap)
+ : MetricDisplayNameB;
const metricPart: string = showQueryIdentifiers
- ? `${MetricDisplayNameB} (Query B)`
- : MetricDisplayNameB;
+ ? `${metricDisplayName} (Query B)`
+ : metricDisplayName;
displayName = entryName.includes(metricPart)
? entryName
: `${metricPart}, ${entryName}`;
@@ -550,8 +568,6 @@ export default function transformProps(
// When no groupby, format as just the entry name with optional query
identifier
displayName = showQueryIdentifiers ? `${entryName} (Query B)` :
entryName;
}
-
- const labelMapValuesB = rawLabelMapB?.[seriesEntry];
if (labelMapValuesB) {
displayLabelMapB[displayName] = labelMapValuesB;
}
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 84c1765b83b..a7e6a088327 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
@@ -1161,3 +1161,83 @@ test('x-axis dedup keeps the forced min label when the
endpoints format identica
expect(formatter(min)).toBe('May');
});
+
+test('regression #37921: multi-metric Query A with groupby does not duplicate
first metric in series names', () => {
+ // Regression test for https://github.com/apache/superset/issues/37921
+ // ("Residual" follow-up to #37055).
+ //
+ // When Query A has multiple metrics + at least one Group By dimension,
+ // the display-name builder in transformProps.ts used to prepend the FIRST
+ // metric's display name to every series that didn't literally contain it:
+ // name: `${MetricDisplayNameA}, ${entryName}`
+ // For series belonging to the *second* metric, this produced a
+ // cross-contaminated label like `score_one, score_two, A` — the
+ // user-visible "first metric duplicated" symptom in the legend / tooltip.
+ // The fix derives each series' metric from its label-map tuple instead.
+ const multiMetricRows = [
+ {
+ 'score_one, A': 1,
+ 'score_one, B': 2,
+ 'score_two, A': 3,
+ 'score_two, B': 4,
+ ds: 599616000000,
+ },
+ {
+ 'score_one, A': 5,
+ 'score_one, B': 6,
+ 'score_two, A': 7,
+ 'score_two, B': 8,
+ ds: 599916000000,
+ },
+ ];
+ const multiMetricLabelMap = {
+ ds: ['ds'],
+ 'score_one, A': ['score_one', 'A'],
+ 'score_one, B': ['score_one', 'B'],
+ 'score_two, A': ['score_two', 'A'],
+ 'score_two, B': ['score_two', 'B'],
+ };
+
+ const queryAData = createTestQueryData(multiMetricRows, {
+ label_map: multiMetricLabelMap,
+ });
+ // Query B keeps the existing single-metric shape — the bug is on
+ // Query A's path so we just need a valid Query B alongside.
+ const queryBData = createTestQueryData(defaultQueryRows, {
+ label_map: defaultLabelMap,
+ });
+
+ const chartProps = createEchartsTimeseriesTestChartProps<
+ EchartsMixedTimeseriesFormData,
+ EchartsMixedTimeseriesProps
+ >({
+ ...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
+ defaultQueriesData: [queryAData, queryBData],
+ formData: {
+ ...formData,
+ metrics: ['score_one', 'score_two'],
+ groupby: ['category'],
+ },
+ queriesData: [queryAData, queryBData],
+ });
+ const transformed = transformProps(chartProps);
+
+ const queryASeriesNames = (transformed.echartOptions.series as any[])
+ .map((s: any) => String(s.name))
+ .filter((n: string) => n.includes('score_'));
+
+ // Each (metric, dim_value) combo from Query A should appear exactly once
+ // with the *correct* metric prefix — not the first-metric-prepended-to-
+ // everything-else form. Comparing the sorted array (rather than using
+ // separate toContain assertions) also catches a regression that emits
+ // duplicate series for the same name.
+ expect([...queryASeriesNames].sort()).toEqual(
+ ['score_one, A', 'score_one, B', 'score_two, A', 'score_two, B'].sort(),
+ );
+
+ // And explicitly: no series name should contain *both* metric names —
+ // that's the smoking gun for the duplication bug.
+ for (const name of queryASeriesNames) {
+ expect(name).not.toMatch(/score_one,\s+score_two/);
+ }
+});