This is an automated email from the ASF dual-hosted git repository.

sadpandajoe 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 ef64bacbaa0 feat(chart): cross-filter by x-axis label on charts with 
dimensions (#44869)
ef64bacbaa0 is described below

commit ef64bacbaa01fc7f76d8672c283dfdaeac5cff13
Author: Luiz Otavio <[email protected]>
AuthorDate: Fri Oct 2 14:16:28 2026 -0300

    feat(chart): cross-filter by x-axis label on charts with dimensions (#44869)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../src/Timeseries/EchartsTimeseries.test.tsx      | 116 ++++++++++++++++++++-
 .../src/Timeseries/EchartsTimeseries.tsx           |  29 ++++--
 .../src/Timeseries/transformProps.ts               |   7 +-
 .../src/Timeseries/transformers.ts                 |   6 +-
 .../plugins/plugin-chart-echarts/src/constants.ts  |   4 +
 .../test/Timeseries/EchartsTimeseries.test.tsx     |  13 ++-
 .../test/Timeseries/transformProps.test.ts         |  31 ++++++
 .../test/Timeseries/transformers.test.ts           |  17 +++
 8 files changed, 207 insertions(+), 16 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 e5b54e25ec6..a8b165ac1c0 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
@@ -575,6 +575,110 @@ test('does not emit duplicate cross-filter for generic 
axis label clicks', () =>
   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?.find(
+    ({ query }) => query === 'yAxis',
+  )?.handler;
+  expect(labelClickHandler).toBeDefined();
+  labelClickHandler?.({
+    targetType: 'axisLabel',
+    value: 'Product A',
+  } as unknown 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', () => {
+  jest.useFakeTimers();
+  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',
+  });
+
+  jest.advanceTimersByTime(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('keeps temporal range exclusive ends on whole-second boundaries', () => {
   const clickedTimestamp = new Date(Date.UTC(2021, 0, 15, 12, 34, 56, 789));
 
@@ -1113,7 +1217,7 @@ test('clears temporal X-axis cross-filter when clicking 
selected bucket again',
   });
 });
 
-test('does not emit temporal X-axis label cross-filter when dimensions are 
set', () => {
+test('emits temporal X-axis label cross-filter when dimensions are set', () => 
{
   const setDataMaskMock = jest.fn();
 
   render(
@@ -1121,6 +1225,7 @@ test('does not emit temporal X-axis label cross-filter 
when dimensions are set',
       {...defaultProps}
       emitCrossFilters
       setDataMask={setDataMaskMock}
+      resolvedTimeGrain={TimeGranularity.MONTH}
       groupby={['country']}
       formData={{
         ...defaultFormData,
@@ -1144,7 +1249,14 @@ test('does not emit temporal X-axis label cross-filter 
when dimensions are set',
     value: '2021-01-01',
   } as unknown as ECElementEvent);
 
-  expect(setDataMaskMock).not.toHaveBeenCalled();
+  expect(setDataMaskMock).toHaveBeenCalledTimes(1);
+  expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+    },
+  ]);
 });
 
 test('does not emit temporal X-axis cross-filter when dimensions are set', () 
=> {
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 6cd09280a1e..52826b75f6c 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
@@ -60,7 +60,7 @@ import { getTemporalXAxisDrillByFilter } from 
'../utils/xAxisDrillByFilter';
 import { useLegendEventHandlers } from '../utils/legendEventHandlers';
 import { ExtraControls } from '../components/ExtraControls';
 import TimeseriesLegend from './TimeseriesLegend';
-import { TIMESERIES_CONSTANTS } from '../constants';
+import { TIMESERIES_CONSTANTS, X_AXIS_CROSS_FILTER_SOURCE } from 
'../constants';
 
 const TIMER_DURATION = 300;
 const MAX_CUSTOM_LEGEND_HEIGHT = 160;
@@ -418,6 +418,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),
@@ -477,6 +480,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,
@@ -520,6 +526,10 @@ export default function EchartsTimeseries({
   const canCrossFilterByXAxis =
     !hasDimensions &&
     (xAxis.type === AxisType.Category || xAxis.type === AxisType.Time);
+  // 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 || xAxis.type === AxisType.Time;
   const xAxisValueIndex =
     formData.orientation === OrientationType.Horizontal ? 1 : 0;
   const getXAxisValue = useCallback(
@@ -559,9 +569,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 &&
           xAxis.type === AxisType.Category &&
@@ -594,6 +606,11 @@ export default function EchartsTimeseries({
       onFocusedSeries(params.seriesName);
     },
     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;
@@ -740,7 +757,7 @@ export default function EchartsTimeseries({
     (event: ECElementEvent) => {
       const { value } = event;
       if (
-        canCrossFilterByXAxis &&
+        canCrossFilterByXAxisLabel &&
         event.targetType === 'axisLabel' &&
         (typeof value === 'string' || typeof value === 'number')
       ) {
@@ -755,7 +772,7 @@ export default function EchartsTimeseries({
       }
     },
     [
-      canCrossFilterByXAxis,
+      canCrossFilterByXAxisLabel,
       handleTimeAxisChange,
       handleXAxisChange,
       xAxis.type,
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 5989547d3f3..a4915dcaaf8 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
@@ -1523,10 +1523,9 @@ export default function transformProps(
     name: xAxisTitle,
     nameGap: convertInteger(xAxisTitleMargin),
     nameLocation: 'middle',
-    ...((xAxisType === AxisType.Category || xAxisType === AxisType.Time) &&
-      groupBy.length === 0 && {
-        triggerEvent: true,
-      }),
+    ...((xAxisType === AxisType.Category || xAxisType === AxisType.Time) && {
+      triggerEvent: true,
+    }),
     ...temporalAxisTickConfig,
     minorTick: { show: minorTicks },
     axisTick: {
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 6325deb9b46..340b6a662d2 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformers.ts
@@ -72,6 +72,7 @@ import {
   OpacityEnum,
   StackControlsValue,
   TIMESERIES_CONSTANTS,
+  X_AXIS_CROSS_FILTER_SOURCE,
 } from '../constants';
 
 const AUTO_LABEL_FIT_RATIO = 0.8;
@@ -470,10 +471,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 69bfdad7c7a..aa707e0d71a 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/EchartsTimeseries.test.tsx
 
b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/EchartsTimeseries.test.tsx
index 72104b286e5..493a3f7e972 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/EchartsTimeseries.test.tsx
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/EchartsTimeseries.test.tsx
@@ -528,6 +528,7 @@ test('emits TEMPORAL_RANGE cross-filter from time axis 
label click on day bucket
       label: ['2021-01-01T00:00:00 : 2021-01-02T00:00:00'],
       value: ['2021-01-01T00:00:00 : 2021-01-02T00:00:00'],
       selectedValues: ['2021-01-01T00:00:00 : 2021-01-02T00:00:00'],
+      crossFilterSource: 'xAxis',
     },
   });
 });
@@ -808,13 +809,14 @@ test('warns and skips temporal cross-filter when string 
value cannot be parsed',
   );
 });
 
-test('does not emit temporal X-axis label cross-filter when dimensions are 
set', () => {
+test('emits temporal X-axis label cross-filter when dimensions are set', () => 
{
   const setDataMask = jest.fn();
 
   renderTimeseries({
     emitCrossFilters: true,
     setDataMask,
     groupby: ['country'],
+    resolvedTimeGrain: TimeGranularity.MONTH,
     formData: {
       groupby: ['country'],
       granularitySqla: 'ds',
@@ -836,7 +838,14 @@ test('does not emit temporal X-axis label cross-filter 
when dimensions are set',
     value: '2021-01-01',
   } as unknown as ECElementEvent);
 
-  expect(setDataMask).not.toHaveBeenCalled();
+  expect(setDataMask).toHaveBeenCalledTimes(1);
+  expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+    },
+  ]);
 });
 
 test('context menu cross-filter is available for a temporal bar point', async 
() => {
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 0e22a4c5064..3a8dc585bec 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
@@ -2850,6 +2850,37 @@ test('temporal x-axis enables trigger events when no 
dimensions are set', () =>
   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);
+});
+
 test('temporal x coltype forced categorical yields a Category axis with date 
labels', () => {
   // Issue #28204: with a temporal x-axis (e.g. weekly grain) the default Time
   // scale places ticks at "nice" intervals that don't line up with the 
buckets.
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 6ac74090d59..79103c93514 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
@@ -136,6 +136,23 @@ describe('transformSeries', () => {
     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);
+  });
+
   test('should use symbolSizeFn for symbolSize when provided', () => {
     const symbolSizeFn = jest.fn(
       (value: (number | string | null)[]) => Number(value[1]) * 2,

Reply via email to