You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/06/24 08:17:37 UTC

(superset) branch master updated: feat(chart): enable cross-filter on x-axis labels for bar, line, area and scatter charts (#41111)

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 8f22b718985 feat(chart): enable cross-filter on x-axis labels for bar, line, area and scatter charts (#41111)
8f22b718985 is described below

commit 8f22b718985bb46ad73e72b615a94679e371917d
Author: abdullah reveha <15...@users.noreply.github.com>
AuthorDate: Wed Jun 24 11:17:29 2026 +0300

    feat(chart): enable cross-filter on x-axis labels for bar, line, area and scatter charts (#41111)
    
    Co-authored-by: Abdullah Sahin <yo...@example.comclear>
---
 .../src/Timeseries/EchartsTimeseries.test.tsx      | 164 ++++++++++++++-
 .../src/Timeseries/EchartsTimeseries.tsx           |  87 ++++++--
 .../src/Timeseries/transformProps.ts               |   4 +
 .../src/components/Echart.test.tsx                 | 223 +++++++++++++++++++++
 .../plugin-chart-echarts/src/components/Echart.tsx |  27 ++-
 .../plugins/plugin-chart-echarts/src/types.ts      |   8 +
 .../test/Timeseries/transformProps.test.ts         |   6 +-
 7 files changed, 501 insertions(+), 18 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 e8f5634948d..8bcb799d396 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
@@ -23,6 +23,7 @@ import {
 } from '../../../../spec/helpers/testing-library';
 import { AxisType } from '@superset-ui/core';
 import type { EChartsCoreOption } from 'echarts/core';
+import type { ECElementEvent } from 'echarts/types/src/util/types';
 import type { ReactNode } from 'react';
 import {
   LegendOrientation,
@@ -202,11 +203,15 @@ const defaultProps: TimeseriesChartTransformedProps = {
   onFocusedSeries: jest.fn(),
 };
 
-function getLatestHeight() {
+function getLatestEchartProps() {
   const lastCall = mockEchart.mock.calls.at(-1);
   expect(lastCall).toBeDefined();
   const [props] = lastCall as [EchartsProps];
-  return props.height;
+  return props;
+}
+
+function getLatestHeight() {
+  return getLatestEchartProps().height;
 }
 
 test('observes extra control height changes when ResizeObserver is available', async () => {
@@ -335,6 +340,7 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
   const clickHandler = props.eventHandlers?.click;
   if (clickHandler) {
     clickHandler({
+      componentType: 'series',
       seriesName: 'Sales', // This is the metric name
       data: ['Product A', 100], // X-axis value is 'Product A'
       name: 'Product A',
@@ -361,6 +367,149 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
   }
 });
 
+test('emits cross-filter on category value for horizontal bar clicks', async () => {
+  const setDataMaskMock = jest.fn();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      formData={{
+        ...defaultFormData,
+        orientation: OrientationType.Horizontal,
+      }}
+      xAxis={{
+        label: 'category_column',
+        type: AxisType.Category,
+      }}
+    />,
+  );
+
+  const clickHandler = getLatestEchartProps().eventHandlers?.click;
+  expect(clickHandler).toBeDefined();
+  clickHandler?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: [100, 'Product A'],
+    name: 'Product A',
+    dataIndex: 0,
+  });
+
+  await waitFor(
+    () => {
+      expect(setDataMaskMock).toHaveBeenCalled();
+    },
+    { timeout: 500 },
+  );
+
+  expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'category_column',
+      op: 'IN',
+      val: ['Product A'],
+    },
+  ]);
+});
+
+test('uses rendered categorical axis for query event handlers', () => {
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      xAxis={{
+        label: 'category_column',
+        type: AxisType.Category,
+      }}
+    />,
+  );
+
+  expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
+    'xAxis.category',
+  );
+
+  cleanup();
+  mockEchart.mockReset();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      formData={{
+        ...defaultFormData,
+        orientation: OrientationType.Horizontal,
+      }}
+      xAxis={{
+        label: 'category_column',
+        type: AxisType.Category,
+      }}
+    />,
+  );
+
+  expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
+    'yAxis.category',
+  );
+});
+
+test('emits cross-filter from horizontal categorical axis label clicks', () => {
+  const setDataMaskMock = jest.fn();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      formData={{
+        ...defaultFormData,
+        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.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'category_column',
+      op: 'IN',
+      val: ['Product A'],
+    },
+  ]);
+});
+
+test('does not emit duplicate cross-filter for generic axis label clicks', async () => {
+  const setDataMaskMock = jest.fn();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      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 emit cross-filter when no dimensions and time-based X-axis', async () => {
   const setDataMaskMock = jest.fn();
 
@@ -385,6 +534,7 @@ test('does not emit cross-filter when no dimensions and time-based X-axis', asyn
   const clickHandler = props.eventHandlers?.click;
   if (clickHandler) {
     clickHandler({
+      componentType: 'series',
       seriesName: 'Sales',
       data: [1609459200000, 100], // Timestamp
       name: '2021-01-01',
@@ -407,6 +557,10 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
     ...defaultProps,
     emitCrossFilters: true,
     setDataMask: setDataMaskMock,
+    formData: {
+      ...defaultFormData,
+      orientation: OrientationType.Horizontal,
+    },
     groupby: [], // No dimensions
     xAxis: {
       label: 'category_column',
@@ -423,6 +577,7 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
   const clickHandler = props.eventHandlers?.click;
   if (clickHandler) {
     clickHandler({
+      componentType: 'series',
       seriesName: 'Sales', // This is the metric name
       data: [100, 'Product A'], // Horizontal: value first, category second
       name: 'Product A',
@@ -457,6 +612,10 @@ test('context menu cross-filter uses the category value for a horizontal categor
     ...defaultProps,
     emitCrossFilters: true,
     onContextMenu: onContextMenuMock,
+    formData: {
+      ...defaultFormData,
+      orientation: OrientationType.Horizontal,
+    },
     groupby: [], // No dimensions
     xAxis: {
       label: 'category_column',
@@ -474,6 +633,7 @@ test('context menu cross-filter uses the category value for a horizontal categor
   expect(contextMenuHandler).toBeDefined();
   if (contextMenuHandler) {
     await contextMenuHandler({
+      componentType: 'series',
       seriesName: 'Sales', // This is the metric name
       data: [100, 'Product A'], // Horizontal: value first, category second
       name: 'Product A',
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 5fcc1021993..7011128b03d 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import {
   DTTM_ALIAS,
   BinaryQueryObjectFilterClause,
@@ -27,12 +27,15 @@ import {
   LegendState,
   ensureIsArray,
 } from '@superset-ui/core';
-import type { ViewRootGroup } from 'echarts/types/src/util/types';
+import type {
+  ECElementEvent,
+  ViewRootGroup,
+} from 'echarts/types/src/util/types';
 import type GlobalModel from 'echarts/types/src/model/Global';
 import type ComponentModel from 'echarts/types/src/model/Component';
 import { EchartsHandler, EventHandlers } from '../types';
 import Echart from '../components/Echart';
-import { TimeseriesChartTransformedProps } from './types';
+import { OrientationType, TimeseriesChartTransformedProps } from './types';
 import { formatSeriesName } from '../utils/series';
 import { ExtraControls } from '../components/ExtraControls';
 
@@ -218,6 +221,26 @@ 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;
+  const categoryAxisValueIndex =
+    formData.orientation === OrientationType.Horizontal ? 1 : 0;
+  const getCategoryAxisValue = useCallback(
+    (data: unknown, name: unknown) => {
+      if (Array.isArray(data)) {
+        const categoryAxisValue = data[categoryAxisValueIndex];
+        if (
+          typeof categoryAxisValue === 'string' ||
+          typeof categoryAxisValue === 'number'
+        ) {
+          return categoryAxisValue;
+        }
+      }
+      if (typeof name === 'string' || typeof name === 'number') {
+        return name;
+      }
+      return undefined;
+    },
+    [categoryAxisValueIndex],
+  );
 
   const eventHandlers: EventHandlers = {
     click: props => {
@@ -234,12 +257,15 @@ export default function EchartsTimeseries({
           // Cross-filter by dimension (original behavior)
           const { seriesName: name } = props;
           handleChange(name);
-        } else if (canCrossFilterByXAxis && props.name != null) {
-          // Cross-filter by X-axis value when no dimensions (issue #25334).
-          // Use `name` (the category-axis value) instead of `data[0]`: for
-          // horizontal bars the data tuple is value-first, so `data[0]` would
-          // be the metric value rather than the category (issue #41102).
-          handleXAxisChange(props.name);
+        } else if (canCrossFilterByXAxis && props.componentType === 'series') {
+          // Cross-filter by X-axis value when no dimensions (issue #25334)
+          const categoryAxisValue = getCategoryAxisValue(
+            props.data,
+            props.name,
+          );
+          if (categoryAxisValue !== undefined) {
+            handleXAxisChange(categoryAxisValue);
+          }
         }
       }, TIMER_DURATION);
     },
@@ -321,10 +347,17 @@ export default function EchartsTimeseries({
         let crossFilter;
         if (hasDimensions) {
           crossFilter = getCrossFilterDataMask(seriesName);
-        } else if (canCrossFilterByXAxis && eventParams.name != null) {
-          // Use `name` (the category-axis value), not `data[0]`, so horizontal
-          // bars cross-filter on the category and not the metric (issue #41102).
-          crossFilter = getXAxisCrossFilterDataMask(eventParams.name);
+        } else if (
+          canCrossFilterByXAxis &&
+          eventParams.componentType === 'series'
+        ) {
+          const categoryAxisValue = getCategoryAxisValue(
+            data,
+            eventParams.name,
+          );
+          if (categoryAxisValue !== undefined) {
+            crossFilter = getXAxisCrossFilterDataMask(categoryAxisValue);
+          }
         }
 
         onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
@@ -336,6 +369,33 @@ export default function EchartsTimeseries({
     },
   };
 
+  const handleXAxisLabelClick = useCallback(
+    (event: ECElementEvent) => {
+      const { value } = event;
+      if (
+        canCrossFilterByXAxis &&
+        (typeof value === 'string' || typeof value === 'number')
+      ) {
+        handleXAxisChange(value);
+      }
+    },
+    [canCrossFilterByXAxis, handleXAxisChange],
+  );
+
+  const categoryAxis =
+    formData.orientation === OrientationType.Horizontal ? 'yAxis' : 'xAxis';
+
+  const queryEventHandlers = useMemo(
+    () => [
+      {
+        name: 'click',
+        query: `${categoryAxis}.category`,
+        handler: handleXAxisLabelClick,
+      },
+    ],
+    [categoryAxis, handleXAxisLabelClick],
+  );
+
   const zrEventHandlers: EventHandlers = {
     dblclick: params => {
       // clear single click timer
@@ -377,6 +437,7 @@ export default function EchartsTimeseries({
         width={width}
         echartOptions={echartOptions}
         eventHandlers={eventHandlers}
+        queryEventHandlers={queryEventHandlers}
         zrEventHandlers={zrEventHandlers}
         selectedValues={selectedValues}
         vizType={formData.vizType}
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 4a0b8f5baf6..bee2137b059 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
@@ -889,6 +889,10 @@ export default function transformProps(
     name: xAxisTitle,
     nameGap: convertInteger(xAxisTitleMargin),
     nameLocation: 'middle',
+    ...(xAxisType === AxisType.Category &&
+      groupBy.length === 0 && {
+        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/components/Echart.test.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.test.tsx
new file mode 100644
index 00000000000..110b4214fd6
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.test.tsx
@@ -0,0 +1,223 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render, waitFor } from '../../../../spec/helpers/testing-library';
+import type { EChartsCoreOption } from 'echarts/core';
+import Echart from './Echart';
+import type { EchartsProps } from '../types';
+
+type Handler = (params: unknown) => void;
+type Listener = {
+  query?: string;
+  handler: Handler;
+};
+
+const listeners: Record<string, Listener[]> = {};
+
+const mockChart = {
+  dispatchAction: jest.fn(),
+  dispose: jest.fn(),
+  getOption: jest.fn(() => ({})),
+  getZr: jest.fn(() => ({
+    off: jest.fn(),
+    on: jest.fn(),
+  })),
+  off: jest.fn((name: string, handler?: Handler) => {
+    if (!handler) {
+      delete listeners[name];
+      return;
+    }
+    listeners[name] = (listeners[name] || []).filter(
+      listener => listener.handler !== handler,
+    );
+  }),
+  on: jest.fn(
+    (name: string, queryOrHandler: string | Handler, handler?: Handler) => {
+      listeners[name] = listeners[name] || [];
+      listeners[name].push(
+        handler
+          ? { query: queryOrHandler as string, handler }
+          : { handler: queryOrHandler as Handler },
+      );
+    },
+  ),
+  resize: jest.fn(),
+  setOption: jest.fn(),
+};
+
+jest.mock('echarts/core', () => ({
+  init: jest.fn(() => mockChart),
+  registerLocale: jest.fn(),
+  use: jest.fn(),
+}));
+
+jest.mock('echarts/charts', () => ({
+  BarChart: 'BarChart',
+  BoxplotChart: 'BoxplotChart',
+  CustomChart: 'CustomChart',
+  FunnelChart: 'FunnelChart',
+  GaugeChart: 'GaugeChart',
+  GraphChart: 'GraphChart',
+  HeatmapChart: 'HeatmapChart',
+  LineChart: 'LineChart',
+  PieChart: 'PieChart',
+  RadarChart: 'RadarChart',
+  SankeyChart: 'SankeyChart',
+  ScatterChart: 'ScatterChart',
+  SunburstChart: 'SunburstChart',
+  TreeChart: 'TreeChart',
+  TreemapChart: 'TreemapChart',
+}));
+
+jest.mock('echarts/components', () => ({
+  AriaComponent: 'AriaComponent',
+  DataZoomComponent: 'DataZoomComponent',
+  GraphicComponent: 'GraphicComponent',
+  GridComponent: 'GridComponent',
+  LegendComponent: 'LegendComponent',
+  MarkAreaComponent: 'MarkAreaComponent',
+  MarkLineComponent: 'MarkLineComponent',
+  TitleComponent: 'TitleComponent',
+  ToolboxComponent: 'ToolboxComponent',
+  TooltipComponent: 'TooltipComponent',
+  VisualMapComponent: 'VisualMapComponent',
+}));
+
+jest.mock('echarts/features', () => ({
+  LabelLayout: 'LabelLayout',
+}));
+
+jest.mock('echarts/renderers', () => ({
+  CanvasRenderer: 'CanvasRenderer',
+}));
+
+const initialState = {
+  common: {
+    locale: 'en',
+  },
+  dashboardState: {
+    isRefreshing: false,
+  },
+};
+
+const defaultProps: EchartsProps = {
+  echartOptions: { series: [] } as EChartsCoreOption,
+  height: 100,
+  refs: {},
+  width: 100,
+};
+
+const renderEchart = (props: Partial<EchartsProps> = {}) => (
+  <Echart {...defaultProps} {...props} />
+);
+
+const trigger = (name: string) => {
+  (listeners[name] || []).forEach(listener => listener.handler({}));
+};
+
+beforeEach(() => {
+  Object.keys(listeners).forEach(name => {
+    delete listeners[name];
+  });
+  Object.values(mockChart).forEach(value => {
+    if (jest.isMockFunction(value)) {
+      value.mockClear();
+    }
+  });
+});
+
+test('replaces stale query event handlers without clearing regular event handlers', async () => {
+  const regularClickHandler = jest.fn();
+  const firstQueryHandler = jest.fn();
+  const secondQueryHandler = jest.fn();
+
+  const { rerender } = render(
+    renderEchart({
+      eventHandlers: {
+        click: regularClickHandler,
+      },
+      queryEventHandlers: [
+        {
+          handler: firstQueryHandler,
+          name: 'click',
+          query: 'xAxis.category',
+        },
+      ],
+    }),
+    { initialState, useRedux: true },
+  );
+
+  await waitFor(() =>
+    expect(mockChart.on).toHaveBeenCalledWith(
+      'click',
+      'xAxis.category',
+      firstQueryHandler,
+    ),
+  );
+
+  rerender(
+    renderEchart({
+      eventHandlers: {
+        click: regularClickHandler,
+      },
+      queryEventHandlers: [
+        {
+          handler: secondQueryHandler,
+          name: 'click',
+          query: 'xAxis.category',
+        },
+      ],
+    }),
+  );
+
+  await waitFor(() =>
+    expect(mockChart.on).toHaveBeenCalledWith(
+      'click',
+      'xAxis.category',
+      secondQueryHandler,
+    ),
+  );
+
+  trigger('click');
+
+  expect(regularClickHandler).toHaveBeenCalledTimes(1);
+  expect(firstQueryHandler).not.toHaveBeenCalled();
+  expect(secondQueryHandler).toHaveBeenCalledTimes(1);
+
+  regularClickHandler.mockClear();
+  secondQueryHandler.mockClear();
+
+  rerender(
+    renderEchart({
+      eventHandlers: {
+        click: regularClickHandler,
+      },
+      queryEventHandlers: [],
+    }),
+  );
+
+  await waitFor(() =>
+    expect(mockChart.off).toHaveBeenCalledWith('click', secondQueryHandler),
+  );
+
+  trigger('click');
+
+  expect(regularClickHandler).toHaveBeenCalledTimes(1);
+  expect(firstQueryHandler).not.toHaveBeenCalled();
+  expect(secondQueryHandler).not.toHaveBeenCalled();
+});
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
index 618cb62142c..52111fd4f86 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
@@ -64,7 +64,12 @@ import {
   MarkLineComponent,
 } from 'echarts/components';
 import { LabelLayout } from 'echarts/features';
-import { EchartsHandler, EchartsProps, EchartsStylesProps } from '../types';
+import {
+  EchartsHandler,
+  EchartsProps,
+  EchartsStylesProps,
+  QueryEventHandlers,
+} from '../types';
 import { DEFAULT_LOCALE } from '../constants';
 import { mergeEchartsThemeOverrides } from '../utils/themeOverrides';
 
@@ -132,6 +137,7 @@ function Echart(
     height,
     echartOptions,
     eventHandlers,
+    queryEventHandlers,
     zrEventHandlers,
     selectedValues = {},
     refs,
@@ -147,6 +153,7 @@ function Echart(
   }
   const [didMount, setDidMount] = useState(false);
   const chartRef = useRef<EChartsType>();
+  const previousQueryEventHandlers = useRef<QueryEventHandlers>([]);
   const currentSelection = useMemo(
     () => Object.keys(selectedValues) || [],
     [selectedValues],
@@ -196,11 +203,19 @@ function Echart(
 
   useEffect(() => {
     if (didMount) {
+      previousQueryEventHandlers.current.forEach(({ name, handler }) => {
+        chartRef.current?.off(name, handler);
+      });
       Object.entries(eventHandlers || {}).forEach(([name, handler]) => {
         chartRef.current?.off(name);
         chartRef.current?.on(name, handler);
       });
 
+      (queryEventHandlers || []).forEach(({ name, query, handler }) => {
+        chartRef.current?.on(name, query, handler);
+      });
+      previousQueryEventHandlers.current = queryEventHandlers || [];
+
       Object.entries(zrEventHandlers || {}).forEach(([name, handler]) => {
         chartRef.current?.getZr().off(name);
         chartRef.current?.getZr().on(name, handler);
@@ -336,7 +351,15 @@ function Echart(
       }
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
-  }, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]);
+  }, [
+    didMount,
+    echartOptions,
+    eventHandlers,
+    queryEventHandlers,
+    zrEventHandlers,
+    theme,
+    vizType,
+  ]);
 
   // Clear tooltip on refresh start to avoid stale content (#39247)
   useEffect(() => {
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts
index 2656bf0a678..a5c54541785 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts
@@ -34,6 +34,7 @@ import {
 } from '@superset-ui/core';
 import type { EChartsCoreOption, EChartsType } from 'echarts/core';
 import type { TooltipMarker } from 'echarts/types/src/util/format';
+import type { ECElementEvent } from 'echarts/types/src/util/types';
 import { StackControlsValue } from './constants';
 
 export type EchartsStylesProps = {
@@ -51,6 +52,7 @@ export interface EchartsProps {
   width: number;
   echartOptions: EChartsCoreOption;
   eventHandlers?: EventHandlers;
+  queryEventHandlers?: QueryEventHandlers;
   zrEventHandlers?: EventHandlers;
   selectedValues?: Record<number, string>;
   forceClear?: boolean;
@@ -105,6 +107,12 @@ export type LegendFormData = {
 
 export type EventHandlers = Record<string, { (props: any): void }>;
 
+export type QueryEventHandlers = {
+  name: string;
+  query: string;
+  handler: (props: ECElementEvent) => void;
+}[];
+
 export enum LabelPositionEnum {
   Top = 'top',
   Left = 'left',
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 e3b8724951c..f4c0ecc4ed3 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
@@ -1564,9 +1564,13 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
   });
 
   const { echartOptions } = transformProps(chartProps);
-  const xAxis = echartOptions.xAxis as { type: string };
+  const xAxis = echartOptions.xAxis as {
+    triggerEvent?: boolean;
+    type: string;
+  };
 
   expect(xAxis.type).toBe(AxisType.Category);
+  expect(xAxis.triggerEvent).toBe(true);
 });
 
 test('temporal x coltype wires the time formatter and Time axis', () => {