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,