This is an automated email from the ASF dual-hosted git repository. sadpandajoe pushed a commit to branch test/drill-to-detail-payloads in repository https://gitbox.apache.org/repos/asf/superset.git
commit 0dc1d1b1aea9a993af11cb3d77cd08d0cdde6a94 Author: Joe Li <[email protected]> AuthorDate: Fri Sep 25 15:50:05 2026 -0700 test(dashboard): cover drill to detail payloads and page reset Adds regression coverage for drill to detail: - DrillDetailPane resets to page 1 and refetches with the updated filter set when drill filters change or on reload - BigNumber trendline context menu builds the drill filter from the x-axis or legacy time column with the time grain - ECharts context menu handler and Treemap drill payloads Co-Authored-By: Claude Opus 5.5 <[email protected]> --- .../src/BigNumber/BigNumberViz.test.tsx | 131 ++++++++++++++++++++- .../test/Treemap/EchartsTreemap.test.tsx | 123 +++++++++++++++++++ .../test/utils/eventHandlers.test.ts | 74 ++++++++++++ .../Chart/DrillDetail/DrillDetailPane.test.tsx | 74 +++++++++++- 4 files changed, 400 insertions(+), 2 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.test.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.test.tsx index dd7a495ee89..db8d4e50f9d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.test.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.test.tsx @@ -17,9 +17,23 @@ * under the License. */ -import { getNumberFormatter } from '@superset-ui/core'; +import { + DTTM_ALIAS, + getNumberFormatter, + TimeFormatter, +} from '@superset-ui/core'; import { render, fireEvent } from '../../../../spec/helpers/testing-library'; import BigNumberVis from './BigNumberViz'; +import Echart from '../components/Echart'; +import { EventHandlers } from '../types'; +import { BigNumberWithTrendlineFormData } from './types'; + +jest.mock('../components/Echart', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +const mockedEchart = jest.mocked(Echart); /** * Tests for the color threshold formatter logic in BigNumberViz. @@ -117,3 +131,118 @@ describe('BigNumberViz context menu', () => { expect(ancestorHandler).not.toHaveBeenCalled(); }); }); + +describe('BigNumberViz trendline context menu', () => { + beforeEach(() => { + mockedEchart.mockClear(); + }); + + const renderWithTrendline = ( + onContextMenu: jest.Mock, + xAxis: string = 'ds', + ) => { + render( + <BigNumberVis + width={200} + height={100} + bigNumber={42} + headerFormatter={getNumberFormatter()} + headerFontSize={0.3} + subheaderFontSize={0.125} + subtitleFontSize={0.125} + subtitle="" + refs={{}} + showTrendLine + trendLineData={[ + [1577836800000, 10], + [1577923200000, 20], + ]} + echartOptions={{}} + formData={ + { + xAxis, + granularitySqla: 'legacy_ds', + timeGrainSqla: 'P1D', + vizType: 'big_number', + } as unknown as BigNumberWithTrendlineFormData + } + xValueFormatter={ + new TimeFormatter({ + id: 'test-time-formatter', + formatFunc: (value: Date) => `formatted-${value.getTime()}`, + }) + } + onContextMenu={onContextMenu} + />, + ); + + const lastCall = + mockedEchart.mock.calls[mockedEchart.mock.calls.length - 1]; + const { eventHandlers } = lastCall[0] as { + eventHandlers: EventHandlers; + }; + return eventHandlers; + }; + + test('right-clicking a trendline point drills to detail for that point', () => { + const onContextMenu = jest.fn(); + const stop = jest.fn(); + const eventHandlers = renderWithTrendline(onContextMenu); + + eventHandlers.contextmenu({ + data: [1577836800000, 10], + event: { + stop, + event: { clientX: 15, clientY: 25 }, + }, + }); + + expect(stop).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledTimes(1); + const [x, y, payload] = onContextMenu.mock.calls[0]; + expect(x).toBe(15); + expect(y).toBe(25); + expect(payload.drillToDetail).toEqual([ + { + col: 'ds', + grain: 'P1D', + op: '==', + val: 1577836800000, + formattedVal: 'formatted-1577836800000', + }, + ]); + }); + + test('drills on the legacy time column when the x-axis is the timestamp alias', () => { + const onContextMenu = jest.fn(); + const eventHandlers = renderWithTrendline(onContextMenu, DTTM_ALIAS); + + eventHandlers.contextmenu({ + data: [1577836800000, 10], + event: { + stop: jest.fn(), + event: { clientX: 15, clientY: 25 }, + }, + }); + + const [, , payload] = onContextMenu.mock.calls[0]; + expect(payload.drillToDetail).toEqual([ + expect.objectContaining({ col: 'legacy_ds', grain: 'P1D' }), + ]); + }); + + test('does not call onContextMenu when the point has no data', () => { + const onContextMenu = jest.fn(); + const eventHandlers = renderWithTrendline(onContextMenu); + + eventHandlers.contextmenu({ + data: undefined, + event: { + stop: jest.fn(), + event: { clientX: 15, clientY: 25 }, + }, + }); + + expect(onContextMenu).not.toHaveBeenCalled(); + }); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Treemap/EchartsTreemap.test.tsx b/superset-frontend/plugins/plugin-chart-echarts/test/Treemap/EchartsTreemap.test.tsx new file mode 100644 index 00000000000..1a3fa069405 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Treemap/EchartsTreemap.test.tsx @@ -0,0 +1,123 @@ +/** + * 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 } from '../../../../spec/helpers/testing-library'; +import EchartsTreemap from '../../src/Treemap/EchartsTreemap'; +import Echart from '../../src/components/Echart'; +import { EventHandlers, TreePathInfo } from '../../src/types'; +import { + EchartsTreemapFormData, + TreemapTransformedProps, +} from '../../src/Treemap/types'; + +jest.mock('../../src/components/Echart', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +const mockedEchart = jest.mocked(Echart); + +const renderTreemap = ( + onContextMenu?: jest.Mock, +): { eventHandlers: EventHandlers } => { + mockedEchart.mockClear(); + + const props: TreemapTransformedProps = { + echartOptions: {}, + formData: { + groupby: ['country', 'state'], + dateFormat: 'smart_date', + numberFormat: 'SMART_NUMBER', + vizType: 'treemap', + } as unknown as EchartsTreemapFormData, + height: 400, + width: 400, + refs: {}, + groupby: ['country', 'state'], + labelMap: { 'USA,CA': ['USA', 'CA'] }, + selectedValues: {}, + setDataMask: jest.fn(), + emitCrossFilters: false, + coltypeMapping: {}, + onContextMenu, + }; + + render(<EchartsTreemap {...props} />); + + const lastCall = mockedEchart.mock.calls[mockedEchart.mock.calls.length - 1]; + const { eventHandlers } = lastCall[0] as { eventHandlers: EventHandlers }; + return { eventHandlers }; +}; + +test('right-clicking a treemap node drills to detail for its groupby path', () => { + const onContextMenu = jest.fn(); + const { eventHandlers } = renderTreemap(onContextMenu); + + const treePathInfo: TreePathInfo[] = [ + { name: 'sum__num', dataIndex: 0, value: 100 }, + { name: 'USA', dataIndex: 1, value: 60 }, + { name: 'CA', dataIndex: 2, value: 40 }, + ]; + const stop = jest.fn(); + + eventHandlers.contextmenu({ + data: { name: 'CA', value: 40 }, + treePathInfo, + event: { + stop, + event: { clientX: 50, clientY: 80 }, + }, + }); + + expect(stop).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledTimes(1); + const [x, y, payload] = onContextMenu.mock.calls[0]; + expect(x).toBe(50); + expect(y).toBe(80); + expect(payload.drillToDetail).toEqual([ + { col: 'country', op: '==', val: 'USA', formattedVal: 'USA' }, + { col: 'state', op: '==', val: 'CA', formattedVal: 'CA' }, + ]); + expect(payload.drillBy).toEqual({ + filters: [ + { col: 'country', op: '==', val: 'USA', formattedVal: 'USA' }, + { col: 'state', op: '==', val: 'CA', formattedVal: 'CA' }, + ], + groupbyFieldName: 'groupby', + }); +}); + +test('right-clicking the root node does not call onContextMenu', () => { + const onContextMenu = jest.fn(); + const { eventHandlers } = renderTreemap(onContextMenu); + + const treePathInfo: TreePathInfo[] = [ + { name: 'sum__num', dataIndex: 0, value: 100 }, + ]; + + eventHandlers.contextmenu({ + data: { name: 'sum__num', value: 100, children: [] }, + treePathInfo, + event: { + stop: jest.fn(), + event: { clientX: 50, clientY: 80 }, + }, + }); + + expect(onContextMenu).not.toHaveBeenCalled(); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/utils/eventHandlers.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/utils/eventHandlers.test.ts index 4c60aa85a36..cef1a79ac94 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/utils/eventHandlers.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/eventHandlers.test.ts @@ -279,3 +279,77 @@ test('context menu offers no cross-filter for an unresolvable name (#42340)', () // contextMenuEventHandler already bails out before opening the menu expect(onContextMenu).not.toHaveBeenCalled(); }); + +test('context menu passes drillToDetail and drillBy filters for a resolved groupby value', () => { + const onContextMenu = jest.fn(); + const props = buildProps({ + groupby: ['topics'], + labelMap: { cancellations: ['cancellations'] }, + selectedValues: {}, + onContextMenu, + }); + + const handlers = allEventHandlers(props); + handlers.contextmenu({ + name: 'cancellations', + event: { + stop: jest.fn(), + event: { clientX: 10, clientY: 20 } as unknown as PointerEvent, + }, + }); + + expect(onContextMenu).toHaveBeenCalledWith( + 10, + 20, + expect.objectContaining({ + drillToDetail: [ + { + col: 'topics', + op: '==', + val: 'cancellations', + formattedVal: 'cancellations', + }, + ], + drillBy: { + filters: [ + { + col: 'topics', + op: '==', + val: 'cancellations', + formattedVal: 'cancellations', + }, + ], + groupbyFieldName: 'groupby', + }, + }), + ); +}); + +test('context menu passes empty drill filters when the chart has no groupby', () => { + const onContextMenu = jest.fn(); + const props = buildProps({ + groupby: [], + labelMap: {}, + selectedValues: {}, + onContextMenu, + }); + + const handlers = allEventHandlers(props); + handlers.contextmenu({ + name: 'total', + event: { + stop: jest.fn(), + event: { clientX: 3, clientY: 4 } as unknown as PointerEvent, + }, + }); + + expect(onContextMenu).toHaveBeenCalledWith( + 3, + 4, + expect.objectContaining({ + drillToDetail: [], + drillBy: { filters: [], groupbyFieldName: 'groupby' }, + crossFilter: undefined, + }), + ); +}); diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx index 8ac25a6f105..e066c133c73 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailPane.test.tsx @@ -17,7 +17,11 @@ * under the License. */ import fetchMock from 'fetch-mock'; -import { QueryFormData, SupersetClient } from '@superset-ui/core'; +import { + BinaryQueryObjectFilterClause, + QueryFormData, + SupersetClient, +} from '@superset-ui/core'; import { fireEvent, render, @@ -51,6 +55,9 @@ const waitForRender = (overrides: Record<string, any> = {}) => const SAMPLES_ENDPOINT = 'end:/datasource/samples?force=false&datasource_type=table&datasource_id=7&per_page=50&page=1'; +const SAMPLES_ENDPOINT_PAGE_2 = + 'end:/datasource/samples?force=false&datasource_type=table&datasource_id=7&per_page=50&page=2'; + const DATASET_ENDPOINT = 'glob:*/api/v1/dataset/*'; const MOCKED_DATASET = { @@ -144,6 +151,26 @@ const fetchWithPaginatedData = () => { }); }; +const fetchWithTwoPages = () => { + setupDatasetEndpoint(); + fetchMock.post(SAMPLES_ENDPOINT, { + result: { + total_count: 100, + data: [{ year: 1996, na_sales: 11.27, eu_sales: 8.89 }], + colnames: ['year', 'na_sales', 'eu_sales'], + coltypes: [0, 0, 0], + }, + }); + fetchMock.post(SAMPLES_ENDPOINT_PAGE_2, { + result: { + total_count: 100, + data: [{ year: 2020, na_sales: 42.42, eu_sales: 24.24 }], + colnames: ['year', 'na_sales', 'eu_sales'], + coltypes: [0, 0, 0], + }, + }); +}; + afterEach(() => { fetchMock.clearHistory().removeRoutes(); supersetGetCache.clear(); @@ -416,3 +443,48 @@ test('renders a semantic-view resource with Not available metadata rows', async const notAvailable = await screen.findAllByText('Not available'); expect(notAvailable.length).toBeGreaterThan(0); }); + +test('resets to page 1 and refetches when the drill filters change', async () => { + jest.restoreAllMocks(); + fetchWithTwoPages(); + const initialFilters: BinaryQueryObjectFilterClause[] = [ + { col: 'year', op: '==', val: 1996, formattedVal: '1996' }, + ]; + await waitForRender({ initialFilters }); + expect(await screen.findByText('11.27')).toBeInTheDocument(); + + await userEvent.click(screen.getByTitle('2')); + expect(await screen.findByText('42.42')).toBeInTheDocument(); + expect(screen.queryByText('11.27')).not.toBeInTheDocument(); + + // Removing the filter tag changes `filters`, which should clear the cached + // pages and drop the pane back to page 1 rather than continuing to request + // page 2 under the new filter set. + await userEvent.click(screen.getByLabelText('Close')); + + expect(await screen.findByText('11.27')).toBeInTheDocument(); + expect(screen.queryByText('42.42')).not.toBeInTheDocument(); + const page1Calls = fetchMock.callHistory.calls(SAMPLES_ENDPOINT); + expect(page1Calls).toHaveLength(2); + expect(JSON.parse(page1Calls[0].options.body as string).filters).toEqual([ + expect.objectContaining({ col: 'year', val: 1996 }), + ]); + expect(JSON.parse(page1Calls[1].options.body as string).filters).toEqual([]); +}); + +test('resets to page 1 and refetches on reload', async () => { + jest.restoreAllMocks(); + fetchWithTwoPages(); + await waitForRender(); + expect(await screen.findByText('11.27')).toBeInTheDocument(); + + await userEvent.click(screen.getByTitle('2')); + expect(await screen.findByText('42.42')).toBeInTheDocument(); + expect(screen.queryByText('11.27')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Reload' })); + + expect(await screen.findByText('11.27')).toBeInTheDocument(); + expect(screen.queryByText('42.42')).not.toBeInTheDocument(); + expect(fetchMock.callHistory.calls(SAMPLES_ENDPOINT)).toHaveLength(2); +});
