This is an automated email from the ASF dual-hosted git repository. rusackas pushed a commit to branch fix/bar-chart-cross-filter-without-dimensions in repository https://gitbox.apache.org/repos/asf/superset.git
commit 09c44cf33c58fcf2d8e8c50f6dce92e1fcfcf205 Author: Evan Rusackas <[email protected]> AuthorDate: Fri Jan 23 14:49:41 2026 -0800 fix(chart): enable cross-filter on bar charts without dimensions When a bar chart has no dimensions set, cross-filtering was disabled entirely. Users expected clicking on a bar to filter by the X-axis category value (e.g., "Product A"), but instead the click was ignored. This change enables cross-filtering by the categorical X-axis value when no dimensions are configured: - Add getXAxisCrossFilterDataMask() to create filters using X-axis column - Add handleXAxisChange() to emit X-axis-based cross-filters - Modify click handler to use X-axis value when no dimensions but categorical X-axis exists - Update context menu to provide X-axis cross-filter option - Add tests for the new cross-filter behavior Fixes #25334 Co-Authored-By: Claude Opus 4.5 <[email protected]> --- .../src/Timeseries/EchartsTimeseries.test.tsx | 90 ++++++++++++++++++++++ .../src/Timeseries/EchartsTimeseries.tsx | 78 +++++++++++++++++-- 2 files changed, 162 insertions(+), 6 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 59cfba2319..89a0269dce 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 @@ -309,3 +309,93 @@ test('falls back to window resize listener when ResizeObserver is unavailable', addEventListenerSpy.mockRestore(); removeEventListenerSpy.mockRestore(); }); + +// Test for issue #25334: Bar chart cross-filter without dimensions +test('emits cross-filter on X-axis value when no dimensions and categorical X-axis', async () => { + const setDataMaskMock = jest.fn(); + + const propsWithCategoricalXAxis: TimeseriesChartTransformedProps = { + ...defaultProps, + emitCrossFilters: true, + setDataMask: setDataMaskMock, + groupby: [], // No dimensions + xAxis: { + label: 'category_column', + type: AxisType.Category, // Categorical X-axis + }, + }; + + render(<EchartsTimeseries {...propsWithCategoricalXAxis} />); + + // Get the click handler from the mock + const lastCall = mockEchart.mock.calls.at(-1); + expect(lastCall).toBeDefined(); + const [props] = lastCall as [EchartsProps]; + expect(props.eventHandlers).toBeDefined(); + expect(props.eventHandlers?.click).toBeDefined(); + + // Simulate a click event with X-axis data + const clickHandler = props.eventHandlers?.click; + if (clickHandler) { + clickHandler({ + seriesName: 'Sales', // This is the metric name + data: ['Product A', 100], // X-axis value is 'Product A' + name: 'Product A', + dataIndex: 0, + }); + + // Wait for the timer (TIMER_DURATION = 300ms) + await waitFor( + () => { + expect(setDataMaskMock).toHaveBeenCalled(); + }, + { timeout: 500 }, + ); + + // Verify the cross-filter uses the X-axis column and value, not the metric + const dataMaskCall = setDataMaskMock.mock.calls[0][0]; + expect(dataMaskCall.extraFormData.filters).toEqual([ + { + col: 'category_column', // X-axis column + op: 'IN', + val: ['Product A'], // X-axis value, not 'Sales' (metric) + }, + ]); + } +}); + +test('does not emit cross-filter when no dimensions and time-based X-axis', async () => { + const setDataMaskMock = jest.fn(); + + const propsWithTimeXAxis: TimeseriesChartTransformedProps = { + ...defaultProps, + emitCrossFilters: true, + setDataMask: setDataMaskMock, + groupby: [], // No dimensions + xAxis: { + label: '__timestamp', + type: AxisType.Time, // Time-based X-axis (not categorical) + }, + }; + + render(<EchartsTimeseries {...propsWithTimeXAxis} />); + + const lastCall = mockEchart.mock.calls.at(-1); + expect(lastCall).toBeDefined(); + const [props] = lastCall as [EchartsProps]; + + // Simulate a click event + const clickHandler = props.eventHandlers?.click; + if (clickHandler) { + clickHandler({ + seriesName: 'Sales', + data: [1609459200000, 100], // Timestamp + name: '2021-01-01', + dataIndex: 0, + }); + + // Wait a bit and verify setDataMask was NOT called + await new Promise(resolve => setTimeout(resolve, 400)); + expect(setDataMaskMock).not.toHaveBeenCalled(); + } +}); 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 b14b7fb6ba..2706d98104 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx @@ -154,6 +154,43 @@ export default function EchartsTimeseries({ [groupby, labelMap, selectedValues], ); + // Cross-filter using X-axis value when no dimensions are set (issue #25334) + const getXAxisCrossFilterDataMask = useCallback( + (xAxisValue: string | number) => { + const stringValue = String(xAxisValue); + const selected: string[] = Object.values(selectedValues); + let values: string[]; + if (selected.includes(stringValue)) { + values = selected.filter(v => v !== stringValue); + } else { + values = [stringValue]; + } + return { + dataMask: { + extraFormData: { + filters: + values.length === 0 + ? [] + : [ + { + col: xAxis.label, + op: 'IN' as const, + val: values, + }, + ], + }, + filterState: { + label: values.length ? values : undefined, + value: values.length ? values : null, + selectedValues: values.length ? values : null, + }, + }, + isCurrentValueSelected: selected.includes(stringValue), + }; + }, + [selectedValues, xAxis.label], + ); + const handleChange = useCallback( (value: string) => { if (!emitCrossFilters) { @@ -164,9 +201,25 @@ export default function EchartsTimeseries({ [emitCrossFilters, setDataMask, getCrossFilterDataMask], ); + // Handle cross-filter using X-axis value when no dimensions (issue #25334) + const handleXAxisChange = useCallback( + (xAxisValue: string | number) => { + if (!emitCrossFilters) { + return; + } + setDataMask(getXAxisCrossFilterDataMask(xAxisValue).dataMask); + }, + [emitCrossFilters, setDataMask, getXAxisCrossFilterDataMask], + ); + + // Determine if X-axis can be used for cross-filtering (categorical axis without dimensions) + const canCrossFilterByXAxis = + !hasDimensions && xAxis.type === AxisType.Category; + const eventHandlers: EventHandlers = { click: props => { - if (!hasDimensions) { + // Allow cross-filter by dimensions OR by categorical X-axis (issue #25334) + if (!hasDimensions && !canCrossFilterByXAxis) { return; } if (clickTimer.current) { @@ -174,8 +227,15 @@ 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(() => { - const { seriesName: name } = props; - handleChange(name); + if (hasDimensions) { + // Cross-filter by dimension (original behavior) + const { seriesName: name } = props; + handleChange(name); + } else if (canCrossFilterByXAxis && props.data) { + // Cross-filter by X-axis value when no dimensions (issue #25334) + const xAxisValue = props.data[0]; + handleXAxisChange(xAxisValue); + } }, TIMER_DURATION); }, mouseout: () => { @@ -252,12 +312,18 @@ export default function EchartsTimeseries({ }); }); + // Provide cross-filter for dimensions OR categorical X-axis (issue #25334) + let crossFilter; + if (hasDimensions) { + crossFilter = getCrossFilterDataMask(seriesName); + } else if (canCrossFilterByXAxis && data) { + crossFilter = getXAxisCrossFilterDataMask(data[0]); + } + onContextMenu(pointerEvent.clientX, pointerEvent.clientY, { drillToDetail: drillToDetailFilters, drillBy: { filters: drillByFilters, groupbyFieldName: 'groupby' }, - crossFilter: hasDimensions - ? getCrossFilterDataMask(seriesName) - : undefined, + crossFilter, }); } },
