mirror of
https://github.com/apache/superset.git
synced 2026-06-04 23:29:24 +00:00
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:
217
superset-frontend/playwright/components/core/Menu.ts
Normal file
217
superset-frontend/playwright/components/core/Menu.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user