fix(dashboard): fix Export as Example with app prefix and enable Dashboard Export E2E tests (#37529)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Joe Li
2026-02-02 12:07:22 -08:00
committed by GitHub
parent e9b494163b
commit 86f690d17f
6 changed files with 381 additions and 78 deletions

View File

@@ -0,0 +1,217 @@
/**
* 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 { TIMEOUT } from '../../utils/constants';
/**
* Menu component for Ant Design dropdown menus.
* Uses hover as primary approach (most natural user interaction).
* Falls back to keyboard navigation, then dispatchEvent if hover fails.
*
* This component handles menu content only - not the trigger that opens the menu.
* The calling page object should open the menu first, then use this component.
*
* @example
* // In a page object
* async selectDownloadOption(optionText: string): Promise<void> {
* await this.openHeaderActionsMenu();
* const menu = new Menu(this.page, '[data-test="header-actions-menu"]');
* await menu.selectSubmenuItem('Download', optionText);
* }
*/
export class Menu {
private readonly page: Page;
private readonly locator: Locator;
private static readonly SELECTORS = {
SUBMENU: '.ant-dropdown-menu-submenu',
SUBMENU_POPUP: '.ant-dropdown-menu-submenu-popup',
SUBMENU_TITLE: '.ant-dropdown-menu-submenu-title',
} as const;
/**
* Ant Design animation delay - allows slide-in animation to complete.
* Without this, elements may be "not stable" and clicks can fail.
*/
private static readonly ANIMATION_DELAY = 150;
constructor(page: Page, selector: string);
constructor(page: Page, locator: Locator);
constructor(page: Page, selectorOrLocator: string | Locator) {
this.page = page;
if (typeof selectorOrLocator === 'string') {
this.locator = page.locator(selectorOrLocator);
} else {
this.locator = selectorOrLocator;
}
}
/**
* Opens a submenu and selects an item within it.
* Uses hover as primary approach, falls back to keyboard then dispatchEvent.
*
* @param submenuText - The text of the submenu to open (e.g., "Download")
* @param itemText - The text of the item to select (e.g., "Export YAML")
* @param options - Optional timeout settings
*/
async selectSubmenuItem(
submenuText: string,
itemText: string,
options?: { timeout?: number },
): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.FORM_LOAD;
// Try hover first (most natural user interaction)
let popup = await this.openSubmenuWithHover(submenuText, itemText, timeout);
// Fallback to keyboard navigation
if (!popup) {
popup = await this.openSubmenuWithKeyboard(
submenuText,
itemText,
timeout,
);
}
// Last resort: dispatchEvent
if (!popup) {
popup = await this.openSubmenuWithDispatchEvent(
submenuText,
itemText,
timeout,
);
}
if (!popup) {
throw new Error(
`Failed to open submenu "${submenuText}". Tried hover, keyboard, and dispatchEvent.`,
);
}
// Use dispatchEvent instead of click to bypass viewport and pointer interception
// issues. Ant Design renders submenu popups in a portal that can be positioned
// outside the viewport or behind chart content (e.g., large tables with z-index).
await popup.getByText(itemText, { exact: true }).dispatchEvent('click');
}
/**
* Opens a submenu using native Playwright hover.
* Returns the popup locator if successful, null otherwise.
*/
private async openSubmenuWithHover(
submenuText: string,
itemText: string,
timeout: number,
): Promise<Locator | null> {
try {
const submenuTitle = this.getSubmenuTitle(submenuText);
await submenuTitle.hover();
// Find the popup that contains the expected item (scopes to correct popup)
const popup = this.page
.locator(Menu.SELECTORS.SUBMENU_POPUP)
.filter({ hasText: itemText });
await popup.waitFor({ state: 'visible', timeout });
// Allow Ant Design's slide-in animation to complete before clicking.
// Without this, the element may be "not stable" and clicks can fail.
await this.page.waitForTimeout(Menu.ANIMATION_DELAY);
return popup;
} catch {
return null;
}
}
/**
* Opens a submenu using keyboard navigation.
* Returns the popup locator if successful, null otherwise.
*/
private async openSubmenuWithKeyboard(
submenuText: string,
itemText: string,
timeout: number,
): Promise<Locator | null> {
try {
const submenuTitle = this.getSubmenuTitle(submenuText);
await submenuTitle.focus();
await this.page.keyboard.press('ArrowRight');
const popup = this.page
.locator(Menu.SELECTORS.SUBMENU_POPUP)
.filter({ hasText: itemText });
await popup.waitFor({ state: 'visible', timeout });
return popup;
} catch {
return null;
}
}
/**
* Opens a submenu using dispatchEvent to trigger mouseover/mouseenter.
* Returns the popup locator if successful, null otherwise.
*/
private async openSubmenuWithDispatchEvent(
submenuText: string,
itemText: string,
timeout: number,
): Promise<Locator | null> {
try {
const submenuTitle = this.getSubmenuTitle(submenuText);
await submenuTitle.evaluate(el => {
el.dispatchEvent(
new MouseEvent('mouseover', {
bubbles: true,
cancelable: true,
view: window,
}),
);
el.dispatchEvent(
new MouseEvent('mouseenter', {
bubbles: true,
cancelable: true,
view: window,
}),
);
});
const popup = this.page
.locator(Menu.SELECTORS.SUBMENU_POPUP)
.filter({ hasText: itemText });
await popup.waitFor({ state: 'visible', timeout });
return popup;
} catch {
return null;
}
}
/**
* Gets the submenu title element for a submenu containing the given text.
*/
private getSubmenuTitle(submenuText: string): Locator {
return this.locator
.locator(Menu.SELECTORS.SUBMENU)
.filter({ hasText: submenuText })
.locator(Menu.SELECTORS.SUBMENU_TITLE);
}
}

