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);
+});

Reply via email to