fix(chart): stop contextmenu propagation in BigNumberViz

Without stopPropagation(), the native contextmenu event bubbles from
the big number's header line to ChartRenderer's fallback handler,
which re-invokes the menu-open logic within the same synchronous
event dispatch (its inContextMenu guard reads stale React state).
The duplicate call re-clicks the dropdown's hidden trigger, closing
the context menu immediately after it opens. Every other chart type
with a context menu (Table, Pivot Table, deck.gl, ECharts canvas
charts) already stops propagation here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Joe Li
2026-08-12 15:44:10 -07:00
co-authored by Claude Sonnet 5
parent 4c1693747b
commit 614726d96b
5 changed files with 179 additions and 78 deletions
@@ -0,0 +1,127 @@
/**
* 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 { Locator, Page } from '@playwright/test';
import { Modal } from '../core';
/**
* The "Drill to detail" modal (`DrillDetailModal.tsx`), opened from a chart's
* "More Options" menu or its right-click context menu. Renders the chart's
* underlying sample rows, optionally scoped to a drilled-by value, via the
* `/datasource/samples` API.
*/
export class DrillDetailModal extends Modal {
private static readonly SELECTORS = {
CLOSE_BUTTON: '[data-test="close-drilltodetail-modal"]',
ROW_COUNT_LABEL: '[data-test="row-count-label"]',
METADATA_BAR: '[data-test="metadata-bar"]',
FILTER_COLUMN: '[data-test="filter-col"]',
FILTER_VALUE: '[data-test="filter-val"]',
PAGE_ITEM: '.ant-pagination-item',
ACTIVE_PAGE_ITEM: '.ant-pagination-item-active',
GRID_CELL: '.virtual-table-cell',
} as const;
private readonly specificLocator: Locator;
constructor(page: Page) {
super(page);
this.specificLocator = page.getByRole('dialog', {
name: /^Drill to detail:/,
});
}
override get element(): Locator {
return this.specificLocator;
}
/**
* The applied-filter value tags (`<col>=<val>`). Empty when the drill was
* whole-chart (no row/point-level filter applied).
*/
get filterValues(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.FILTER_VALUE);
}
/** The applied-filter chip(s); each is closable via its own "Close" icon. */
get filterColumns(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.FILTER_COLUMN);
}
/** Row-count label above the results grid, e.g. "1-50 of 500 rows". */
get rowCountLabel(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.ROW_COUNT_LABEL);
}
/** The metadata bar (column/row summary) shown once samples have loaded. */
get metadataBar(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.METADATA_BAR);
}
/** Pagination page-number items below the results grid. */
get pageItems(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.PAGE_ITEM);
}
/** The currently active pagination page-number item. */
get activePageItem(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.ACTIVE_PAGE_ITEM);
}
/** Cells of the virtualized results grid. */
get gridCells(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.GRID_CELL);
}
/**
* Removes the first applied filter by clicking its chip's Close icon,
* re-fetching the unfiltered samples.
*/
async clearFirstFilter(): Promise<void> {
await this.filterColumns.first().getByLabel('Close').click();
}
/** Navigates to the given 1-indexed pagination page. */
async goToPage(pageNumber: number): Promise<void> {
await this.pageItems.nth(pageNumber - 1).click();
}
/** Re-fetches the current samples query, resetting pagination to page 1. */
async reload(): Promise<void> {
await this.element.getByRole('button', { name: 'Reload' }).click();
}
/** Navigates to Explore for the drilled chart (hidden when embedded). */
async editChart(): Promise<void> {
await this.element.getByRole('button', { name: 'Edit chart' }).click();
}
/**
* Closes the modal via its footer Close button, if open (idempotent).
*/
async close(): Promise<void> {
const closeButton = this.element.locator(
DrillDetailModal.SELECTORS.CLOSE_BUTTON,
);
if (await closeButton.count()) {
await closeButton.first().click();
await this.waitForHidden();
}
}
}
@@ -21,6 +21,7 @@
export { ChartPropertiesModal } from './ChartPropertiesModal';
export { ConfirmDialog } from './ConfirmDialog';
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { DrillDetailModal } from './DrillDetailModal';
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
export { EditDatasetModal } from './EditDatasetModal';
export { ImportDatasetModal } from './ImportDatasetModal';
@@ -20,6 +20,7 @@
import { Page, Download, Locator, expect } from '@playwright/test';
import { Button, Input, Menu, Tabs } from '../components/core';
import { DashboardFilterBar } from '../components/dashboard';
import { DrillDetailModal } from '../components/modals';
import { gotoWithRetry } from '../helpers/navigation';
import { html5DragAndDrop } from '../helpers/dnd';
import { TIMEOUT } from '../utils/constants';
@@ -491,26 +492,8 @@ export class DashboardPage {
/**
* The DrillDetailModal dialog (titled "Drill to detail: <chart name>").
*/
drillModal(): Locator {
return this.page.getByRole('dialog', { name: /^Drill to detail:/ });
}
/**
* Close the drill-to-detail modal if it is open (idempotent).
*/
async closeDrillModal(): Promise<void> {
const close = this.page.locator('[data-test="close-drilltodetail-modal"]');
if (await close.count()) {
await close.first().click();
await this.drillModal().waitFor({ state: 'hidden' });
}
}
/**
* The applied-filter value tags inside the drill modal (`<col>=<val>`).
*/
drillFilterValues(): Locator {
return this.page.locator('[data-test="filter-val"]');
drillModal(): DrillDetailModal {
return new DrillDetailModal(this.page);
}
/**
@@ -299,8 +299,10 @@ async function expectCanvasDrillByValueRoundTrips(
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
await expect(dashboard.drillModal()).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText(value);
await expect(dashboard.drillModal().element).toBeVisible();
await expect(dashboard.drillModal().filterValues.first()).toContainText(
value,
);
}
// Shared form-data fragment for the echarts time-series family (line/scatter/
@@ -332,40 +334,31 @@ testWithAssets(
await samplesOnOpen;
const modal = dashboard.drillModal();
await expect(modal).toBeVisible();
await expect(modal).toContainText('Drill to detail:');
await expect(modal.element).toBeVisible();
await expect(modal.element).toContainText('Drill to detail:');
// The metadata bar and a real row count prove the modal loaded backend data.
await expect(modal.locator('[data-test="metadata-bar"]')).toBeVisible();
await expect(modal.locator('[data-test="row-count-label"]')).toContainText(
'rows',
);
await expect(modal.metadataBar).toBeVisible();
await expect(modal.rowCountLabel).toContainText('rows');
// No drill filter was applied (whole-chart drill).
await expect(dashboard.drillFilterValues()).toHaveCount(0);
await expect(modal.filterValues).toHaveCount(0);
// The full dataset spans multiple pages, and the grid has rendered rows.
const pageItems = modal.locator('.ant-pagination-item');
expect(await pageItems.count()).toBeGreaterThan(1);
await expect(modal.locator('.virtual-table-cell').first()).toBeVisible();
await expect(modal.locator('.ant-pagination-item-active')).toContainText(
'1',
);
expect(await modal.pageItems.count()).toBeGreaterThan(1);
await expect(modal.gridCells.first()).toBeVisible();
await expect(modal.activePageItem).toContainText('1');
// Paginate forward: clicking page 2 fires a real samples fetch and moves the
// active page to 2.
const samplesOnPage2 = expectSamplesPost(page);
await modal.locator('.ant-pagination-item').nth(1).click();
await modal.goToPage(2);
await samplesOnPage2;
await expect(modal.locator('.ant-pagination-item-active')).toContainText(
'2',
);
await expect(modal.activePageItem).toContainText('2');
// Reload re-fetches and resets back to the first page.
const samplesOnReload = expectSamplesPost(page);
await modal.getByRole('button', { name: 'Reload' }).click();
await modal.reload();
await samplesOnReload;
await expect(modal.locator('.ant-pagination-item-active')).toContainText(
'1',
);
await expect(modal.activePageItem).toContainText('1');
},
);
@@ -391,12 +384,10 @@ testWithAssets(
await dashboard.contextMenuDrillToDetail();
await samples;
await expect(dashboard.drillModal()).toBeVisible();
await expect(dashboard.drillModal().element).toBeVisible();
// Whole-chart drill: no per-value filter tag.
await expect(dashboard.drillFilterValues()).toHaveCount(0);
await expect(
dashboard.drillModal().locator('[data-test="row-count-label"]'),
).toContainText('rows');
await expect(dashboard.drillModal().filterValues).toHaveCount(0);
await expect(dashboard.drillModal().rowCountLabel).toContainText('rows');
},
);
@@ -430,25 +421,19 @@ testWithAssets(
await samplesOnDrill;
const modal = dashboard.drillModal();
await expect(modal).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText('boy');
await expect(modal.element).toBeVisible();
await expect(modal.filterValues.first()).toContainText('boy');
const filteredCount = parseRowCount(
await modal.locator('[data-test="row-count-label"]').innerText(),
);
const filteredCount = parseRowCount(await modal.rowCountLabel.innerText());
expect(filteredCount).toBeGreaterThan(0);
// Clearing the filter reloads the samples and restores the larger, unfiltered total.
const samplesOnClear = expectSamplesPost(page);
await modal.locator('[data-test="filter-col"]').getByLabel('Close').click();
await modal.clearFirstFilter();
await samplesOnClear;
await expect(dashboard.drillFilterValues()).toHaveCount(0);
await expect(modal.filterValues).toHaveCount(0);
await expect
.poll(async () =>
parseRowCount(
await modal.locator('[data-test="row-count-label"]').innerText(),
),
)
.poll(async () => parseRowCount(await modal.rowCountLabel.innerText()))
.toBeGreaterThan(filteredCount);
},
);
@@ -488,8 +473,10 @@ testWithAssets(
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
await expect(dashboard.drillModal()).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText(value);
await expect(dashboard.drillModal().element).toBeVisible();
await expect(dashboard.drillModal().filterValues.first()).toContainText(
value,
);
},
);
@@ -519,8 +506,10 @@ testWithAssets(
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
await expect(dashboard.drillModal()).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText(value);
await expect(dashboard.drillModal().element).toBeVisible();
await expect(dashboard.drillModal().filterValues.first()).toContainText(
value,
);
},
);
@@ -563,8 +552,10 @@ testWithAssets(
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
await expect(dashboard.drillModal()).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText(value);
await expect(dashboard.drillModal().element).toBeVisible();
await expect(dashboard.drillModal().filterValues.first()).toContainText(
value,
);
},
);
@@ -595,11 +586,9 @@ testWithAssets(
await dashboard.contextMenuDrillToDetail();
await samples;
await expect(dashboard.drillModal()).toBeVisible();
await expect(dashboard.drillFilterValues()).toHaveCount(0);
await expect(
dashboard.drillModal().locator('[data-test="row-count-label"]'),
).toContainText('rows');
await expect(dashboard.drillModal().element).toBeVisible();
await expect(dashboard.drillModal().filterValues).toHaveCount(0);
await expect(dashboard.drillModal().rowCountLabel).toContainText('rows');
},
);
@@ -805,10 +794,10 @@ testWithAssets(
await dashboard.contextMenuDrillToDetailBy('all');
await samples;
await expect(dashboard.drillModal()).toBeVisible();
expect(await dashboard.drillFilterValues().count()).toBeGreaterThanOrEqual(
2,
);
await expect(dashboard.drillModal().element).toBeVisible();
expect(
await dashboard.drillModal().filterValues.count(),
).toBeGreaterThanOrEqual(2);
},
);
@@ -842,9 +831,9 @@ testWithAssets(
await samples;
const modal = dashboard.drillModal();
await expect(modal).toBeVisible();
await expect(dashboard.drillFilterValues().first()).toContainText(value);
await dashboard.closeDrillModal();
await expect(modal.element).toBeVisible();
await expect(modal.filterValues.first()).toContainText(value);
await modal.close();
}
},
);
@@ -224,6 +224,7 @@ function BigNumberVis({
const handleContextMenu = (e: MouseEvent<HTMLDivElement>) => {
if (onContextMenu) {
e.preventDefault();
e.stopPropagation();
onContextMenu(e.nativeEvent.clientX, e.nativeEvent.clientY);
}
};