View File

@@ -21,5 +21,7 @@
export { Button } from './Button';
export { Form } from './Form';
export { Input } from './Input';
export { Menu } from './Menu';
export { Modal } from './Modal';
export { Table } from './Table';
export { Toast } from './Toast';

View File

@@ -18,6 +18,7 @@
*/
import { Page, Download } from '@playwright/test';
import { Menu } from '../components/core';
import { TIMEOUT } from '../utils/constants';
/**
@@ -54,7 +55,7 @@ export class DashboardPage {
}
/**
* Wait for the dashboard to load
* Wait for the dashboard header to be visible.
*/
async waitForLoad(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD;
@@ -63,6 +64,35 @@ export class DashboardPage {
});
}
/**
* Wait for all charts on the dashboard to finish loading.
* Waits until no loading indicators are visible on the page.
*/
async waitForChartsToLoad(options?: { timeout?: number }): Promise<void> {
const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
// Use browser-context evaluation to check visibility directly.
// Loading indicators ([aria-label="Loading"]) may persist in the DOM as hidden
// elements after charts finish loading. This checks that none are currently visible,
// returning immediately when charts are already loaded (no timeout penalty).
await this.page.waitForFunction(
() => {
const loaders = document.querySelectorAll('[aria-label="Loading"]');
if (loaders.length === 0) return true;
return Array.from(loaders).every(el => {
const style = getComputedStyle(el);
return (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0'
);
});
},
undefined,
{ timeout },
);
}
/**
* Open the dashboard header actions menu (three-dot menu)
*/
@@ -78,33 +108,21 @@ export class DashboardPage {
}
/**
* Hover over the Download submenu to open it (Ant Design submenus open on hover)
* Selects an option from the Download submenu.
* Opens the header actions menu, navigates to Download submenu,
* and clicks the specified option.
*
* @param optionText - The download option to select (e.g., "Export YAML")
*/
async openDownloadMenu(): Promise<void> {
// Find the Download menu item within the header actions menu and hover
const menu = this.page.locator(DashboardPage.SELECTORS.HEADER_ACTIONS_MENU);
await menu.getByText('Download', { exact: true }).hover();
// Wait for Export YAML to become visible (indicates submenu opened)
await this.page.getByText('Export YAML').waitFor({ state: 'visible' });
}
async selectDownloadOption(optionText: string): Promise<Download> {
await this.openHeaderActionsMenu();
/**
* Click "Export YAML" in the download menu
* Returns a Promise that resolves when download starts
*/
async clickExportYaml(): Promise<Download> {
const menu = new Menu(
this.page,
DashboardPage.SELECTORS.HEADER_ACTIONS_MENU,
);
const downloadPromise = this.page.waitForEvent('download');
await this.page.getByText('Export YAML').click();
return downloadPromise;
}
/**
* Click "Export as Example" in the download menu
* Returns a Promise that resolves when download starts
*/
async clickExportAsExample(): Promise<Download> {
const downloadPromise = this.page.waitForEvent('download');
await this.page.getByText('Export as Example').click();
await menu.selectSubmenuItem('Download', optionText);
return downloadPromise;
}
}

