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