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', () => {