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/08/21 22:45:13 UTC
(superset) branch master updated: feat(chart): enable cross-filter on temporal x-axis (bar/label click) (#42792)
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 09bd8c98de2 feat(chart): enable cross-filter on temporal x-axis (bar/label click) (#42792)
09bd8c98de2 is described below
commit 09bd8c98de214e27a1ff8e06be70ca68b62b4106
Author: abdullah reveha <15...@users.noreply.github.com>
AuthorDate: Sat Aug 22 01:45:06 2026 +0300
feat(chart): enable cross-filter on temporal x-axis (bar/label click) (#42792)
Co-authored-by: Abdullah Sahin <yo...@example.comclear>
---
.../superset-ui-core/src/time-format/index.ts | 1 +
.../src/Timeseries/EchartsTimeseries.test.tsx | 736 +++++++++++++++++++--
.../src/Timeseries/EchartsTimeseries.tsx | 205 ++++--
.../src/Timeseries/transformProps.ts | 3 +-
.../plugin-chart-echarts/src/Timeseries/types.ts | 1 +
.../test/Timeseries/EchartsTimeseries.test.tsx | 426 +++++++++++-
.../test/Timeseries/transformProps.test.ts | 35 +
7 files changed, 1313 insertions(+), 94 deletions(-)
diff --git a/superset-frontend/packages/superset-ui-core/src/time-format/index.ts b/superset-frontend/packages/superset-ui-core/src/time-format/index.ts
index 5b01bbca77b..e6023a02b59 100644
--- a/superset-frontend/packages/superset-ui-core/src/time-format/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/time-format/index.ts
@@ -49,5 +49,6 @@ export { default as finestTemporalGrainFormatter } from './formatters/finestTemp
export { default as normalizeTimestamp } from './utils/normalizeTimestamp';
export { default as denormalizeTimestamp } from './utils/denormalizeTimestamp';
+export { default as createTimeRangeFromGranularity } from './utils/createTimeRangeFromGranularity';
export * from './types';
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 7829f18ac3d..2d2a23e95b5 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
@@ -21,8 +21,14 @@ import {
waitFor,
cleanup,
} from '../../../../spec/helpers/testing-library';
-import { AxisType, TimeGranularity } from '@superset-ui/core';
+import {
+ AxisType,
+ createTimeRangeFromGranularity,
+ DTTM_ALIAS,
+ TimeGranularity,
+} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
+import { logging } from '@apache-superset/core/utils';
import type { EChartsCoreOption } from 'echarts/core';
import type { ECElementEvent } from 'echarts/types/src/util/types';
import type { ReactNode } from 'react';
@@ -93,12 +99,17 @@ afterAll(() => {
});
afterEach(() => {
+ jest.useRealTimers();
cleanup();
mockEchart.mockReset();
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
originalResizeObserver;
});
+const advanceClickTimer = () => {
+ jest.advanceTimersByTime(300);
+};
+
const defaultFormData: EchartsTimeseriesFormData & {
vizType: string;
dateFormat: string;
@@ -314,7 +325,8 @@ test('falls back to window resize listener when ResizeObserver is unavailable',
});
// 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 () => {
+test('emits cross-filter on X-axis value when no dimensions and categorical X-axis', () => {
+ jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const propsWithCategoricalXAxis: TimeseriesChartTransformedProps = {
@@ -348,13 +360,7 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
dataIndex: 0,
});
- // Wait for the timer (TIMER_DURATION = 300ms)
- await waitFor(
- () => {
- expect(setDataMaskMock).toHaveBeenCalled();
- },
- { timeout: 500 },
- );
+ advanceClickTimer();
// Verify the cross-filter uses the X-axis column and value, not the metric
const dataMaskCall = setDataMaskMock.mock.calls[0][0];
@@ -368,7 +374,8 @@ 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 () => {
+test('emits cross-filter on category value for horizontal bar clicks', () => {
+ jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
@@ -397,12 +404,7 @@ test('emits cross-filter on category value for horizontal bar clicks', async ()
dataIndex: 0,
});
- await waitFor(
- () => {
- expect(setDataMaskMock).toHaveBeenCalled();
- },
- { timeout: 500 },
- );
+ advanceClickTimer();
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
@@ -424,9 +426,7 @@ test('uses rendered categorical axis for query event handlers', () => {
/>,
);
- expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
- 'xAxis.category',
- );
+ expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe('xAxis');
cleanup();
mockEchart.mockReset();
@@ -445,9 +445,7 @@ test('uses rendered categorical axis for query event handlers', () => {
/>,
);
- expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
- 'yAxis.category',
- );
+ expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe('yAxis');
});
test('emits cross-filter from horizontal categorical axis label clicks', () => {
@@ -473,8 +471,9 @@ test('emits cross-filter from horizontal categorical axis label clicks', () => {
getLatestEchartProps().queryEventHandlers?.[0].handler;
expect(labelClickHandler).toBeDefined();
labelClickHandler?.({
+ targetType: 'axisLabel',
value: 'Product A',
- } as ECElementEvent);
+ } as unknown as ECElementEvent);
expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
{
@@ -485,7 +484,8 @@ test('emits cross-filter from horizontal categorical axis label clicks', () => {
]);
});
-test('does not emit duplicate cross-filter for generic axis label clicks', async () => {
+test('does not emit duplicate cross-filter for generic axis label clicks', () => {
+ jest.useFakeTimers();
const setDataMaskMock = jest.fn();
render(
@@ -507,11 +507,28 @@ test('does not emit duplicate cross-filter for generic axis label clicks', async
name: 'Product A',
});
- await new Promise(resolve => setTimeout(resolve, 400));
+ jest.advanceTimersByTime(400);
expect(setDataMaskMock).not.toHaveBeenCalled();
});
-test('does not emit cross-filter when no dimensions and time-based X-axis', async () => {
+test('keeps temporal range exclusive ends on whole-second boundaries', () => {
+ const clickedTimestamp = new Date(Date.UTC(2021, 0, 15, 12, 34, 56, 789));
+
+ [TimeGranularity.DAY, TimeGranularity.MONTH, TimeGranularity.YEAR].forEach(
+ grain => {
+ const [, inclusiveEnd] = createTimeRangeFromGranularity(
+ clickedTimestamp,
+ grain,
+ false,
+ );
+ const exclusiveEnd = new Date(inclusiveEnd.getTime() + 1);
+
+ expect(exclusiveEnd.getUTCMilliseconds()).toBe(0);
+ },
+ );
+});
+
+test('emits TEMPORAL_RANGE cross-filter from time axis label click on day bucket', () => {
const setDataMaskMock = jest.fn();
const propsWithTimeXAxis: TimeseriesChartTransformedProps = {
@@ -519,39 +536,610 @@ test('does not emit cross-filter when no dimensions and time-based X-axis', asyn
emitCrossFilters: true,
setDataMask: setDataMaskMock,
groupby: [], // No dimensions
+ resolvedTimeGrain: TimeGranularity.DAY,
+ formData: {
+ ...defaultFormData,
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.DAY,
+ },
xAxis: {
- label: '__timestamp',
- type: AxisType.Time, // Time-based X-axis (not categorical)
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
},
};
render(<EchartsTimeseries {...propsWithTimeXAxis} />);
- const lastCall = mockEchart.mock.calls.at(-1);
- expect(lastCall).toBeDefined();
- const [props] = lastCall as [EchartsProps];
+ const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+ ({ query }) => query === 'xAxis',
+ )?.handler;
+ expect(labelClickHandler).toBeDefined();
+ labelClickHandler?.({
+ targetType: 'axisLabel',
+ value: '2021-01-01',
+ } as unknown as ECElementEvent);
- // Simulate a click event
- const clickHandler = props.eventHandlers?.click;
- if (clickHandler) {
- clickHandler({
- componentType: 'series',
- seriesName: 'Sales',
- data: [1609459200000, 100], // Timestamp
- name: '2021-01-01',
- dataIndex: 0,
- });
+ expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
+ {
+ col: 'ds',
+ op: 'TEMPORAL_RANGE',
+ val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
+ },
+ ]);
+});
- // Wait a bit and verify setDataMask was NOT called
- await new Promise(resolve => setTimeout(resolve, 400));
- expect(setDataMaskMock).not.toHaveBeenCalled();
- }
+test('emits TEMPORAL_RANGE cross-filter from time axis label click on month bucket', () => {
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ setDataMask={setDataMaskMock}
+ groupby={[]}
+ resolvedTimeGrain={TimeGranularity.MONTH}
+ formData={{
+ ...defaultFormData,
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ }}
+ xAxis={{
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ }}
+ />,
+ );
+
+ const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+ ({ query }) => query === 'xAxis',
+ )?.handler;
+ expect(labelClickHandler).toBeDefined();
+ labelClickHandler?.({
+ targetType: 'axisLabel',
+ value: '2021-01-01',
+ } as unknown as ECElementEvent);
+
+ 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('emits TEMPORAL_RANGE cross-filter from time axis label click on year bucket', () => {
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ setDataMask={setDataMaskMock}
+ groupby={[]}
+ resolvedTimeGrain={TimeGranularity.YEAR}
+ formData={{
+ ...defaultFormData,
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.YEAR,
+ }}
+ xAxis={{
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ }}
+ />,
+ );
+
+ const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+ ({ query }) => query === 'xAxis',
+ )?.handler;
+ expect(labelClickHandler).toBeDefined();
+ labelClickHandler?.({
+ targetType: 'axisLabel',
+ value: '2021-01-01',
+ } as unknown as ECElementEvent);
+
+ expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
+ {
+ col: 'ds',
+ op: 'TEMPORAL_RANGE',
+ val: '2021-01-01T00:00:00 : 2022-01-01T00:00:00',
+ },
+ ]);
+});
+
+test('emits upper-exclusive TEMPORAL_RANGE from time point click on month bucket', () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ setDataMask={setDataMaskMock}
+ groupby={[]}
+ resolvedTimeGrain={TimeGranularity.MONTH}
+ formData={{
+ ...defaultFormData,
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ }}
+ xAxis={{
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ }}
+ />,
+ );
+
+ const clickHandler = getLatestEchartProps().eventHandlers?.click;
+ expect(clickHandler).toBeDefined();
+ clickHandler?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: [Date.UTC(2021, 0, 1), 100],
+ name: '2021-01-01',
+ dataIndex: 0,
+ });
+
+ advanceClickTimer();
+
+ 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('emits TEMPORAL_RANGE from string-typed time point click value', () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ setDataMask={setDataMaskMock}
+ groupby={[]}
+ resolvedTimeGrain={TimeGranularity.MONTH}
+ formData={{
+ ...defaultFormData,
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ }}
+ xAxis={{
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ }}
+ />,
+ );
+
+ const clickHandler = getLatestEchartProps().eventHandlers?.click;
+ expect(clickHandler).toBeDefined();
+ clickHandler?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: ['2021-01-01T00:00:00Z', 100],
+ name: '2021-01-01',
+ dataIndex: 0,
+ });
+
+ advanceClickTimer();
+
+ 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('uses resolved time grain for temporal point-click cross-filter', () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ setDataMask={setDataMaskMock}
+ groupby={[]}
+ resolvedTimeGrain={TimeGranularity.MONTH}
+ formData={{
+ ...defaultFormData,
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.DAY,
+ extraFormData: {
+ time_grain_sqla: TimeGranularity.MONTH,
+ },
+ }}
+ xAxis={{
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ }}
+ />,
+ );
+
+ const clickHandler = getLatestEchartProps().eventHandlers?.click;
+ expect(clickHandler).toBeDefined();
+ clickHandler?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: [Date.UTC(2021, 0, 1), 100],
+ name: '2021-01-01',
+ dataIndex: 0,
+ });
+
+ advanceClickTimer();
+
+ 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('emits TEMPORAL_RANGE from horizontal time point click using timestamp, not metric', () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ setDataMask={setDataMaskMock}
+ groupby={[]}
+ resolvedTimeGrain={TimeGranularity.MONTH}
+ formData={{
+ ...defaultFormData,
+ orientation: OrientationType.Horizontal,
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ }}
+ xAxis={{
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ }}
+ />,
+ );
+
+ const clickHandler = getLatestEchartProps().eventHandlers?.click;
+ expect(clickHandler).toBeDefined();
+ clickHandler?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: [129, Date.UTC(2021, 0, 1)],
+ name: '2021-01-01',
+ dataIndex: 0,
+ });
+
+ advanceClickTimer();
+
+ 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('emits TEMPORAL_RANGE cross-filter from horizontal time axis label click', () => {
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ setDataMask={setDataMaskMock}
+ groupby={[]}
+ resolvedTimeGrain={TimeGranularity.MONTH}
+ formData={{
+ ...defaultFormData,
+ orientation: OrientationType.Horizontal,
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ }}
+ xAxis={{
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ }}
+ />,
+ );
+
+ const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+ ({ query }) => query === 'yAxis',
+ )?.handler;
+ expect(labelClickHandler).toBeDefined();
+ labelClickHandler?.({
+ targetType: 'axisLabel',
+ value: '2021-01-01',
+ } as unknown as ECElementEvent);
+
+ 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('warns and skips temporal axis label cross-filter when label value cannot be parsed', () => {
+ const setDataMaskMock = jest.fn();
+ const warn = jest.spyOn(logging, 'warn').mockImplementation();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ setDataMask={setDataMaskMock}
+ groupby={[]}
+ formData={{
+ ...defaultFormData,
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ }}
+ xAxis={{
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ }}
+ />,
+ );
+
+ const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+ ({ query }) => query === 'xAxis',
+ )?.handler;
+ expect(labelClickHandler).toBeDefined();
+ labelClickHandler?.({
+ targetType: 'axisLabel',
+ value: 'not-a-date',
+ } as unknown as ECElementEvent);
+
+ expect(setDataMaskMock).not.toHaveBeenCalled();
+ expect(warn).toHaveBeenCalledWith(
+ 'Unable to parse time axis value for cross-filtering',
+ 'not-a-date',
+ );
+
+ warn.mockRestore();
+});
+
+test('logs and skips temporal point-click cross-filter when string value cannot be parsed', () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+ const warn = jest.spyOn(logging, 'warn').mockImplementation();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ setDataMask={setDataMaskMock}
+ groupby={[]}
+ resolvedTimeGrain={TimeGranularity.MONTH}
+ formData={{
+ ...defaultFormData,
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ }}
+ xAxis={{
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ }}
+ />,
+ );
+
+ const clickHandler = getLatestEchartProps().eventHandlers?.click;
+ expect(clickHandler).toBeDefined();
+ clickHandler?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: ['not-a-date', 100],
+ name: 'not-a-date',
+ dataIndex: 0,
+ });
+
+ advanceClickTimer();
+
+ expect(setDataMaskMock).not.toHaveBeenCalled();
+ expect(warn).toHaveBeenCalledWith(
+ 'Unable to parse time axis value for cross-filtering',
+ 'not-a-date',
+ );
+
+ warn.mockRestore();
+});
+
+test('emits empty temporal X-axis data mask when filter grain is missing', () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ setDataMask={setDataMaskMock}
+ groupby={[]}
+ formData={{
+ ...defaultFormData,
+ granularitySqla: 'ds',
+ timeGrainSqla: undefined,
+ }}
+ xAxis={{
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ }}
+ />,
+ );
+
+ const clickHandler = getLatestEchartProps().eventHandlers?.click;
+ expect(clickHandler).toBeDefined();
+ clickHandler?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: [Date.UTC(2021, 0, 1), 100],
+ name: '2021-01-01',
+ dataIndex: 0,
+ });
+
+ advanceClickTimer();
+
+ expect(setDataMaskMock.mock.calls[0][0]).toEqual({
+ extraFormData: {
+ filters: [],
+ },
+ filterState: {
+ label: undefined,
+ value: null,
+ selectedValues: null,
+ },
+ });
+});
+
+test('clears temporal X-axis cross-filter when clicking selected bucket again', () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+ const selectedRange = '2021-01-01T00:00:00 : 2021-02-01T00:00:00';
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ setDataMask={setDataMaskMock}
+ groupby={[]}
+ selectedValues={{ 0: selectedRange }}
+ resolvedTimeGrain={TimeGranularity.MONTH}
+ formData={{
+ ...defaultFormData,
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ }}
+ xAxis={{
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ }}
+ />,
+ );
+
+ const clickHandler = getLatestEchartProps().eventHandlers?.click;
+ expect(clickHandler).toBeDefined();
+ clickHandler?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: [Date.UTC(2021, 0, 1), 100],
+ name: '2021-01-01',
+ dataIndex: 0,
+ });
+
+ advanceClickTimer();
+
+ expect(setDataMaskMock.mock.calls[0][0]).toEqual({
+ extraFormData: {
+ filters: [],
+ },
+ filterState: {
+ label: undefined,
+ value: null,
+ selectedValues: null,
+ },
+ });
+});
+
+test('does not emit temporal X-axis label cross-filter when dimensions are set', () => {
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ setDataMask={setDataMaskMock}
+ groupby={['country']}
+ formData={{
+ ...defaultFormData,
+ groupby: ['country'],
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ }}
+ xAxis={{
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ }}
+ />,
+ );
+
+ const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+ ({ query }) => query === 'xAxis',
+ )?.handler;
+ expect(labelClickHandler).toBeDefined();
+ labelClickHandler?.({
+ targetType: 'axisLabel',
+ value: '2021-01-01',
+ } as unknown as ECElementEvent);
+
+ expect(setDataMaskMock).not.toHaveBeenCalled();
+});
+
+test('does not emit temporal X-axis cross-filter when dimensions are set', () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ setDataMask={setDataMaskMock}
+ groupby={['country']}
+ labelMap={{
+ Sales: ['US'],
+ }}
+ formData={{
+ ...defaultFormData,
+ groupby: ['country'],
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ }}
+ xAxis={{
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ }}
+ />,
+ );
+
+ const clickHandler = getLatestEchartProps().eventHandlers?.click;
+ expect(clickHandler).toBeDefined();
+ clickHandler?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: [Date.UTC(2021, 0, 1), 100],
+ name: '2021-01-01',
+ dataIndex: 0,
+ });
+
+ advanceClickTimer();
+
+ expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
+ {
+ col: 'country',
+ op: 'IN',
+ val: ['US'],
+ },
+ ]);
+ expect(
+ setDataMaskMock.mock.calls[0][0].extraFormData.filters.some(
+ (filter: { op: string }) => filter.op === 'TEMPORAL_RANGE',
+ ),
+ ).toBe(false);
});
// Test for issue #41102: horizontal bar cross-filter must use the category
// value, not the metric. For horizontal bars the data tuple is value-first
// (e.g. [100, 'Product A']), so relying on data[0] emitted the metric value.
-test('emits cross-filter on the category value for a horizontal categorical bar', async () => {
+test('emits cross-filter on the category value for a horizontal categorical bar', () => {
+ jest.useFakeTimers();
const setDataMaskMock = jest.fn();
const propsWithHorizontalXAxis: TimeseriesChartTransformedProps = {
@@ -585,12 +1173,7 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
dataIndex: 0,
});
- await waitFor(
- () => {
- expect(setDataMaskMock).toHaveBeenCalled();
- },
- { timeout: 500 },
- );
+ advanceClickTimer();
// Must filter on the category ('Product A'), not the metric value (100)
const dataMaskCall = setDataMaskMock.mock.calls[0][0];
@@ -604,6 +1187,55 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
}
});
+test('context menu cross-filter is available for a temporal bar point', async () => {
+ const onContextMenuMock = jest.fn();
+
+ render(
+ <EchartsTimeseries
+ {...defaultProps}
+ emitCrossFilters
+ onContextMenu={onContextMenuMock}
+ groupby={[]}
+ resolvedTimeGrain={TimeGranularity.MONTH}
+ formData={{
+ ...defaultFormData,
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.DAY,
+ extraFormData: {
+ time_grain_sqla: TimeGranularity.MONTH,
+ },
+ }}
+ xAxis={{
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ }}
+ />,
+ );
+
+ const contextMenuHandler = getLatestEchartProps().eventHandlers?.contextmenu;
+ expect(contextMenuHandler).toBeDefined();
+ await contextMenuHandler?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: [Date.UTC(2021, 0, 1), 100],
+ name: '2021-01-01',
+ event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
+ });
+
+ await waitFor(() => {
+ expect(onContextMenuMock).toHaveBeenCalled();
+ });
+
+ const { crossFilter } = onContextMenuMock.mock.calls[0][2];
+ expect(crossFilter.dataMask.extraFormData.filters).toEqual([
+ {
+ col: 'ds',
+ op: 'TEMPORAL_RANGE',
+ val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+ },
+ ]);
+});
+
// Test for issue #41102: the context-menu ("Add cross-filter") path must also
// use the category value, not the metric, for a horizontal categorical bar.
test('context menu cross-filter uses the category value for a horizontal categorical bar', async () => {
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 4665e1ee59f..fae555e33da 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
@@ -21,14 +21,17 @@ import {
DTTM_ALIAS,
BinaryQueryObjectFilterClause,
AxisType,
+ type TimeGranularity,
getTimeFormatter,
getColumnLabel,
getNumberFormatter,
LegendState,
ensureIsArray,
+ createTimeRangeFromGranularity,
} from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import { GenericDataType } from '@apache-superset/core/common';
+import { logging } from '@apache-superset/core/utils';
import type {
ECElementEvent,
ViewRootGroup,
@@ -48,6 +51,29 @@ import { getTemporalXAxisDrillByFilter } from '../utils/xAxisDrillByFilter';
import { ExtraControls } from '../components/ExtraControls';
const TIMER_DURATION = 300;
+const getTimestampFromTimeAxisValue = (value: string | number) => {
+ if (typeof value === 'number') {
+ return Number.isFinite(value) ? value : undefined;
+ }
+ const timestamp = Date.parse(value);
+ if (Number.isNaN(timestamp)) {
+ logging.warn('Unable to parse time axis value for cross-filtering', value);
+ }
+ return Number.isNaN(timestamp) ? undefined : timestamp;
+};
+
+// Day, month, and year ranges end at 23:59:59.999, so adding 1ms lands on a
+// whole-second next bucket boundary. The formatter intentionally emits seconds.
+const formatDateTime = (date: Date) =>
+ `${[
+ date.getUTCFullYear(),
+ String(date.getUTCMonth() + 1).padStart(2, '0'),
+ String(date.getUTCDate()).padStart(2, '0'),
+ ].join('-')}T${[
+ String(date.getUTCHours()).padStart(2, '0'),
+ String(date.getUTCMinutes()).padStart(2, '0'),
+ String(date.getUTCSeconds()).padStart(2, '0'),
+ ].join(':')}`;
// Percent-change draggable baseline handle geometry, in pixels.
const BASELINE_HANDLE_WIDTH = 8;
@@ -71,6 +97,7 @@ export default function EchartsTimeseries({
onFocusedSeries,
xValueFormatter,
xAxis,
+ resolvedTimeGrain,
refs,
emitCrossFilters,
coltypeMapping,
@@ -363,6 +390,65 @@ export default function EchartsTimeseries({
[selectedValues, xAxis.label],
);
+ const getTimeAxisCrossFilterDataMask = useCallback(
+ (clickedTimestamp: number) => {
+ const filterColumn =
+ xAxis.label === DTTM_ALIAS ? formData.granularitySqla : xAxis.label;
+ const grain = resolvedTimeGrain as TimeGranularity | undefined;
+
+ if (!filterColumn || !grain) {
+ return {
+ dataMask: {
+ extraFormData: {
+ filters: [],
+ },
+ filterState: {
+ label: undefined,
+ value: null,
+ selectedValues: null,
+ },
+ },
+ isCurrentValueSelected: false,
+ };
+ }
+
+ const [start, inclusiveEnd] = createTimeRangeFromGranularity(
+ new Date(clickedTimestamp),
+ grain,
+ false,
+ );
+ const exclusiveEnd = new Date(inclusiveEnd.getTime() + 1);
+ const timeRange = `${formatDateTime(start)} : ${formatDateTime(exclusiveEnd)}`;
+ const selected: string[] = Object.values(selectedValues);
+ const isCurrentValueSelected = selected.includes(timeRange);
+ const values = isCurrentValueSelected ? [] : [timeRange];
+
+ return {
+ dataMask: {
+ extraFormData: {
+ filters:
+ values.length === 0
+ ? []
+ : [
+ {
+ col: filterColumn,
+ op: 'TEMPORAL_RANGE' as const,
+ val: timeRange,
+ },
+ ],
+ },
+ filterState: {
+ label: values.length ? values : undefined,
+ value: values.length ? values : null,
+ selectedValues: values.length ? values : null,
+ },
+ },
+ isCurrentValueSelected,
+ };
+ },
+ [formData.granularitySqla, resolvedTimeGrain, selectedValues, xAxis.label],
+ );
+
const handleChange = useCallback(
(value: string) => {
if (!emitCrossFilters) {
@@ -384,15 +470,26 @@ export default function EchartsTimeseries({
[emitCrossFilters, setDataMask, getXAxisCrossFilterDataMask],
);
+ const handleTimeAxisChange = useCallback(
+ (clickedTimestamp: number) => {
+ if (!emitCrossFilters) {
+ return;
+ }
+ setDataMask(getTimeAxisCrossFilterDataMask(clickedTimestamp).dataMask);
+ },
+ [emitCrossFilters, setDataMask, getTimeAxisCrossFilterDataMask],
+ );
+
// Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
const canCrossFilterByXAxis =
- !hasDimensions && xAxis.type === AxisType.Category;
- const categoryAxisValueIndex =
+ !hasDimensions &&
+ (xAxis.type === AxisType.Category || xAxis.type === AxisType.Time);
+ const xAxisValueIndex =
formData.orientation === OrientationType.Horizontal ? 1 : 0;
- const getCategoryAxisValue = useCallback(
+ const getXAxisValue = useCallback(
(data: unknown, name: unknown) => {
if (Array.isArray(data)) {
- const categoryAxisValue = data[categoryAxisValueIndex];
+ const categoryAxisValue = data[xAxisValueIndex];
if (
typeof categoryAxisValue === 'string' ||
typeof categoryAxisValue === 'number'
@@ -405,7 +502,7 @@ export default function EchartsTimeseries({
}
return undefined;
},
- [categoryAxisValueIndex],
+ [xAxisValueIndex],
);
const eventHandlers: EventHandlers = {
@@ -423,15 +520,28 @@ export default function EchartsTimeseries({
// Cross-filter by dimension (original behavior)
const { seriesName: name } = props;
handleChange(name);
- } else if (canCrossFilterByXAxis && props.componentType === 'series') {
+ } else if (
+ canCrossFilterByXAxis &&
+ xAxis.type === AxisType.Category &&
+ props.componentType === 'series'
+ ) {
// Cross-filter by X-axis value when no dimensions (issue #25334)
- const categoryAxisValue = getCategoryAxisValue(
- props.data,
- props.name,
- );
+ const categoryAxisValue = getXAxisValue(props.data, props.name);
if (categoryAxisValue !== undefined) {
handleXAxisChange(categoryAxisValue);
}
+ } else if (
+ canCrossFilterByXAxis &&
+ xAxis.type === AxisType.Time &&
+ props.componentType === 'series'
+ ) {
+ const timeAxisValue = getXAxisValue(props.data, props.name);
+ if (timeAxisValue !== undefined) {
+ const timestamp = getTimestampFromTimeAxisValue(timeAxisValue);
+ if (timestamp !== undefined) {
+ handleTimeAxisChange(timestamp);
+ }
+ }
}
}, TIMER_DURATION);
},
@@ -466,17 +576,20 @@ export default function EchartsTimeseries({
];
const groupBy = ensureIsArray(formData.groupby);
if (data && xAxis.type === AxisType.Time) {
- drillToDetailFilters.push({
- col:
- // if the xAxis is '__timestamp', granularity_sqla will be the column of filter
- xAxis.label === DTTM_ALIAS
- ? formData.granularitySqla
- : xAxis.label,
- grain: formData.timeGrainSqla,
- op: '==',
- val: data[0],
- formattedVal: xValueFormatter(data[0]),
- });
+ const timeAxisValue = getXAxisValue(data, eventParams.name);
+ if (timeAxisValue !== undefined) {
+ drillToDetailFilters.push({
+ col:
+ // if the xAxis is '__timestamp', granularity_sqla will be the column of filter
+ xAxis.label === DTTM_ALIAS
+ ? formData.granularitySqla
+ : xAxis.label,
+ grain: resolvedTimeGrain,
+ op: '==',
+ val: timeAxisValue,
+ formattedVal: xValueFormatter(timeAxisValue),
+ });
+ }
}
[
...(xAxis.type === AxisType.Category && data ? [xAxis.label] : []),
@@ -517,9 +630,7 @@ export default function EchartsTimeseries({
xAxis.label === DTTM_ALIAS ? formData.granularitySqla : xAxis.label;
if (data && xAxis.type === AxisType.Time && xAxisCol) {
// For horizontal orientation the [x, value] pair is swapped
- const xValue = Array.isArray(data)
- ? data[categoryAxisValueIndex]
- : data;
+ const xValue = Array.isArray(data) ? data[xAxisValueIndex] : data;
const xAxisFilter = getTemporalXAxisDrillByFilter(
xAxisCol,
xValue,
@@ -530,10 +641,7 @@ export default function EchartsTimeseries({
xAxisFilters.push(xAxisFilter);
}
} else if (xAxis.type === AxisType.Category && xAxisCol) {
- const categoryAxisValue = getCategoryAxisValue(
- data,
- eventParams.name,
- );
+ const categoryAxisValue = getXAxisValue(data, eventParams.name);
if (categoryAxisValue !== undefined) {
// A category axis can still sit on a temporal column when the
// axis is forced categorical; filter by time bucket in that case
@@ -564,15 +672,25 @@ export default function EchartsTimeseries({
crossFilter = getCrossFilterDataMask(seriesName);
} else if (
canCrossFilterByXAxis &&
+ xAxis.type === AxisType.Category &&
eventParams.componentType === 'series'
) {
- const categoryAxisValue = getCategoryAxisValue(
- data,
- eventParams.name,
- );
+ const categoryAxisValue = getXAxisValue(data, eventParams.name);
if (categoryAxisValue !== undefined) {
crossFilter = getXAxisCrossFilterDataMask(categoryAxisValue);
}
+ } else if (
+ canCrossFilterByXAxis &&
+ xAxis.type === AxisType.Time &&
+ eventParams.componentType === 'series'
+ ) {
+ const timeAxisValue = getXAxisValue(data, eventParams.name);
+ if (timeAxisValue !== undefined) {
+ const timestamp = getTimestampFromTimeAxisValue(timeAxisValue);
+ if (timestamp !== undefined) {
+ crossFilter = getTimeAxisCrossFilterDataMask(timestamp);
+ }
+ }
}
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
@@ -593,26 +711,39 @@ export default function EchartsTimeseries({
const { value } = event;
if (
canCrossFilterByXAxis &&
+ event.targetType === 'axisLabel' &&
(typeof value === 'string' || typeof value === 'number')
) {
- handleXAxisChange(value);
+ if (xAxis.type === AxisType.Time) {
+ const timestamp = getTimestampFromTimeAxisValue(value);
+ if (timestamp !== undefined) {
+ handleTimeAxisChange(timestamp);
+ }
+ } else {
+ handleXAxisChange(value);
+ }
}
},
- [canCrossFilterByXAxis, handleXAxisChange],
+ [
+ canCrossFilterByXAxis,
+ handleTimeAxisChange,
+ handleXAxisChange,
+ xAxis.type,
+ ],
);
- const categoryAxis =
+ const renderedXAxis =
formData.orientation === OrientationType.Horizontal ? 'yAxis' : 'xAxis';
const queryEventHandlers = useMemo(
() => [
{
name: 'click',
- query: `${categoryAxis}.category`,
+ query: renderedXAxis,
handler: handleXAxisLabelClick,
},
],
- [categoryAxis, handleXAxisLabelClick],
+ [renderedXAxis, handleXAxisLabelClick],
);
const zrEventHandlers: EventHandlers = {
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 28a73cfe314..701c47d4aee 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
@@ -1248,7 +1248,7 @@ export default function transformProps(
name: xAxisTitle,
nameGap: convertInteger(xAxisTitleMargin),
nameLocation: 'middle',
- ...(xAxisType === AxisType.Category &&
+ ...((xAxisType === AxisType.Category || xAxisType === AxisType.Time) &&
groupBy.length === 0 && {
triggerEvent: true,
}),
@@ -1600,6 +1600,7 @@ export default function transformProps(
label: xAxisLabel,
type: xAxisType,
},
+ resolvedTimeGrain,
refs,
coltypeMapping: dataTypes,
onLegendScroll,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts
index 6f06fb84715..03bb1e165ea 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts
@@ -122,5 +122,6 @@ export type TimeseriesChartTransformedProps =
label: string;
type: AxisType;
};
+ resolvedTimeGrain?: TimeGranularity;
onFocusedSeries: (series: string | null) => void;
};
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 776ba420e6b..bb91ffea970 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
@@ -16,11 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { render } from '@testing-library/react';
-import { AxisType } from '@superset-ui/core';
+import { render, waitFor } from '@testing-library/react';
+import { AxisType, DTTM_ALIAS, TimeGranularity } from '@superset-ui/core';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
+import { logging } from '@apache-superset/core/utils';
+import type { ECElementEvent } from 'echarts/types/src/util/types';
import EchartsTimeseries from '../../src/Timeseries/EchartsTimeseries';
-import { TimeseriesChartTransformedProps } from '../../src/Timeseries/types';
+import {
+ OrientationType,
+ TimeseriesChartTransformedProps,
+} from '../../src/Timeseries/types';
+import type { EchartsProps } from '../../src/types';
// Percent-change draggable baseline: this is the one piece of the ECharts
// rebuilds with zero prior test coverage despite six separate production
@@ -40,12 +46,14 @@ let mockChart: {
convertFromPixel: jest.Mock;
getModel: jest.Mock;
};
+const mockEchart = jest.fn();
jest.mock('../../src/components/Echart', () => {
const { forwardRef, useImperativeHandle } = jest.requireActual('react');
return {
__esModule: true,
- default: forwardRef((_props: unknown, ref: unknown) => {
+ default: forwardRef((props: unknown, ref: unknown) => {
+ mockEchart(props);
useImperativeHandle(ref, () => ({
getEchartInstance: () => mockChart,
}));
@@ -115,6 +123,17 @@ function renderTimeseries(
);
}
+function getLatestEchartProps() {
+ const lastCall = mockEchart.mock.calls.at(-1);
+ expect(lastCall).toBeDefined();
+ const [props] = lastCall as [EchartsProps];
+ return props;
+}
+
+function advanceClickTimer() {
+ jest.advanceTimersByTime(300);
+}
+
// Pulls the graphic descriptor for the draggable baseline handle out of the
// most recent setOption call, mirroring how ECharts itself would read it.
function getBaselineGraphic() {
@@ -126,6 +145,7 @@ function getBaselineGraphic() {
beforeEach(() => {
jest.clearAllMocks();
+ mockEchart.mockReset();
setupChartMock();
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
cb(0);
@@ -134,6 +154,7 @@ beforeEach(() => {
});
afterEach(() => {
+ jest.useRealTimers();
jest.restoreAllMocks();
});
@@ -245,3 +266,400 @@ test('does not touch the chart instance when rebase is disabled', () => {
expect(mockChart.setOption).not.toHaveBeenCalled();
});
+
+test('emits TEMPORAL_RANGE cross-filter from time axis label click on day bucket', () => {
+ const setDataMask = jest.fn();
+
+ renderTimeseries({
+ emitCrossFilters: true,
+ setDataMask,
+ groupby: [],
+ resolvedTimeGrain: TimeGranularity.DAY,
+ formData: {
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.DAY,
+ vizType: 'echarts_timeseries_line',
+ } as any,
+ xAxis: {
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ },
+ });
+
+ const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+ ({ query }) => query === 'xAxis',
+ )?.handler;
+ expect(labelClickHandler).toBeDefined();
+ labelClickHandler?.({
+ targetType: 'axisLabel',
+ value: '2021-01-01',
+ } as unknown as ECElementEvent);
+
+ expect(setDataMask.mock.calls[0][0]).toEqual({
+ extraFormData: {
+ filters: [
+ {
+ col: 'ds',
+ op: 'TEMPORAL_RANGE',
+ val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
+ },
+ ],
+ },
+ filterState: {
+ 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'],
+ },
+ });
+});
+
+test('emits upper-exclusive TEMPORAL_RANGE from time point click on month bucket', () => {
+ jest.useFakeTimers();
+ const setDataMask = jest.fn();
+
+ renderTimeseries({
+ emitCrossFilters: true,
+ setDataMask,
+ groupby: [],
+ resolvedTimeGrain: TimeGranularity.MONTH,
+ formData: {
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ vizType: 'echarts_timeseries_line',
+ } as any,
+ xAxis: {
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ },
+ });
+
+ getLatestEchartProps().eventHandlers?.click?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: [Date.UTC(2021, 0, 1), 100],
+ name: '2021-01-01',
+ dataIndex: 0,
+ });
+ advanceClickTimer();
+
+ 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('uses resolved time grain for temporal point-click cross-filter', () => {
+ jest.useFakeTimers();
+ const setDataMask = jest.fn();
+
+ renderTimeseries({
+ emitCrossFilters: true,
+ setDataMask,
+ groupby: [],
+ resolvedTimeGrain: TimeGranularity.MONTH,
+ formData: {
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.DAY,
+ extraFormData: {
+ time_grain_sqla: TimeGranularity.MONTH,
+ },
+ vizType: 'echarts_timeseries_line',
+ } as any,
+ xAxis: {
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ },
+ });
+
+ getLatestEchartProps().eventHandlers?.click?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: [Date.UTC(2021, 0, 1), 100],
+ name: '2021-01-01',
+ dataIndex: 0,
+ });
+ advanceClickTimer();
+
+ 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('emits TEMPORAL_RANGE from string-typed time point click value', () => {
+ jest.useFakeTimers();
+ const setDataMask = jest.fn();
+
+ renderTimeseries({
+ emitCrossFilters: true,
+ setDataMask,
+ groupby: [],
+ resolvedTimeGrain: TimeGranularity.MONTH,
+ formData: {
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ vizType: 'echarts_timeseries_line',
+ } as any,
+ xAxis: {
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ },
+ });
+
+ getLatestEchartProps().eventHandlers?.click?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: ['2021-01-01T00:00:00Z', 100],
+ name: '2021-01-01',
+ dataIndex: 0,
+ });
+ advanceClickTimer();
+
+ 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('emits TEMPORAL_RANGE from horizontal time point click using timestamp, not metric', () => {
+ jest.useFakeTimers();
+ const setDataMask = jest.fn();
+
+ renderTimeseries({
+ emitCrossFilters: true,
+ setDataMask,
+ groupby: [],
+ resolvedTimeGrain: TimeGranularity.MONTH,
+ formData: {
+ orientation: OrientationType.Horizontal,
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ vizType: 'echarts_timeseries_line',
+ } as any,
+ xAxis: {
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ },
+ });
+
+ getLatestEchartProps().eventHandlers?.click?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: [129, Date.UTC(2021, 0, 1)],
+ name: '2021-01-01',
+ dataIndex: 0,
+ });
+ advanceClickTimer();
+
+ 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('clears temporal X-axis cross-filter when clicking selected bucket again', () => {
+ jest.useFakeTimers();
+ const setDataMask = jest.fn();
+ const selectedRange = '2021-01-01T00:00:00 : 2021-02-01T00:00:00';
+
+ renderTimeseries({
+ emitCrossFilters: true,
+ setDataMask,
+ groupby: [],
+ selectedValues: { 0: selectedRange },
+ resolvedTimeGrain: TimeGranularity.MONTH,
+ formData: {
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ vizType: 'echarts_timeseries_line',
+ } as any,
+ xAxis: {
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ },
+ });
+
+ getLatestEchartProps().eventHandlers?.click?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: [Date.UTC(2021, 0, 1), 100],
+ name: '2021-01-01',
+ dataIndex: 0,
+ });
+ advanceClickTimer();
+
+ expect(setDataMask.mock.calls[0][0]).toEqual({
+ extraFormData: {
+ filters: [],
+ },
+ filterState: {
+ label: undefined,
+ value: null,
+ selectedValues: null,
+ },
+ });
+});
+
+test('emits empty temporal X-axis data mask when filter grain is missing', () => {
+ jest.useFakeTimers();
+ const setDataMask = jest.fn();
+
+ renderTimeseries({
+ emitCrossFilters: true,
+ setDataMask,
+ groupby: [],
+ formData: {
+ granularitySqla: 'ds',
+ timeGrainSqla: undefined,
+ vizType: 'echarts_timeseries_line',
+ } as any,
+ xAxis: {
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ },
+ });
+
+ getLatestEchartProps().eventHandlers?.click?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: [Date.UTC(2021, 0, 1), 100],
+ name: '2021-01-01',
+ dataIndex: 0,
+ });
+ advanceClickTimer();
+
+ expect(setDataMask.mock.calls[0][0]).toEqual({
+ extraFormData: {
+ filters: [],
+ },
+ filterState: {
+ label: undefined,
+ value: null,
+ selectedValues: null,
+ },
+ });
+});
+
+test('warns and skips temporal cross-filter when string value cannot be parsed', () => {
+ jest.useFakeTimers();
+ const setDataMask = jest.fn();
+ const warn = jest.spyOn(logging, 'warn').mockImplementation();
+
+ renderTimeseries({
+ emitCrossFilters: true,
+ setDataMask,
+ groupby: [],
+ resolvedTimeGrain: TimeGranularity.MONTH,
+ formData: {
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ vizType: 'echarts_timeseries_line',
+ } as any,
+ xAxis: {
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ },
+ });
+
+ getLatestEchartProps().eventHandlers?.click?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: ['not-a-date', 100],
+ name: 'not-a-date',
+ dataIndex: 0,
+ });
+ advanceClickTimer();
+
+ expect(setDataMask).not.toHaveBeenCalled();
+ expect(warn).toHaveBeenCalledWith(
+ 'Unable to parse time axis value for cross-filtering',
+ 'not-a-date',
+ );
+});
+
+test('does not emit temporal X-axis label cross-filter when dimensions are set', () => {
+ const setDataMask = jest.fn();
+
+ renderTimeseries({
+ emitCrossFilters: true,
+ setDataMask,
+ groupby: ['country'],
+ formData: {
+ groupby: ['country'],
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.MONTH,
+ vizType: 'echarts_timeseries_line',
+ } as any,
+ xAxis: {
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ },
+ });
+
+ const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+ ({ query }) => query === 'xAxis',
+ )?.handler;
+ expect(labelClickHandler).toBeDefined();
+ labelClickHandler?.({
+ targetType: 'axisLabel',
+ value: '2021-01-01',
+ } as unknown as ECElementEvent);
+
+ expect(setDataMask).not.toHaveBeenCalled();
+});
+
+test('context menu cross-filter is available for a temporal bar point', async () => {
+ const onContextMenu = jest.fn();
+
+ renderTimeseries({
+ emitCrossFilters: true,
+ onContextMenu,
+ groupby: [],
+ resolvedTimeGrain: TimeGranularity.MONTH,
+ formData: {
+ granularitySqla: 'ds',
+ timeGrainSqla: TimeGranularity.DAY,
+ extraFormData: {
+ time_grain_sqla: TimeGranularity.MONTH,
+ },
+ vizType: 'echarts_timeseries_line',
+ } as any,
+ xAxis: {
+ label: DTTM_ALIAS,
+ type: AxisType.Time,
+ },
+ });
+
+ await getLatestEchartProps().eventHandlers?.contextmenu?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: [Date.UTC(2021, 0, 1), 100],
+ name: '2021-01-01',
+ event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
+ });
+
+ await waitFor(() => {
+ expect(onContextMenu).toHaveBeenCalled();
+ });
+
+ const { crossFilter } = onContextMenu.mock.calls[0][2];
+ expect(crossFilter.dataMask.extraFormData.filters).toEqual([
+ {
+ col: 'ds',
+ op: 'TEMPORAL_RANGE',
+ val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+ },
+ ]);
+});
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 8065781b34f..8c3b705d7b0 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
@@ -2083,6 +2083,39 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
expect(xAxis.triggerEvent).toBe(true);
});
+test('temporal x-axis enables trigger events when no dimensions are set', () => {
+ const ts1 = 1745784000000;
+ const ts2 = 1745870400000;
+ const chartProps = createTestChartProps({
+ formData: {
+ metrics: ['metric'],
+ granularity_sqla: 'ds',
+ x_axis: '__timestamp',
+ },
+ queriesData: [
+ createTestQueryData(
+ [
+ { __timestamp: ts1, metric: 10 },
+ { __timestamp: ts2, metric: 20 },
+ ],
+ {
+ colnames: ['__timestamp', 'metric'],
+ coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
+ },
+ ),
+ ],
+ });
+
+ const { echartOptions } = transformProps(chartProps);
+ const xAxis = echartOptions.xAxis as {
+ triggerEvent?: boolean;
+ type: string;
+ };
+
+ expect(xAxis.type).toBe(AxisType.Time);
+ 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.
@@ -2330,6 +2363,7 @@ test('tooltip time grain wiring: dashboard-level extraFormData time grain overri
});
const transformedProps = transformProps(chartProps);
+ expect(transformedProps.resolvedTimeGrain).toBe(TimeGranularity.MONTH);
const tooltipFormatter = (
transformedProps.echartOptions as unknown as TooltipFormatterOptions
).tooltip.formatter;
@@ -2363,6 +2397,7 @@ test('tooltip time grain wiring: chart-level time grain drives the tooltip when
});
const transformedProps = transformProps(chartProps);
+ expect(transformedProps.resolvedTimeGrain).toBe(TimeGranularity.YEAR);
const tooltipFormatter = (
transformedProps.echartOptions as unknown as TooltipFormatterOptions
).tooltip.formatter;