View File

@@ -19,6 +19,7 @@
import { test, expect } from '@playwright/test';
import { DashboardPage } from '../../../pages/DashboardPage';
import { Toast } from '../../../components/core';
import { TIMEOUT } from '../../../utils/constants';
/**
@@ -31,74 +32,56 @@ import { TIMEOUT } from '../../../utils/constants';
* Prerequisites:
* - Superset running with example dashboards loaded
* - Admin user authenticated (via global-setup)
*
* SKIP REASON: Ant Design Menu submenu hover behavior is not reliably
* triggered by Playwright. The submenu popup doesn't appear consistently
* when hovering over the Download menu item. This functionality is
* covered by unit tests in DownloadMenuItems.test.tsx.
*
* TODO: Investigate Ant Design Menu triggerSubMenuAction or alternative
* approaches for E2E testing of nested menus.
*/
let dashboardPage: DashboardPage;
const downloads: { delete: () => Promise<void> }[] = [];
test.describe('Dashboard Export', () => {
// Dashboard with multiple charts needs extra time for cold-cache CI runs:
// waitForLoad (10s) + waitForChartsToLoad (15s) + menu + download + toast
test.setTimeout(60_000);
test.describe.skip('Dashboard Export', () => {
test.beforeEach(async ({ page }) => {
dashboardPage = new DashboardPage(page);
// Navigate to World Health dashboard (standard example)
await dashboardPage.gotoBySlug('world_health');
await dashboardPage.waitForLoad({ timeout: TIMEOUT.PAGE_LOAD });
// Wait for charts to finish loading - Download menu may be disabled while loading
await dashboardPage.waitForChartsToLoad();
});
test('should download ZIP when clicking Export YAML', async ({ page }) => {
// Open the header actions menu (three-dot menu)
await dashboardPage.openHeaderActionsMenu();
// Open the Download submenu
await dashboardPage.openDownloadMenu();
// Click Export YAML and wait for download
const download = await dashboardPage.clickExportYaml();
// Verify the download
const filename = download.suggestedFilename();
expect(filename).toMatch(/\.zip$/);
test.afterEach(async () => {
// Clean up downloaded files
await Promise.all(downloads.map(d => d.delete().catch(() => {})));
downloads.length = 0;
});
test('should download example bundle when clicking Export as Example', async ({
test('should download ZIP and show success toast when clicking Export YAML', async ({
page,
}) => {
// Open the header actions menu
await dashboardPage.openHeaderActionsMenu();
const toast = new Toast(page);
const download = await dashboardPage.selectDownloadOption('Export YAML');
downloads.push(download);
// Open the Download submenu
await dashboardPage.openDownloadMenu();
// Click Export as Example and wait for download
const download = await dashboardPage.clickExportAsExample();
// Verify the download
const filename = download.suggestedFilename();
expect(filename).toMatch(/_example\.zip$/);
expect(download.suggestedFilename()).toMatch(/\.zip$/);
await expect(toast.getSuccess()).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
});
test('should show success toast after Export as Example', async ({
test('should download example bundle and show success toast when clicking Export as Example', async ({
page,
}) => {
// Open the header actions menu
await dashboardPage.openHeaderActionsMenu();
const toast = new Toast(page);
const download =
await dashboardPage.selectDownloadOption('Export as Example');
downloads.push(download);
// Open the Download submenu
await dashboardPage.openDownloadMenu();
// Click Export as Example
await dashboardPage.clickExportAsExample();
// Verify success toast appears
await expect(
page.locator('.ant-message-success, [data-test="toast-success"]'),
).toBeVisible({ timeout: TIMEOUT.API_RESPONSE });
expect(download.suggestedFilename()).toMatch(/_example\.zip$/);
await expect(toast.getSuccess()).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
});
});

View File

@@ -16,10 +16,38 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import React from 'react';
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { SupersetClient } from '@superset-ui/core';
import { useDownloadMenuItems } from '.';
const mockAddSuccessToast = jest.fn();
const mockAddDangerToast = jest.fn();
jest.mock('src/components/MessageToasts/withToasts', () => ({
__esModule: true,
default: (Component: React.ComponentType) => Component,
useToasts: () => ({
addSuccessToast: mockAddSuccessToast,
addDangerToast: mockAddDangerToast,
}),
}));
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
SupersetClient: {
get: jest.fn(),
},
}));
const mockSupersetClient = SupersetClient as jest.Mocked<typeof SupersetClient>;
const createProps = () => ({
pdfMenuItemTitle: 'Export to PDF',
imageMenuItemTitle: 'Download as Image',
@@ -37,6 +65,18 @@ const MenuWrapper = () => {
return <Menu forceSubMenuRender items={menuItems} />;
};
const originalCreateObjectURL = window.URL.createObjectURL;
const originalRevokeObjectURL = window.URL.revokeObjectURL;
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
window.URL.createObjectURL = originalCreateObjectURL;
window.URL.revokeObjectURL = originalRevokeObjectURL;
});
test('Should render all menu items', () => {
render(<MenuWrapper />, {
useRedux: true,
@@ -50,3 +90,49 @@ test('Should render all menu items', () => {
expect(screen.getByText('Export YAML')).toBeInTheDocument();
expect(screen.getByText('Export as Example')).toBeInTheDocument();
});
test('Export as Example calls SupersetClient.get with correct endpoint', async () => {
const mockBlob = new Blob(['test'], { type: 'application/zip' });
const mockResponse: Pick<Response, 'blob' | 'headers'> = {
blob: jest.fn().mockResolvedValue(mockBlob),
headers: new Headers({
'Content-Disposition': 'attachment; filename="dashboard_123_example.zip"',
}),
};
mockSupersetClient.get.mockResolvedValue(mockResponse as unknown as Response);
// Mock URL.createObjectURL / revokeObjectURL since jsdom doesn't support them
const createObjectURL = jest.fn(() => 'blob:http://localhost/fake');
const revokeObjectURL = jest.fn();
window.URL.createObjectURL = createObjectURL;
window.URL.revokeObjectURL = revokeObjectURL;
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export as Example'));
await waitFor(() => {
expect(mockSupersetClient.get).toHaveBeenCalledWith({
endpoint: '/api/v1/dashboard/123/export_as_example/',
headers: { Accept: 'application/zip' },
parseMethod: 'raw',
});
expect(mockAddSuccessToast).toHaveBeenCalledWith(
'Dashboard exported as example successfully',
);
});
});
test('Export as Example shows error toast on failure', async () => {
mockSupersetClient.get.mockRejectedValue(new Error('Network error'));
render(<MenuWrapper />, { useRedux: true });
await userEvent.click(screen.getByText('Export as Example'));
await waitFor(() => {
expect(mockAddDangerToast).toHaveBeenCalledWith(
'Sorry, something went wrong. Try again later.',
);
});
});

View File

@@ -35,7 +35,7 @@ import {
LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE,
} from 'src/logger/LogUtils';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { ensureAppRoot } from 'src/utils/pathUtils';
import { DownloadScreenshotFormat } from './types';
export interface UseDownloadMenuItemsProps {
@@ -104,11 +104,8 @@ export const useDownloadMenuItems = (
const onExportAsExample = async () => {
try {
const endpoint = ensureAppRoot(
`/api/v1/dashboard/${dashboardId}/export_as_example/`,
);
const response = await SupersetClient.get({
endpoint,
endpoint: `/api/v1/dashboard/${dashboardId}/export_as_example/`,
headers: {
Accept: 'application/zip',
},