You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/10/06 13:56:05 UTC
(superset) branch 6.2 updated: feat(chart): backport x-axis label cross-filter with dimensions to 6.2 (#44869) (#45001)
This is an automated email from the ASF dual-hosted git repository.
michael-s-molina pushed a commit to branch 6.2
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/6.2 by this push:
new 3f91970f0fc feat(chart): backport x-axis label cross-filter with dimensions to 6.2 (#44869) (#45001)
3f91970f0fc is described below
commit 3f91970f0fc4f520c4e9224d7278d179381c3447
Author: Luiz Otavio <45...@users.noreply.github.com>
AuthorDate: Tue Oct 6 10:55:56 2026 -0300
feat(chart): backport x-axis label cross-filter with dimensions to 6.2 (#44869) (#45001)
Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
.../src/Timeseries/EchartsTimeseries.test.tsx | 101 +++++++++++++++++++++
.../src/Timeseries/EchartsTimeseries.tsx | 24 ++++-
.../src/Timeseries/transformProps.ts | 7 +-
.../src/Timeseries/transformers.ts | 6 +-
.../plugins/plugin-chart-echarts/src/constants.ts | 4 +
.../test/Timeseries/transformProps.test.ts | 31 +++++++
.../test/Timeseries/transformers.test.ts | 17 ++++
7 files changed, 179 insertions(+), 11 deletions(-)
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx
index 7e8e59b71fb..385bb83be79 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx
@@ -510,6 +510,107 @@ test('does not emit duplicate cross-filter for generic axis label clicks', async
expect(setDataMaskMock).not.toHaveBeenCalled();
});
+test('emits X-axis cross-filter from categorical axis label clicks when dimensions are set', () => {
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ setDataMask={setDataMaskMock}
+ groupby={['status']}
+ labelMap={{ RESOLVED: ['RESOLVED'] }}
+ formData={{
+ ...defaultFormData,
+ groupby: ['status'],
+ orientation: OrientationType.Horizontal,
+ }}
+ xAxis={{
+ label: 'category_column',
+ type: AxisType.Category,
+ }}
+ />,
+ );
+
+ const labelClickHandler =
+ getLatestEchartProps().queryEventHandlers?.[0].handler;
+ expect(labelClickHandler).toBeDefined();
+ labelClickHandler?.({
+ value: 'Product A',
+ } as ECElementEvent);
+
+ expect(setDataMaskMock).toHaveBeenCalledTimes(1);
+ expect(setDataMaskMock.mock.calls[0][0]).toEqual({
+ extraFormData: {
+ filters: [{ col: 'category_column', op: 'IN', val: ['Product A'] }],
+ },
+ filterState: {
+ label: ['Product A'],
+ value: ['Product A'],
+ selectedValues: ['Product A'],
+ crossFilterSource: 'xAxis',
+ },
+ });
+});
+
+test('does not emit dimension cross-filter for generic axis label clicks when dimensions are set', async () => {
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ setDataMask={setDataMaskMock}
+ groupby={['status']}
+ labelMap={{ RESOLVED: ['RESOLVED'] }}
+ formData={{ ...defaultFormData, groupby: ['status'] }}
+ xAxis={{
+ label: 'category_column',
+ type: AxisType.Category,
+ }}
+ />,
+ );
+
+ const clickHandler = getLatestEchartProps().eventHandlers?.click;
+ expect(clickHandler).toBeDefined();
+ clickHandler?.({
+ componentType: 'xAxis',
+ name: 'Product A',
+ });
+
+ await new Promise(resolve => setTimeout(resolve, 400));
+ expect(setDataMaskMock).not.toHaveBeenCalled();
+});
+
+test('does not open context menu for axis labels when dimensions are set', async () => {
+ const onContextMenuMock = jest.fn();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ onContextMenu={onContextMenuMock}
+ groupby={['status']}
+ labelMap={{ RESOLVED: ['RESOLVED'] }}
+ formData={{ ...defaultFormData, groupby: ['status'] }}
+ xAxis={{
+ label: 'category_column',
+ type: AxisType.Category,
+ }}
+ />,
+ );
+
+ const contextMenuHandler = getLatestEchartProps().eventHandlers?.contextmenu;
+ expect(contextMenuHandler).toBeDefined();
+ await contextMenuHandler?.({
+ componentType: 'xAxis',
+ name: 'Product A',
+ event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
+ });
+
+ expect(onContextMenuMock).not.toHaveBeenCalled();
+});
+
test('does not emit cross-filter when no dimensions and time-based X-axis', async () => {
const setDataMaskMock = jest.fn();
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
index 1f1d45d07b6..5e80b86ff22 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
@@ -38,6 +38,7 @@ import Echart from '../components/Echart';
import { OrientationType, TimeseriesChartTransformedProps } from './types';
import { formatSeriesName } from '../utils/series';
import { ExtraControls } from '../components/ExtraControls';
+import { X_AXIS_CROSS_FILTER_SOURCE } from '../constants';
const TIMER_DURATION = 300;
@@ -186,6 +187,9 @@ export default function EchartsTimeseries({
label: values.length ? values : undefined,
value: values.length ? values : null,
selectedValues: values.length ? values : null,
+ ...(values.length && {
+ crossFilterSource: X_AXIS_CROSS_FILTER_SOURCE,
+ }),
},
},
isCurrentValueSelected: selected.includes(stringValue),
@@ -218,6 +222,9 @@ export default function EchartsTimeseries({
// Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
const canCrossFilterByXAxis =
!hasDimensions && xAxis.type === AxisType.Category;
+ // Axis labels always map to an x-axis value, so they can cross-filter by it
+ // even when dimensions are set
+ const canCrossFilterByXAxisLabel = xAxis.type === AxisType.Category;
const categoryAxisValueIndex =
formData.orientation === OrientationType.Horizontal ? 1 : 0;
const getCategoryAxisValue = useCallback(
@@ -251,9 +258,11 @@ export default function EchartsTimeseries({
// Ensure that double-click events do not trigger single click event. So we put it in the timer.
clickTimer.current = setTimeout(() => {
if (hasDimensions) {
- // Cross-filter by dimension (original behavior)
- const { seriesName: name } = props;
- handleChange(name);
+ // Cross-filter by dimension (original behavior). Axis label clicks
+ // are handled by handleXAxisLabelClick.
+ if (props.componentType === 'series') {
+ handleChange(props.seriesName);
+ }
} else if (canCrossFilterByXAxis && props.componentType === 'series') {
// Cross-filter by X-axis value when no dimensions (issue #25334)
const categoryAxisValue = getCategoryAxisValue(
@@ -285,6 +294,11 @@ export default function EchartsTimeseries({
onLegendStateChanged?.(payload.selected);
},
contextmenu: async eventParams => {
+ // Axis labels carry no series, so the dimension-based filters below
+ // cannot be built from them
+ if (hasDimensions && eventParams.componentType !== 'series') {
+ return;
+ }
if (onContextMenu) {
eventParams.event.stop();
const { data, seriesName } = eventParams;
@@ -370,13 +384,13 @@ export default function EchartsTimeseries({
(event: ECElementEvent) => {
const { value } = event;
if (
- canCrossFilterByXAxis &&
+ canCrossFilterByXAxisLabel &&
(typeof value === 'string' || typeof value === 'number')
) {
handleXAxisChange(value);
}
},
- [canCrossFilterByXAxis, handleXAxisChange],
+ [canCrossFilterByXAxisLabel, handleXAxisChange],
);
const categoryAxis =
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 703fd71a90c..1bae33ccadd 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
@@ -888,10 +888,9 @@ export default function transformProps(
name: xAxisTitle,
nameGap: convertInteger(xAxisTitleMargin),
nameLocation: 'middle',
- ...(xAxisType === AxisType.Category &&
- groupBy.length === 0 && {
- triggerEvent: true,
- }),
+ ...(xAxisType === AxisType.Category && {
+ triggerEvent: true,
+ }),
axisLabel: {
// When rotation is applied on time axes, hideOverlap can
// aggressively hide the last label. Rotated labels already
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts
index 2a5f937075b..dfccf66fa58 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts
@@ -67,6 +67,7 @@ import {
OpacityEnum,
StackControlsValue,
TIMESERIES_CONSTANTS,
+ X_AXIS_CROSS_FILTER_SOURCE,
} from '../constants';
// based on weighted wiggle algorithm
@@ -271,10 +272,11 @@ export function transformSeries(
const isConfidenceBand =
forecastSeries.type === ForecastSeriesEnum.ForecastLower ||
forecastSeries.type === ForecastSeriesEnum.ForecastUpper;
- // When cross-filtering by X-axis (no dimensions), selectedValues contains
- // X-axis values rather than series names, so skip series-level dimming.
+ // When cross-filtering by X-axis, selectedValues contains X-axis values
+ // rather than series names, so skip series-level dimming.
const isFiltered =
opts.hasDimensions !== false &&
+ filterState?.crossFilterSource !== X_AXIS_CROSS_FILTER_SOURCE &&
filterState?.selectedValues &&
!filterState?.selectedValues.includes(name);
const opacity = isFiltered
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts
index d1169f8a27d..828ce88c63d 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts
@@ -30,6 +30,10 @@ import {
// eslint-disable-next-line import/prefer-default-export
export const NULL_STRING = '<NULL>';
+// Marks a cross-filter emitted from the x-axis value, so series-level
+// selection styling is not applied to it when the chart has dimensions.
+export const X_AXIS_CROSS_FILTER_SOURCE = 'xAxis';
+
export const TIMESERIES_CONSTANTS = {
gridOffsetRight: 20,
gridOffsetLeft: 20,
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 947115f59af..290610ab1a5 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
@@ -1555,3 +1555,34 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
expect(xAxis.type).toBe(AxisType.Category);
expect(xAxis.triggerEvent).toBe(true);
});
+
+test('categorical x-axis enables trigger events when dimensions are set', () => {
+ const chartProps = createTestChartProps({
+ formData: {
+ metrics: ['metric'],
+ groupby: ['status'],
+ x_axis: 'category_column',
+ },
+ queriesData: [
+ createTestQueryData(
+ [
+ { category_column: 'Product A', 'metric, RESOLVED': 10 },
+ { category_column: 'Product B', 'metric, RESOLVED': 20 },
+ ],
+ {
+ colnames: ['category_column', 'metric, RESOLVED'],
+ coltypes: [GenericDataType.String, GenericDataType.Numeric],
+ },
+ ),
+ ],
+ });
+
+ const { echartOptions } = transformProps(chartProps);
+ const xAxis = echartOptions.xAxis as {
+ triggerEvent?: boolean;
+ type: string;
+ };
+
+ expect(xAxis.type).toBe(AxisType.Category);
+ expect(xAxis.triggerEvent).toBe(true);
+});
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts
index 54da9c14ea0..d6d24980d36 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformers.test.ts
@@ -121,6 +121,23 @@ describe('transformSeries', () => {
// OpacityEnum.NonTransparent = 1 (not dimmed)
expect((result as any).itemStyle.opacity).toBe(1);
});
+
+ test('should not dim series for an X-axis cross-filter when dimensions are set', () => {
+ const opts = {
+ filterState: {
+ selectedValues: ['Product A'],
+ crossFilterSource: 'xAxis',
+ },
+ hasDimensions: true,
+ seriesType: EchartsTimeseriesSeriesType.Bar,
+ timeShiftColor: false,
+ };
+
+ const result = transformSeries(series, mockColorScale, 'test-key', opts);
+
+ // OpacityEnum.NonTransparent = 1 (not dimmed)
+ expect((result as any).itemStyle.opacity).toBe(1);
+ });
});
describe('transformNegativeLabelsPosition', () => {