mirror of
https://github.com/apache/superset.git
synced 2026-07-29 10:02:32 +00:00
Compare commits
4 Commits
chore/depe
...
dashboard-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a198e8b96 | ||
|
|
5822b94b98 | ||
|
|
dc7593f978 | ||
|
|
8435a9ca42 |
74
superset-frontend/playwright/helpers/dnd.ts
Normal file
74
superset-frontend/playwright/helpers/dnd.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Unconditional pause between drag events, letting react-dnd's HTML5 backend
|
||||
* commit its monitor state before the next event fires. Deliberately not in
|
||||
* `TIMEOUT`: that object holds wait *ceilings* (a wait may finish sooner),
|
||||
* whereas this is a fixed sleep that always costs what it says.
|
||||
*/
|
||||
const REACT_DND_SETTLE_MS = 50;
|
||||
|
||||
/**
|
||||
* Drives an HTML5 drag-and-drop using synthetic native drag events.
|
||||
*
|
||||
* The dashboard grid uses react-dnd with the HTML5 backend
|
||||
* (`react-dnd-html5-backend`), which listens for native `dragstart` /
|
||||
* `dragenter` / `dragover` / `drop` events rather than the mouse events that
|
||||
* Playwright's built-in `locator.dragTo()` produces. To trigger it we dispatch
|
||||
* the native drag sequence ourselves, threading a single shared `DataTransfer`
|
||||
* object through every event so react-dnd's monitor sees a consistent payload.
|
||||
*
|
||||
* Mirrors the synthetic-event sequence used by the deprecated Cypress `drag`
|
||||
* helper (cypress-base/cypress/utils/index.ts).
|
||||
*
|
||||
* @param page - Playwright page (used to mint the shared DataTransfer)
|
||||
* @param source - The draggable element (or a descendant; drag events bubble)
|
||||
* @param target - The drop target element
|
||||
*/
|
||||
export async function html5DragAndDrop(
|
||||
page: Page,
|
||||
source: Locator,
|
||||
target: Locator,
|
||||
): Promise<void> {
|
||||
// Note: we intentionally do not scrollIntoView the source. The chart card list
|
||||
// is virtualized, so a separate scroll action can detach the element between
|
||||
// resolution and use; dispatchEvent only requires the node to be attached.
|
||||
|
||||
// A single DataTransfer shared across every event in the sequence: react-dnd's
|
||||
// HTML5 backend reads/writes drag state through it, so reusing one handle is
|
||||
// what makes the monitor treat this as one coherent drag.
|
||||
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());
|
||||
|
||||
await source.dispatchEvent('dragstart', { dataTransfer });
|
||||
// react-dnd's HTML5 backend commits monitor state (the active drag source) on a
|
||||
// microtask after dragstart; a short settle avoids a race where dragover/drop
|
||||
// fire before the backend considers a drag to be in progress.
|
||||
await page.waitForTimeout(REACT_DND_SETTLE_MS);
|
||||
// dragenter must precede dragover for react-dnd to register the hover target.
|
||||
await target.dispatchEvent('dragenter', { dataTransfer });
|
||||
await target.dispatchEvent('dragover', { dataTransfer });
|
||||
await page.waitForTimeout(REACT_DND_SETTLE_MS);
|
||||
await target.dispatchEvent('drop', { dataTransfer });
|
||||
await source.dispatchEvent('dragend', { dataTransfer });
|
||||
|
||||
await dataTransfer.dispose();
|
||||
}
|
||||
@@ -18,11 +18,23 @@
|
||||
*/
|
||||
|
||||
import { Page, Download, Locator } from '@playwright/test';
|
||||
import { Menu } from '../components/core';
|
||||
import { Button, Input, Menu, Tabs } from '../components/core';
|
||||
import { NativeFiltersConfigModal } from '../components/modals';
|
||||
import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { html5DragAndDrop } from '../helpers/dnd';
|
||||
import { TIMEOUT } from '../utils/constants';
|
||||
|
||||
/** Tabs of the dashboard builder side pane, by their rendered label. */
|
||||
type BuilderTab = 'Charts' | 'Layout elements';
|
||||
|
||||
/**
|
||||
* Built-in draggable layout elements, by their rendered label (see
|
||||
* `src/dashboard/components/gridComponents/new/`). Extension-provided elements
|
||||
* carry dynamic names and are not covered here.
|
||||
*/
|
||||
type LayoutElementLabel =
|
||||
'Tabs' | 'Row' | 'Column' | 'Header' | 'Text / Markdown' | 'Divider';
|
||||
|
||||
/**
|
||||
* Dashboard Page object for interacting with dashboards.
|
||||
*/
|
||||
@@ -31,12 +43,28 @@ export class DashboardPage {
|
||||
|
||||
private static readonly SELECTORS = {
|
||||
DASHBOARD_HEADER: '[data-test="dashboard-header-container"]',
|
||||
CHART_GRID_COMPONENT: '[data-test="chart-grid-component"]',
|
||||
DASHBOARD_MENU_TRIGGER: '[data-test="actions-trigger"]',
|
||||
// The header-actions-menu is the data-test for the dropdown menu content
|
||||
HEADER_ACTIONS_MENU: '[data-test="header-actions-menu"]',
|
||||
FILTER_BAR_SETTINGS: '[data-test="filterbar-orientation-icon"]',
|
||||
APPLY_FILTERS_BUTTON:
|
||||
'[data-test="filter-bar__apply-button"], [data-test="filterbar-action-buttons"] button[type="submit"]',
|
||||
EDIT_BUTTON: '[data-test="edit-dashboard-button"]',
|
||||
BUILDER_PANE: '[data-test="dashboard-builder-sidepane"]',
|
||||
CHARTS_SEARCH: '[data-test="dashboard-charts-filter-search-input"]',
|
||||
CHART_CARD: '[data-test="chart-card"]',
|
||||
EMPTY_DROPTARGET: '[data-test="grid-content"] .empty-droptarget',
|
||||
NEW_COMPONENT: '[data-test="new-component"]',
|
||||
CHART_HOLDER: '[data-test="dashboard-component-chart-holder"]',
|
||||
GRID_CONTENT: '[data-test="grid-content"]',
|
||||
DELETE_COMPONENT: '[data-test="dashboard-delete-component-button"]',
|
||||
MARKDOWN_EDITOR: '[data-test="dashboard-markdown-editor"]',
|
||||
EDITABLE_TITLE: '[data-test="editable-title-input"]',
|
||||
// Ace exposes no data-test hooks; these are its own stable DOM classes.
|
||||
ACE_CONTENT: '.ace_content',
|
||||
ACE_TEXT_INPUT: '.ace_text-input',
|
||||
RESIZE_HANDLE_BOTTOM: '.resizable-container-handle--bottom',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
@@ -61,12 +89,16 @@ export class DashboardPage {
|
||||
|
||||
/**
|
||||
* Wait for the dashboard header to be visible.
|
||||
*
|
||||
* The header container renders well before the grid does, so this only
|
||||
* establishes that the dashboard route mounted — pair it with
|
||||
* {@link waitForChartsToLoad} before asserting on chart content.
|
||||
*/
|
||||
async waitForLoad(options?: { timeout?: number }): Promise<void> {
|
||||
const timeout = options?.timeout ?? TIMEOUT.PAGE_LOAD;
|
||||
await this.page.waitForSelector(DashboardPage.SELECTORS.DASHBOARD_HEADER, {
|
||||
timeout,
|
||||
});
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.DASHBOARD_HEADER)
|
||||
.waitFor({ state: 'visible', timeout });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,21 +106,71 @@ export class DashboardPage {
|
||||
*/
|
||||
getChart(chartId: number): Locator {
|
||||
return this.page.locator(
|
||||
`[data-test="chart-grid-component"][data-test-chart-id="${chartId}"]`,
|
||||
`${DashboardPage.SELECTORS.CHART_GRID_COMPONENT}[data-test-chart-id="${chartId}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all charts on the dashboard to finish loading.
|
||||
* Waits until no loading indicators are visible on the page.
|
||||
* Wait for the dashboard's charts to mount and finish loading.
|
||||
*
|
||||
* Waiting only for loading indicators to clear is not enough: the grid mounts
|
||||
* its spinners after the header renders, so a "no visible loader" check
|
||||
* called straight after {@link waitForLoad} passes instantly against a
|
||||
* dashboard that has not started rendering anything. Waiting for at least one
|
||||
* chart grid component first makes the absence of loaders mean "charts
|
||||
* finished" rather than "charts have not begun".
|
||||
*
|
||||
* Only for dashboards that have charts — on an empty one this waits out
|
||||
* `timeout` rather than returning. Use {@link waitForGridToLoad} there.
|
||||
*/
|
||||
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
|
||||
.locator(DashboardPage.SELECTORS.CHART_GRID_COMPONENT)
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout });
|
||||
|
||||
await this.waitForLoadersToSettle(timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the dashboard grid to mount and any loading indicators to clear.
|
||||
*
|
||||
* The counterpart to {@link waitForChartsToLoad} for a dashboard with no
|
||||
* charts on it: the grid container renders whatever the grid holds, so it
|
||||
* gives the "the page got past the header" evidence that a chart component
|
||||
* cannot. Prefer {@link waitForChartsToLoad} whenever charts are expected —
|
||||
* this cannot tell a grid that rendered empty from one whose charts have not
|
||||
* begun rendering.
|
||||
*/
|
||||
async waitForGridToLoad(options?: { timeout?: number }): Promise<void> {
|
||||
const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
|
||||
|
||||
// Attached rather than visible: an empty grid collapses to zero height,
|
||||
// which Playwright counts as not visible.
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.GRID_CONTENT)
|
||||
.first()
|
||||
.waitFor({ state: 'attached', timeout });
|
||||
|
||||
await this.waitForLoadersToSettle(timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve once no loading indicator is visible.
|
||||
*
|
||||
* Uses browser-context evaluation to check visibility directly. Loading
|
||||
* indicators ([aria-label="Loading"]) may persist in the DOM as hidden
|
||||
* elements after charts finish loading, so this checks that none are
|
||||
* currently visible, returning immediately when they are already settled (no
|
||||
* timeout penalty).
|
||||
*
|
||||
* Loader absence is also the state of a dashboard that has not started
|
||||
* rendering, which is why every caller pairs this with a wait for the content
|
||||
* it expects.
|
||||
*/
|
||||
private async waitForLoadersToSettle(timeout: number): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
() => {
|
||||
const loaders = document.querySelectorAll('[aria-label="Loading"]');
|
||||
@@ -129,7 +211,9 @@ export class DashboardPage {
|
||||
* Opens the native filters and Display Controls configuration modal.
|
||||
*/
|
||||
async openNativeFiltersConfigModal(): Promise<NativeFiltersConfigModal> {
|
||||
await this.page.click(DashboardPage.SELECTORS.FILTER_BAR_SETTINGS);
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.FILTER_BAR_SETTINGS)
|
||||
.click();
|
||||
await this.page
|
||||
.getByText('Add or edit filters and controls', { exact: true })
|
||||
.click();
|
||||
@@ -141,12 +225,17 @@ export class DashboardPage {
|
||||
|
||||
/**
|
||||
* Applies pending native filter changes when the Apply button is enabled.
|
||||
*
|
||||
* A disabled button means there is nothing pending, which is a valid state to
|
||||
* skip. A *missing* button is not — the button is waited for rather than
|
||||
* having its absence collapse into the same no-op as "nothing to apply".
|
||||
*/
|
||||
async applyFiltersIfEnabled(): Promise<void> {
|
||||
const applyButton = this.page
|
||||
.locator(DashboardPage.SELECTORS.APPLY_FILTERS_BUTTON)
|
||||
.first();
|
||||
if (!(await applyButton.isEnabled().catch(() => false))) {
|
||||
await applyButton.waitFor({ state: 'attached' });
|
||||
if (!(await applyButton.isEnabled())) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -157,14 +246,13 @@ export class DashboardPage {
|
||||
* Open the dashboard header actions menu (three-dot menu)
|
||||
*/
|
||||
async openHeaderActionsMenu(): Promise<void> {
|
||||
await this.page.click(DashboardPage.SELECTORS.DASHBOARD_MENU_TRIGGER);
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.DASHBOARD_MENU_TRIGGER)
|
||||
.click();
|
||||
// Wait for the dropdown menu to appear
|
||||
await this.page.waitForSelector(
|
||||
DashboardPage.SELECTORS.HEADER_ACTIONS_MENU,
|
||||
{
|
||||
state: 'visible',
|
||||
},
|
||||
);
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.HEADER_ACTIONS_MENU)
|
||||
.waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,4 +287,192 @@ export class DashboardPage {
|
||||
await menu.selectSubmenuItem('Download', optionText);
|
||||
return downloadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter dashboard edit mode and wait for the builder side pane to appear.
|
||||
*/
|
||||
async enterEditMode(): Promise<void> {
|
||||
const editButton = new Button(
|
||||
this.page,
|
||||
DashboardPage.SELECTORS.EDIT_BUTTON,
|
||||
);
|
||||
await editButton.click();
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.BUILDER_PANE)
|
||||
.waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
/**
|
||||
* The builder side pane's tab bar (Charts / Layout elements).
|
||||
*/
|
||||
private builderTabs(): Tabs {
|
||||
return new Tabs(
|
||||
this.page,
|
||||
this.page
|
||||
.locator(`${DashboardPage.SELECTORS.BUILDER_PANE} .ant-tabs`)
|
||||
.first(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the builder side pane to one of its tabs.
|
||||
* @param tab - 'Charts' (existing slices) or 'Layout elements' (new components)
|
||||
*/
|
||||
private async openBuilderTab(tab: BuilderTab): Promise<void> {
|
||||
await this.builderTabs().clickTab(tab);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator for chart-holder components currently placed on the grid.
|
||||
* Markdown components are chart holders too — use
|
||||
* {@link getMarkdownEditors} when the assertion must exclude them.
|
||||
*/
|
||||
getChartHolders(): Locator {
|
||||
return this.page.locator(DashboardPage.SELECTORS.CHART_HOLDER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag an existing chart from the Charts pane onto the dashboard grid.
|
||||
* Requires edit mode to be active.
|
||||
* @param sliceName - The slice name to search for and drag
|
||||
*/
|
||||
async addChartByName(sliceName: string): Promise<void> {
|
||||
await this.openBuilderTab('Charts');
|
||||
const search = new Input(this.page, DashboardPage.SELECTORS.CHARTS_SEARCH);
|
||||
await search.fill(sliceName);
|
||||
const card = this.page
|
||||
.locator(DashboardPage.SELECTORS.CHART_CARD)
|
||||
.filter({ hasText: sliceName })
|
||||
.first();
|
||||
await card.waitFor({ state: 'visible' });
|
||||
await html5DragAndDrop(this.page, card, this.dropTarget());
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a new Layout element (by its label) onto the dashboard grid.
|
||||
* Requires edit mode to be active.
|
||||
* @param label - The new-component label, e.g. 'Text / Markdown'
|
||||
*/
|
||||
async addLayoutElement(label: LayoutElementLabel): Promise<void> {
|
||||
await this.openBuilderTab('Layout elements');
|
||||
const source = this.page
|
||||
.locator(DashboardPage.SELECTORS.NEW_COMPONENT)
|
||||
.filter({ hasText: label })
|
||||
.first();
|
||||
await source.waitFor({ state: 'visible' });
|
||||
await html5DragAndDrop(this.page, source, this.dropTarget());
|
||||
}
|
||||
|
||||
/**
|
||||
* The grid's empty drop target, which the grid renders while in edit mode.
|
||||
*
|
||||
* Only resolves while the grid is still empty. Dropping a second component
|
||||
* needs a target relative to the already-placed one, not this.
|
||||
*/
|
||||
private dropTarget(): Locator {
|
||||
return this.page.locator(DashboardPage.SELECTORS.EMPTY_DROPTARGET).first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover the first placed chart-holder and click its delete button (edit mode).
|
||||
*/
|
||||
async deleteChartHolder(): Promise<void> {
|
||||
const holder = this.getChartHolders().first();
|
||||
await holder.hover();
|
||||
const deleteButton = new Button(
|
||||
this.page,
|
||||
holder.locator(DashboardPage.SELECTORS.DELETE_COMPONENT),
|
||||
);
|
||||
await deleteButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator for markdown editor components on the grid.
|
||||
*/
|
||||
getMarkdownEditors(): Locator {
|
||||
return this.page.locator(DashboardPage.SELECTORS.MARKDOWN_EDITOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rendered ace document inside a markdown component. Present only once
|
||||
* the component has entered its editing state.
|
||||
*
|
||||
* Exposed as a locator rather than routed through the `AceEditor` component:
|
||||
* that component reads and writes through `ace.edit(...)` in page context,
|
||||
* which both bypasses the real keystroke path under test and gives up
|
||||
* web-first retries on assertions.
|
||||
*
|
||||
* @param markdownEditor - A locator from {@link getMarkdownEditors}
|
||||
*/
|
||||
getMarkdownAceContent(markdownEditor: Locator): Locator {
|
||||
return markdownEditor.locator(DashboardPage.SELECTORS.ACE_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ace's hidden textarea inside a markdown component — the element that
|
||||
* receives keystrokes.
|
||||
*
|
||||
* @param markdownEditor - A locator from {@link getMarkdownEditors}
|
||||
*/
|
||||
getMarkdownAceInput(markdownEditor: Locator): Locator {
|
||||
return markdownEditor.locator(DashboardPage.SELECTORS.ACE_TEXT_INPUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the dashboard title, moving focus off whichever grid component holds
|
||||
* it. Committing a markdown edit needs a click on some other element, and the
|
||||
* title is the one that is always present regardless of what is on the grid.
|
||||
*
|
||||
* In edit mode the click focuses the title's input. That is a state change,
|
||||
* not a no-op — but it edits nothing on its own, so it leaves the component
|
||||
* under test untouched.
|
||||
*/
|
||||
async blurToDashboardTitle(): Promise<void> {
|
||||
await this.page
|
||||
.locator(DashboardPage.SELECTORS.EDITABLE_TITLE)
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a grid component's bottom resize handle down by `deltaY` pixels.
|
||||
* Requires edit mode. Uses the mouse because the resize handle is driven by
|
||||
* `react-resizable`, which tracks real pointer movement.
|
||||
*
|
||||
* @param component - The grid component to resize
|
||||
* @param deltaY - Pixels to drag downwards (positive grows the component)
|
||||
* @returns The component's height before and after the drag
|
||||
*/
|
||||
async resizeComponent(
|
||||
component: Locator,
|
||||
deltaY: number,
|
||||
): Promise<{ heightBefore: number; heightAfter: number }> {
|
||||
const boxBefore = await component.boundingBox();
|
||||
if (!boxBefore) {
|
||||
throw new Error('Cannot resize a component that is not visible');
|
||||
}
|
||||
|
||||
const handle = component
|
||||
.locator(DashboardPage.SELECTORS.RESIZE_HANDLE_BOTTOM)
|
||||
.last();
|
||||
const handleBox = await handle.boundingBox();
|
||||
if (!handleBox) {
|
||||
throw new Error('Resize handle is not visible');
|
||||
}
|
||||
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
await this.page.mouse.move(startX, startY);
|
||||
await this.page.mouse.down();
|
||||
// Multiple steps so react-resizable sees a drag rather than a teleport.
|
||||
await this.page.mouse.move(startX, startY + deltaY, { steps: 10 });
|
||||
await this.page.mouse.up();
|
||||
|
||||
const boxAfter = await component.boundingBox();
|
||||
if (!boxAfter) {
|
||||
throw new Error('Component disappeared during resize');
|
||||
}
|
||||
|
||||
return { heightBefore: boxBefore.height, heightAfter: boxAfter.height };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ interface TestDashboardResult {
|
||||
interface CreateTestDashboardOptions {
|
||||
/** Prefix for generated name (default: 'test_dashboard') */
|
||||
prefix?: string;
|
||||
/** Publish the dashboard on creation (default: false, the API default) */
|
||||
published?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,6 +88,7 @@ export async function createTestDashboard(
|
||||
|
||||
const response = await apiPostDashboard(page, {
|
||||
dashboard_title: name,
|
||||
...(options?.published !== undefined && { published: options.published }),
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dashboard edit-mode component tests — migrated from the deprecated Cypress
|
||||
* suite (cypress-base/cypress/e2e/dashboard/editmode.test.ts, "Components"
|
||||
* block). These cover the chart/markdown drag-and-drop workflows that the
|
||||
* upstream Cypress notes flagged as the one part of edit mode that genuinely
|
||||
* requires E2E coverage ("Chart drag/drop functionality requires true E2E
|
||||
* testing"). The grid uses react-dnd with the HTML5 backend, so drags are
|
||||
* driven by synthetic native drag events (see helpers/dnd.ts).
|
||||
*
|
||||
* The 21 skipped "Color consistency" tests from the same Cypress file are NOT
|
||||
* migrated here: they assert per-series colors by reading an `.nv-legend-symbol`
|
||||
* SVG `fill` attribute that no longer exists (ECharts renders to <canvas>, which
|
||||
* is not DOM-inspectable — the upstream FIXME skipped them for exactly this
|
||||
* reason). The underlying color-precedence logic is covered by Jest/RTL.
|
||||
*/
|
||||
|
||||
import {
|
||||
testWithAssets,
|
||||
expect,
|
||||
type TestAssets,
|
||||
} from '../../helpers/fixtures';
|
||||
import { apiPostChart } from '../../helpers/api/chart';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { extractIdFromResponse } from '../../helpers/api/assertions';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import { createTestDashboard } from './dashboard-test-helpers';
|
||||
import type { Page, TestInfo } from '@playwright/test';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
/**
|
||||
* How long one click on the markdown component gets to bring up the ace editor
|
||||
* before the retry loop tries again, and how long the whole loop gets. The
|
||||
* per-attempt budget is deliberately short: the failure mode is a swallowed
|
||||
* click, and retrying is cheaper than waiting out the full budget once.
|
||||
*/
|
||||
const MARKDOWN_EDIT_ATTEMPT_TIMEOUT = 2000;
|
||||
const MARKDOWN_EDIT_TOTAL_TIMEOUT = 20000;
|
||||
|
||||
/** Downward drag distance for the resize assertion — several grid rows. */
|
||||
const RESIZE_DELTA_PX = 150;
|
||||
|
||||
/** Create a hermetic chart from birth_names, NOT placed on any dashboard. */
|
||||
async function createChart(
|
||||
page: Page,
|
||||
testAssets: TestAssets,
|
||||
testInfo: TestInfo,
|
||||
): Promise<string> {
|
||||
const dataset = await getDatasetByName(page, DATASET_NAME);
|
||||
if (!dataset) {
|
||||
throw new Error(`Dataset ${DATASET_NAME} not found`);
|
||||
}
|
||||
const sliceName = `edit_mode_chart_${Date.now()}_${testInfo.parallelIndex}`;
|
||||
const resp = await apiPostChart(page, {
|
||||
slice_name: sliceName,
|
||||
viz_type: 'big_number_total',
|
||||
datasource_id: dataset.id,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify({
|
||||
datasource: `${dataset.id}__table`,
|
||||
viz_type: 'big_number_total',
|
||||
metric: 'count',
|
||||
}),
|
||||
});
|
||||
expect(resp.ok()).toBe(true);
|
||||
testAssets.trackChart(await extractIdFromResponse(resp));
|
||||
return sliceName;
|
||||
}
|
||||
|
||||
/** Options for the empty published dashboard every test in this file starts from. */
|
||||
const DASHBOARD_OPTIONS = { prefix: 'edit_mode', published: true } as const;
|
||||
|
||||
testWithAssets(
|
||||
'edit mode: add a chart to the dashboard via drag-and-drop',
|
||||
async ({ page, testAssets }, testInfo) => {
|
||||
const sliceName = await createChart(page, testAssets, testInfo);
|
||||
const { id: dashboardId } = await createTestDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testInfo,
|
||||
DASHBOARD_OPTIONS,
|
||||
);
|
||||
|
||||
const dashboard = new DashboardPage(page);
|
||||
await dashboard.gotoById(dashboardId);
|
||||
await dashboard.waitForLoad();
|
||||
await dashboard.enterEditMode();
|
||||
|
||||
await expect(dashboard.getChartHolders()).toHaveCount(0);
|
||||
await dashboard.addChartByName(sliceName);
|
||||
await expect(dashboard.getChartHolders()).toHaveCount(1);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'edit mode: remove an added chart from the dashboard',
|
||||
async ({ page, testAssets }, testInfo) => {
|
||||
const sliceName = await createChart(page, testAssets, testInfo);
|
||||
const { id: dashboardId } = await createTestDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testInfo,
|
||||
DASHBOARD_OPTIONS,
|
||||
);
|
||||
|
||||
const dashboard = new DashboardPage(page);
|
||||
await dashboard.gotoById(dashboardId);
|
||||
await dashboard.waitForLoad();
|
||||
await dashboard.enterEditMode();
|
||||
|
||||
await dashboard.addChartByName(sliceName);
|
||||
await expect(dashboard.getChartHolders()).toHaveCount(1);
|
||||
|
||||
await dashboard.deleteChartHolder();
|
||||
await expect(dashboard.getChartHolders()).toHaveCount(0);
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'edit mode: add a markdown component via drag-and-drop',
|
||||
async ({ page, testAssets }, testInfo) => {
|
||||
// Heaviest edit-mode flow (drag + ace edit + commit + mouse resize); give it
|
||||
// extra headroom so it stays reliable when the suite runs in parallel.
|
||||
testWithAssets.slow();
|
||||
const { id: dashboardId } = await createTestDashboard(
|
||||
page,
|
||||
testAssets,
|
||||
testInfo,
|
||||
DASHBOARD_OPTIONS,
|
||||
);
|
||||
|
||||
const dashboard = new DashboardPage(page);
|
||||
await dashboard.gotoById(dashboardId);
|
||||
await dashboard.waitForLoad();
|
||||
await dashboard.enterEditMode();
|
||||
|
||||
await dashboard.addLayoutElement('Text / Markdown');
|
||||
const editor = dashboard.getMarkdownEditors().first();
|
||||
await expect(editor).toBeVisible();
|
||||
|
||||
// Enter edit mode by focusing the component. The markdown enters edit on a
|
||||
// document-level focus handler attached after mount, so a single early click
|
||||
// can be missed under load; retry until the ace editor appears. Click the
|
||||
// rendered "Header 1" heading element specifically (never the trailing
|
||||
// hyperlink in the default content), so a stray click can't navigate away.
|
||||
const aceContent = dashboard.getMarkdownAceContent(editor);
|
||||
const heading = editor.locator('h1', { hasText: 'Header 1' });
|
||||
await expect(async () => {
|
||||
if (await aceContent.isVisible()) return;
|
||||
await heading.click();
|
||||
await expect(aceContent).toBeVisible({
|
||||
timeout: MARKDOWN_EDIT_ATTEMPT_TIMEOUT,
|
||||
});
|
||||
}).toPass({ timeout: MARKDOWN_EDIT_TOTAL_TIMEOUT });
|
||||
await expect(aceContent).toContainText('Header 1');
|
||||
await expect(aceContent).toContainText('markdown formatting');
|
||||
|
||||
// Replace the content and confirm the edit is reflected.
|
||||
const aceInput = dashboard.getMarkdownAceInput(editor);
|
||||
await aceInput.press('ControlOrMeta+a');
|
||||
await aceInput.press('Delete');
|
||||
await aceInput.pressSequentially('Test resize');
|
||||
await expect(aceContent).toContainText('Test resize');
|
||||
|
||||
// Commit by clicking outside the component; the preview keeps the text.
|
||||
await dashboard.blurToDashboardTitle();
|
||||
await expect(editor).toContainText('Test resize');
|
||||
|
||||
// Resize via the bottom handle and confirm the component grew taller.
|
||||
const { heightBefore, heightAfter } = await dashboard.resizeComponent(
|
||||
editor,
|
||||
RESIZE_DELTA_PX,
|
||||
);
|
||||
expect(heightAfter).toBeGreaterThan(heightBefore);
|
||||
},
|
||||
);
|
||||
@@ -152,9 +152,11 @@ test('non-admin user can view a themed dashboard without 403 or infinite spinner
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.gotoById(dashboardId!);
|
||||
|
||||
// 7. Assert dashboard fully loads (not stuck on infinite spinner)
|
||||
// 7. Assert dashboard fully loads (not stuck on infinite spinner).
|
||||
// The dashboard is created with no position_json, so its grid renders
|
||||
// empty — there is no chart to wait for, only the grid itself.
|
||||
await dashboardPage.waitForLoad({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
await dashboardPage.waitForChartsToLoad();
|
||||
await dashboardPage.waitForGridToLoad();
|
||||
|
||||
// 8. Assert no /api/v1/theme/ requests were made (theme data comes from dashboard response)
|
||||
expect(themeApiRequests).toHaveLength(0);
|
||||
|
||||
Reference in New Issue
Block a user