mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
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:
co-authored by
Claude Sonnet 5
parent
4c1693747b
commit
614726d96b
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user