This is an automated email from the ASF dual-hosted git repository. sadpandajoe pushed a commit to branch remove-explore-cypress-test in repository https://gitbox.apache.org/repos/asf/superset.git
commit 409b359c40b7f4b8deb389798b5fa8d8a22555ef Author: Joe Li <[email protected]> AuthorDate: Thu Sep 24 15:10:36 2026 -0700 test(explore): migrate remaining explore e2e coverage to Playwright Port the last Cypress explore scenarios to Playwright using the page-object model: save-as/overwrite (with and without adding to a new dashboard), the "No results" state, and the chart metadata bar / "On dashboards" submenu reflecting dashboard membership. Adds a SaveChartModal component, Explore page helpers, and chart/dashboard name-lookup API helpers, and removes the superseded Cypress spec and helpers. Co-Authored-By: Claude Opus 5.5 <[email protected]> --- .../cypress-base/cypress/e2e/explore/chart.test.js | 157 ----------------- .../cypress-base/cypress/e2e/explore/utils.ts | 78 --------- .../e2e/explore/visualizations/shared.helper.js | 139 --------------- .../playwright/components/modals/SaveChartModal.ts | 90 ++++++++++ .../playwright/components/modals/index.ts | 1 + superset-frontend/playwright/helpers/api/chart.ts | 44 +++++ .../playwright/helpers/api/dashboard.ts | 37 ++++ superset-frontend/playwright/pages/ExplorePage.ts | 78 +++++++++ .../explore/cross-referenced-dashboards.spec.ts | 191 +++++++++++++++++++++ .../tests/explore/explore-no-results.spec.ts | 87 ++++++++++ .../playwright/tests/explore/explore-save.spec.ts | 179 +++++++++++++++++++ 11 files changed, 707 insertions(+), 374 deletions(-) diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js b/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js deleted file mode 100644 index 16cac29a07d..00000000000 --- a/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js +++ /dev/null @@ -1,157 +0,0 @@ -/** - * 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 { CHART_LIST } from 'cypress/utils/urls'; -import { FORM_DATA_DEFAULTS, NUM_METRIC } from './visualizations/shared.helper'; -import { - interceptDashboardGet, - interceptFiltering, - interceptV1ChartData, - saveChartToDashboard, - visitSampleChartFromList, -} from './utils'; - -// SEARCH_THRESHOLD is 10. We need to add at least 11 dashboards to show search -const SAMPLE_DASHBOARDS_INDEXES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; - -function openDashboardsAddedTo() { - cy.getBySel('actions-trigger').should('be.visible').click(); - cy.get('.ant-dropdown-menu-submenu-title') - .contains('On dashboards') - .trigger('mouseover', { force: true }); -} - -function closeDashboardsAddedTo() { - cy.get('.ant-dropdown-menu-submenu-title') - .contains('On dashboards') - .trigger('mouseout', { force: true }); - cy.getBySel('actions-trigger').click(); -} - -function verifyDashboardsSubmenuItem(dashboardName) { - cy.get('.ant-dropdown-menu-submenu-popup').contains(dashboardName); - closeDashboardsAddedTo(); -} - -function verifyDashboardSearch() { - openDashboardsAddedTo(); - cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover'); - cy.get('.ant-dropdown-menu-submenu-popup') - .find('input[placeholder="Search"]') - .type('1'); - cy.get('.ant-dropdown-menu-submenu-popup').contains('1 - Sample dashboard'); - cy.get('.ant-dropdown-menu-submenu-popup') - .find('input[placeholder="Search"]') - .type('Blahblah'); - cy.get('.ant-dropdown-menu-submenu-popup').contains('No results found'); - cy.get('.ant-dropdown-menu-submenu-popup') - .find('[aria-label="close-circle"]') - .click(); - closeDashboardsAddedTo(); -} - -function verifyDashboardLink() { - interceptDashboardGet(); - openDashboardsAddedTo(); - cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover', { - force: true, - }); - cy.get('.ant-dropdown-menu-submenu-popup a') - .first() - .invoke('removeAttr', 'target') - .click({ force: true }); - cy.wait('@get'); -} - -function verifyMetabar(text) { - cy.getBySel('metadata-bar').contains(text); -} - -function saveAndVerifyDashboard(chartName, number) { - saveChartToDashboard(chartName, `${number} - Sample dashboard`); - verifyMetabar( - number > 1 ? `Added to ${number} dashboards` : 'Added to 1 dashboard', - ); - openDashboardsAddedTo(); - verifyDashboardsSubmenuItem(`${number} - Sample dashboard`); -} - -describe('Cross-referenced dashboards', () => { - beforeEach(() => { - interceptFiltering(); - - cy.createSampleDashboards(SAMPLE_DASHBOARDS_INDEXES); - cy.createSampleCharts([0]); - cy.visit(CHART_LIST); - cy.wait('@filtering'); - }); - - it('should show the cross-referenced dashboards', () => { - visitSampleChartFromList('1 - Sample chart'); - - cy.getBySel('metadata-bar').contains('Not added to any dashboard'); - openDashboardsAddedTo(); - verifyDashboardsSubmenuItem('None'); - - saveAndVerifyDashboard('1 - Sample chart', '1'); - saveAndVerifyDashboard('1 - Sample chart', '2'); - saveAndVerifyDashboard('1 - Sample chart', '3'); - saveAndVerifyDashboard('1 - Sample chart', '4'); - saveAndVerifyDashboard('1 - Sample chart', '5'); - saveAndVerifyDashboard('1 - Sample chart', '6'); - saveAndVerifyDashboard('1 - Sample chart', '7'); - saveAndVerifyDashboard('1 - Sample chart', '8'); - saveAndVerifyDashboard('1 - Sample chart', '9'); - saveAndVerifyDashboard('1 - Sample chart', '10'); - saveAndVerifyDashboard('1 - Sample chart', '11'); - - verifyDashboardSearch(); - verifyDashboardLink(); - }); -}); - -// Skip: Uses hardcoded datasource ID that may not exist after example loading changes -describe.skip('No Results', () => { - beforeEach(() => { - interceptV1ChartData(); - }); - - it('No results message shows up', () => { - const formData = { - ...FORM_DATA_DEFAULTS, - metrics: [NUM_METRIC], - viz_type: 'echarts_timeseries_line', - adhoc_filters: [ - { - expressionType: 'SIMPLE', - subject: 'state', - operator: 'IN', - comparator: ['Fake State'], - clause: 'WHERE', - sqlExpression: null, - }, - ], - }; - - cy.visitChartByParams(formData); - cy.wait('@v1Data').its('response.statusCode').should('eq', 200); - cy.get('div.chart-container').contains( - 'No results were returned for this query', - ); - }); -}); diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/utils.ts b/superset-frontend/cypress-base/cypress/e2e/explore/utils.ts deleted file mode 100644 index 36884392c02..00000000000 --- a/superset-frontend/cypress-base/cypress/e2e/explore/utils.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * 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. - */ - -export function interceptDashboardGet() { - cy.intercept('GET', '**/api/v1/dashboard/*').as('get'); -} - -export function interceptFiltering() { - cy.intercept('GET', `**/api/v1/chart/?q=*`).as('filtering'); -} - -export function interceptUpdate() { - cy.intercept('PUT', `**/api/v1/chart/*`).as('update'); -} - -export const interceptV1ChartData = (alias = 'v1Data') => { - cy.intercept('**/api/v1/chart/data*').as(alias); -}; - -function interceptExploreGet() { - cy.intercept({ - method: 'GET', - url: /.*\/api\/v1\/explore\/\?(form_data_key|dashboard_page_id|slice_id)=.*/, - }).as('getExplore'); -} - -export function saveChartToDashboard(chartName: string, dashboardName: string) { - interceptDashboardGet(); - interceptUpdate(); - interceptExploreGet(); - - cy.getBySel('query-save-button') - .should('be.enabled') - .should('not.be.disabled') - .click({ force: true }); - - cy.getBySel('save-modal-body') - .should('be.visible') - .then($modal => { - cy.wait(500); - cy.wrap($modal) - .find('.ant-select-input[aria-label*="Select a dashboard"]') - .type(dashboardName, { force: true }); - - cy.get(`.ant-select-item-option[title="${dashboardName}"]`).click(); - - cy.getBySel('btn-modal-save').click(); - cy.wait('@update'); - }); - cy.getBySel('save-modal-body').should('not.exist'); - cy.getBySel('query-save-button').should('be.disabled'); - cy.wait('@get'); - cy.wait('@getExplore'); - cy.contains(`was added to dashboard [${dashboardName}]`); - cy.contains(`Chart [${chartName}] has been overwritten`); - cy.getBySel('query-save-button').should('be.enabled'); -} - -export function visitSampleChartFromList(chartName: string) { - cy.getBySel('table-row').contains(chartName).click(); - cy.intercept('POST', '**/superset/explore_json/**').as('getJson'); -} diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/shared.helper.js b/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/shared.helper.js deleted file mode 100644 index 12126e6a7b1..00000000000 --- a/superset-frontend/cypress-base/cypress/e2e/explore/visualizations/shared.helper.js +++ /dev/null @@ -1,139 +0,0 @@ -/** - * 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. - */ -// *********************************************** -// Constants for visualization tests -// *********************************************** - -/** - * Look up a dataset ID by table name - * @param {string} tableName - The name of the table to look up - * @returns {Cypress.Chainable<number>} - The dataset ID - */ -export function getDatasetId(tableName) { - return cy - .request({ - method: 'GET', - url: `/api/v1/dataset/?q=${encodeURIComponent( - JSON.stringify({ - filters: [{ col: 'table_name', opr: 'eq', value: tableName }], - }), - )}`, - }) - .then(response => { - const datasets = response.body.result; - if (datasets && datasets.length > 0) { - return datasets[0].id; - } - throw new Error(`Dataset with table name "${tableName}" not found`); - }); -} - -export const FORM_DATA_DEFAULTS = { - datasource: '3__table', - time_grain_sqla: null, - x_axis: 'ds', - adhoc_filters: [ - { - clause: 'WHERE', - subject: 'ds', - operator: 'TEMPORAL_RANGE', - comparator: '100 years ago : now', - expressionType: 'SIMPLE', - }, - ], - groupby: [], - limit: null, - timeseries_limit_metric: null, - order_desc: false, - contributionMode: null, -}; - -export const HEALTH_POP_FORM_DATA_DEFAULTS = { - datasource: '2__table', - granularity_sqla: 'ds', - time_grain_sqla: 'P1D', - time_range: '1960-01-01 : 2014-01-02', -}; - -export const NUM_METRIC = { - expressionType: 'SIMPLE', - column: { - id: 336, - column_name: 'num', - verbose_name: null, - description: null, - expression: '', - filterable: false, - groupby: false, - is_dttm: false, - type: 'BIGINT', - database_expression: null, - python_date_format: null, - optionName: '_col_num', - }, - aggregate: 'SUM', - sqlExpression: null, - hasCustomLabel: false, - label: 'Sum(num)', - optionName: 'metric_1de0s4viy5d_ly7y8k6ghvk', -}; - -export const MAX_DS = { - aggregate: 'MAX', - column: { - column_name: 'ds', - description: null, - expression: null, - filterable: true, - groupby: true, - id: 333, - is_dttm: true, - optionName: '_col_ds', - python_date_format: null, - type: 'TIMESTAMP WITHOUT TIME ZONE', - verbose_name: null, - }, - expressionType: 'SIMPLE', - hasCustomLabel: false, - isNew: false, - label: 'MAX(ds)', - optionName: 'metric_pbib7j9m15a_js80vs9vca', - sqlExpression: null, -}; - -export const MAX_STATE = { - expressionType: 'SQL', - sqlExpression: 'MAX(UPPER(state))', - column: null, - aggregate: null, - isNew: false, - hasCustomLabel: false, - label: 'MAX(UPPER(state))', - optionName: 'metric_kvval50pvbo_hewj3pzacb', -}; - -export const SIMPLE_FILTER = { - expressionType: 'SIMPLE', - subject: 'name', - operator: 'IN', - comparator: ['Aaron', 'Amy', 'Andrea'], - clause: 'WHERE', - sqlExpression: null, - filterOptionName: 'filter_4y6teao56zs_ebjsvwy48c', -}; diff --git a/superset-frontend/playwright/components/modals/SaveChartModal.ts b/superset-frontend/playwright/components/modals/SaveChartModal.ts new file mode 100644 index 00000000000..5bd6582291a --- /dev/null +++ b/superset-frontend/playwright/components/modals/SaveChartModal.ts @@ -0,0 +1,90 @@ +/** + * 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 { Page } from '@playwright/test'; +import { Modal, Select } from '../core'; + +/** + * Save chart modal in Explore. + * Opened by clicking the "Save" button (`query-save-button`) in the chart header. + * Supports overwriting the current chart, saving as a new chart, and adding the + * chart to an existing or brand-new dashboard. + */ +export class SaveChartModal extends Modal { + private static readonly SELECTORS = { + OVERWRITE_RADIO: '[data-test="save-overwrite-radio"]', + SAVEAS_RADIO: '[data-test="saveas-radio"]', + NAME_INPUT: '[data-test="new-chart-name"]', + // The AsyncSelect forwards `data-test={ariaLabel || name}`; SaveModal passes + // ariaLabel={t('Select a dashboard')}, and this stays stable regardless of + // whether a dashboard is currently selected. + DASHBOARD_SELECT: '[data-test="Select a dashboard"]', + } as const; + + constructor(page: Page) { + // The modal itself carries no data-test; scope by its body's data-test instead. + super(page, '[role="dialog"]:has([data-test="save-modal-body"])'); + } + + /** + * Selects the "Save (Overwrite)" radio option. + */ + async selectOverwrite(): Promise<void> { + await this.body.locator(SaveChartModal.SELECTORS.OVERWRITE_RADIO).click(); + } + + /** + * Selects the "Save as..." radio option. + */ + async selectSaveAsNew(): Promise<void> { + await this.body.locator(SaveChartModal.SELECTORS.SAVEAS_RADIO).click(); + } + + /** + * Fills the chart name field (only meaningful in "Save as..." mode). + */ + async fillChartName(name: string): Promise<void> { + const input = this.body.locator(SaveChartModal.SELECTORS.NAME_INPUT); + await input.fill(''); + await input.fill(name); + } + + private get dashboardSelect(): Select { + return new Select( + this.page, + this.body.locator(SaveChartModal.SELECTORS.DASHBOARD_SELECT), + ); + } + + /** + * Adds the chart to a dashboard. When `dashboardTitle` matches an existing + * dashboard it is selected; otherwise the select's `allowNewOptions` + * affordance creates a new dashboard with that title. + */ + async selectDashboard(dashboardTitle: string): Promise<void> { + await this.dashboardSelect.selectOption(dashboardTitle); + } + + /** + * Clicks the primary "Save" footer button (`btn-modal-save`). + */ + async clickSave(): Promise<void> { + await this.clickFooterButton('Save'); + } +} diff --git a/superset-frontend/playwright/components/modals/index.ts b/superset-frontend/playwright/components/modals/index.ts index 09670d711e2..3d5a2a2523b 100644 --- a/superset-frontend/playwright/components/modals/index.ts +++ b/superset-frontend/playwright/components/modals/index.ts @@ -26,5 +26,6 @@ export { DuplicateDatasetModal } from './DuplicateDatasetModal'; export { EditDatasetModal } from './EditDatasetModal'; export { ImportDatasetModal } from './ImportDatasetModal'; export { NativeFiltersConfigModal } from './NativeFiltersConfigModal'; +export { SaveChartModal } from './SaveChartModal'; export { SaveDatasetModal } from './SaveDatasetModal'; export { SaveQueryModal } from './SaveQueryModal'; diff --git a/superset-frontend/playwright/helpers/api/chart.ts b/superset-frontend/playwright/helpers/api/chart.ts index 090c8ad7586..aa3c9f5c353 100644 --- a/superset-frontend/playwright/helpers/api/chart.ts +++ b/superset-frontend/playwright/helpers/api/chart.ts @@ -18,6 +18,7 @@ */ import { Page, APIResponse } from '@playwright/test'; +import rison from 'rison'; import { apiGet, apiPost, @@ -102,3 +103,46 @@ export async function apiPutChart( ): Promise<APIResponse> { return apiPut(page, `${ENDPOINTS.CHART}${chartId}`, data, options); } + +/** + * TypeScript interface for a chart search result. + */ +export interface ChartResult { + id: number; + slice_name: string; +} + +/** + * Result of {@link getChartsByName}: the total count and matching rows, + * mirroring the API's own `{count, result}` envelope for exact-count + * assertions (e.g. "exactly one chart named X exists"). + */ +export interface ChartsByNameResult { + count: number; + result: ChartResult[]; +} + +/** + * Get every chart with an exact `slice_name` match. + * @param page - Playwright page instance (provides authentication context) + * @param name - The slice_name to search for + * @returns The matching charts and their total count + */ +export async function getChartsByName( + page: Page, + name: string, +): Promise<ChartsByNameResult> { + const queryParam = rison.encode({ + filters: [{ col: 'slice_name', opr: 'eq', value: name }], + }); + const response = await apiGet(page, `${ENDPOINTS.CHART}?q=${queryParam}`, { + failOnStatusCode: false, + }); + + if (!response.ok()) { + return { count: 0, result: [] }; + } + + const body = await response.json(); + return { count: body.count ?? 0, result: body.result ?? [] }; +} diff --git a/superset-frontend/playwright/helpers/api/dashboard.ts b/superset-frontend/playwright/helpers/api/dashboard.ts index 77028805cd4..d696518d30e 100644 --- a/superset-frontend/playwright/helpers/api/dashboard.ts +++ b/superset-frontend/playwright/helpers/api/dashboard.ts @@ -291,3 +291,40 @@ export async function getDashboardBySlug( ): Promise<DashboardResult | null> { return getDashboardByFilter(page, 'slug', slug); } + +/** + * Result of {@link getDashboardsByName}: the total count and matching rows, + * mirroring the API's own `{count, result}` envelope for exact-count + * assertions (e.g. "exactly one dashboard titled X exists"). + */ +export interface DashboardsByNameResult { + count: number; + result: DashboardResult[]; +} + +/** + * Get every dashboard with an exact `dashboard_title` match. + * @param page - Playwright page instance (provides authentication context) + * @param title - The dashboard_title to search for + * @returns The matching dashboards and their total count + */ +export async function getDashboardsByName( + page: Page, + title: string, +): Promise<DashboardsByNameResult> { + const queryParam = rison.encode({ + filters: [{ col: 'dashboard_title', opr: 'eq', value: title }], + }); + const response = await apiGet( + page, + `${ENDPOINTS.DASHBOARD}?q=${queryParam}`, + { failOnStatusCode: false }, + ); + + if (!response.ok()) { + return { count: 0, result: [] }; + } + + const body = await response.json(); + return { count: body.count ?? 0, result: body.result ?? [] }; +} diff --git a/superset-frontend/playwright/pages/ExplorePage.ts b/superset-frontend/playwright/pages/ExplorePage.ts index 39f6f5df0ae..6eab2e9afea 100644 --- a/superset-frontend/playwright/pages/ExplorePage.ts +++ b/superset-frontend/playwright/pages/ExplorePage.ts @@ -20,6 +20,8 @@ import { Page, Locator } from '@playwright/test'; import { TIMEOUT } from '../utils/constants'; import { AgGrid } from '../components/core/AgGrid'; +import { Menu } from '../components/core'; +import { SaveChartModal } from '../components/modals'; /** * Explore Page object @@ -36,6 +38,12 @@ export class ExplorePage { EXPAND_DATA_PANEL: '[aria-label="Expand data panel"]', RESULTS_TAB: '[data-node-key="results"]', ACTIVE_TABPANE: '.ant-tabs-content-active', + SAVE_BUTTON: '[data-test="query-save-button"]', + METADATA_BAR: '[data-test="metadata-bar"]', + // Ant Design's Dropdown popupRender wraps its content in an OverrideProvider + // that renames the Menu's CSS prefix to "ant-dropdown-menu", so the chart + // actions menu (which carries no data-test of its own) is reliably found here. + ACTIONS_MENU_ROOT: '.ant-dropdown-menu-root', } as const; constructor(page: Page) { @@ -152,4 +160,74 @@ export class ExplorePage { .first(); return new AgGrid(this.page, grid); } + + /** + * Gets the chart header's Save button locator. + */ + getSaveButton(): Locator { + return this.page.locator(ExplorePage.SELECTORS.SAVE_BUTTON); + } + + /** + * Clicks the Save button and returns a ready-to-use SaveChartModal. + */ + async openSaveModal(): Promise<SaveChartModal> { + await this.getSaveButton().click(); + const modal = new SaveChartModal(this.page); + await modal.waitForReady(); + return modal; + } + + /** + * Gets the chart metadata bar locator (dashboard membership, last modified, etc.). + */ + getMetadataBar(): Locator { + return this.page.locator(ExplorePage.SELECTORS.METADATA_BAR); + } + + /** + * Locator for the metadata bar's dashboard-membership title text, e.g. + * "Not added to any dashboard" or "Added to 3 dashboards". Assert with + * `toHaveText` so the check retries until the post-save refresh lands. + */ + getDashboardsMetadataText(): Locator { + return this.getMetadataBar() + .locator('.metadata-text') + .filter({ hasText: /dashboard/i }) + .first(); + } + + /** + * Opens the chart's "..." actions menu (the `Menu actions trigger` button) + * and waits for its popup to render. + */ + async openActionsMenu(): Promise<void> { + await this.page + .getByRole('button', { name: 'Menu actions trigger' }) + .click(); + await this.page + .locator(ExplorePage.SELECTORS.ACTIONS_MENU_ROOT) + .first() + .waitFor({ state: 'visible', timeout: TIMEOUT.FORM_LOAD }); + } + + /** + * Closes the chart's actions menu (and any open submenu) via Escape. + */ + async closeActionsMenu(): Promise<void> { + await this.page.keyboard.press('Escape'); + } + + /** + * Opens the chart actions menu and its "On dashboards" submenu, returning + * the submenu's popup locator so callers can inspect the listed dashboards. + */ + async openDashboardsSubmenu(): Promise<Locator> { + await this.openActionsMenu(); + const menu = new Menu( + this.page, + this.page.locator(ExplorePage.SELECTORS.ACTIONS_MENU_ROOT).first(), + ); + return menu.openSubmenu('On dashboards'); + } } diff --git a/superset-frontend/playwright/tests/explore/cross-referenced-dashboards.spec.ts b/superset-frontend/playwright/tests/explore/cross-referenced-dashboards.spec.ts new file mode 100644 index 00000000000..1f54c18ad79 --- /dev/null +++ b/superset-frontend/playwright/tests/explore/cross-referenced-dashboards.spec.ts @@ -0,0 +1,191 @@ +/** + * 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. + */ + +/** + * Migrated from the Cypress "Cross-referenced dashboards" suite in + * explore/chart.test.js. The original did 11 sequential UI saves; this + * version UI-saves to the first two dashboards (covering the singular vs. + * plural metadata text and the submenu listing), then attaches the rest via + * the API to reach the SEARCH_THRESHOLD (10) search-input behavior. + */ +import { testWithAssets, expect } from '../../helpers/fixtures'; +import { apiPostChart, apiPutChart } from '../../helpers/api/chart'; +import { createTestDashboard } from '../dashboard/dashboard-test-helpers'; +import { getDatasetByName } from '../../helpers/api/dataset'; +import { extractIdFromResponse } from '../../helpers/api/assertions'; +import { waitForPut } from '../../helpers/api/intercepts'; +import { ExplorePage } from '../../pages/ExplorePage'; +import { TIMEOUT } from '../../utils/constants'; + +const DATASET_NAME = 'birth_names'; +const TOTAL_DASHBOARDS = 11; // > SEARCH_THRESHOLD (10) to exercise the search input + +testWithAssets( + 'chart metadata bar and "On dashboards" submenu reflect dashboard membership', + async ({ page, testAssets }, testInfo) => { + testWithAssets.setTimeout(TIMEOUT.SLOW_TEST); + + const dataset = await getDatasetByName(page, DATASET_NAME); + if (!dataset) throw new Error(`Dataset ${DATASET_NAME} not found`); + + const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`; + const chartName = `xref_chart_${uniqueSuffix}`; + + const chartResp = await apiPostChart(page, { + slice_name: chartName, + viz_type: 'table', + datasource_id: dataset.id, + datasource_type: 'table', + params: JSON.stringify({ + datasource: `${dataset.id}__table`, + viz_type: 'table', + query_mode: 'raw', + all_columns: ['name', 'gender', 'num'], + adhoc_filters: [], + order_by_cols: [], + row_limit: 1000, + server_pagination: false, + }), + }); + expect(chartResp.ok()).toBe(true); + const chartId = await extractIdFromResponse(chartResp); + testAssets.trackChart(chartId); + + const explorePage = new ExplorePage(page); + await explorePage.goto(chartId); + + // Initial state: not on any dashboard. + await expect(explorePage.getDashboardsMetadataText()).toHaveText( + 'Not added to any dashboard', + ); + const submenuEmpty = await explorePage.openDashboardsSubmenu(); + await expect(submenuEmpty.getByText('None', { exact: true })).toBeVisible(); + await explorePage.closeActionsMenu(); + + // Create dashboards 1 and 2 up front so the UI-save select can find them. + const dashboard1 = await createTestDashboard(page, testAssets, testInfo, { + prefix: `xref_dash_${uniqueSuffix}_1`, + }); + const dashboard2 = await createTestDashboard(page, testAssets, testInfo, { + prefix: `xref_dash_${uniqueSuffix}_2`, + }); + + // UI-save to dashboard 1: verifies singular metadata text. + const saveModal1 = await explorePage.openSaveModal(); + await saveModal1.selectOverwrite(); + await saveModal1.selectDashboard(dashboard1.name); + const updated1 = waitForPut(page, `api/v1/chart/${chartId}`, { + pathMatch: true, + }); + await saveModal1.clickSave(); + expect((await updated1).ok()).toBe(true); + await explorePage.waitForPageLoad(); + + await expect(explorePage.getDashboardsMetadataText()).toHaveText( + 'Added to 1 dashboard', + ); + + // UI-save to dashboard 2: verifies plural metadata text and that both + // dashboards are listed in the submenu. + const saveModal2 = await explorePage.openSaveModal(); + await saveModal2.selectOverwrite(); + await saveModal2.selectDashboard(dashboard2.name); + const updated2 = waitForPut(page, `api/v1/chart/${chartId}`, { + pathMatch: true, + }); + await saveModal2.clickSave(); + expect((await updated2).ok()).toBe(true); + await explorePage.waitForPageLoad(); + + await expect(explorePage.getDashboardsMetadataText()).toHaveText( + 'Added to 2 dashboards', + ); + + const submenuTwo = await explorePage.openDashboardsSubmenu(); + await expect( + submenuTwo.getByText(dashboard1.name, { exact: true }), + ).toBeVisible(); + await expect( + submenuTwo.getByText(dashboard2.name, { exact: true }), + ).toBeVisible(); + await explorePage.closeActionsMenu(); + + // Attach the remaining dashboards via the API (PUT replaces the full + // dashboard list, so dashboard1/dashboard2 must be included again). + const remainingCount = TOTAL_DASHBOARDS - 2; + const remainingDashboards = []; + for (let i = 0; i < remainingCount; i += 1) { + // eslint-disable-next-line no-await-in-loop + const dash = await createTestDashboard(page, testAssets, testInfo, { + prefix: `xref_dash_${uniqueSuffix}_${i + 3}`, + }); + remainingDashboards.push(dash); + } + const allDashboardIds = [ + dashboard1.id, + dashboard2.id, + ...remainingDashboards.map(d => d.id), + ]; + const putResp = await apiPutChart(page, chartId, { + dashboards: allDashboardIds, + }); + expect(putResp.ok()).toBe(true); + + // Reload explore to pick up the new metadata via GET /api/v1/explore/. + await page.reload(); + await explorePage.waitForPageLoad(); + + await expect(explorePage.getDashboardsMetadataText()).toHaveText( + `Added to ${TOTAL_DASHBOARDS} dashboards`, + ); + + // Search: a matching term narrows the list to the matching dashboard(s). + const submenuFull = await explorePage.openDashboardsSubmenu(); + const searchInput = submenuFull.locator('input[placeholder="Search"]'); + await searchInput.fill(dashboard1.name, { force: true }); + await expect( + submenuFull.getByText(dashboard1.name, { exact: true }), + ).toBeVisible(); + await expect( + submenuFull.getByText(dashboard2.name, { exact: true }), + ).not.toBeVisible(); + + // Gibberish search yields "No results found". + await searchInput.fill('zzz_no_such_dashboard_zzz', { force: true }); + await expect( + submenuFull.getByText('No results found', { exact: true }), + ).toBeVisible(); + + // Clearing the search resets the full list. + const clearIcon = submenuFull.locator('.ant-input-clear-icon'); + await clearIcon.click({ force: true }); + await expect( + submenuFull.getByText(dashboard1.name, { exact: true }), + ).toBeVisible(); + + // The first dashboard's link opens it (in a new tab) at the right URL. + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + submenuFull.getByText(dashboard1.name, { exact: true }).click(), + ]); + await popup.waitForLoadState(); + expect(popup.url()).toContain(`/dashboard/${dashboard1.id}`); + await popup.close(); + }, +); diff --git a/superset-frontend/playwright/tests/explore/explore-no-results.spec.ts b/superset-frontend/playwright/tests/explore/explore-no-results.spec.ts new file mode 100644 index 00000000000..fb8572cdaaf --- /dev/null +++ b/superset-frontend/playwright/tests/explore/explore-no-results.spec.ts @@ -0,0 +1,87 @@ +/** + * 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. + */ + +/** + * Migrated from the Cypress `describe.skip('No Results', ...)` block in + * explore/chart.test.js, which was skipped due to a hardcoded datasource id + * (`3__table`). This version resolves the dataset by name instead. + */ +import { testWithAssets, expect } from '../../helpers/fixtures'; +import { apiPostChart } from '../../helpers/api/chart'; +import { getDatasetByName } from '../../helpers/api/dataset'; +import { extractIdFromResponse } from '../../helpers/api/assertions'; +import { ExplorePage } from '../../pages/ExplorePage'; +import { TIMEOUT } from '../../utils/constants'; + +const DATASET_NAME = 'birth_names'; + +testWithAssets( + 'chart with a filter matching no rows shows "No results"', + async ({ page, testAssets }, testInfo) => { + testWithAssets.setTimeout(TIMEOUT.SLOW_TEST); + + const dataset = await getDatasetByName(page, DATASET_NAME); + if (!dataset) throw new Error(`Dataset ${DATASET_NAME} not found`); + + const params = { + datasource: `${dataset.id}__table`, + viz_type: 'table', + query_mode: 'raw', + all_columns: ['name', 'gender', 'num'], + adhoc_filters: [ + { + expressionType: 'SIMPLE', + subject: 'state', + operator: 'IN', + comparator: ['Fake State'], + clause: 'WHERE', + }, + ], + order_by_cols: [], + row_limit: 1000, + server_pagination: false, + }; + + const resp = await apiPostChart(page, { + slice_name: `explore_no_results_${Date.now()}_${testInfo.parallelIndex}`, + viz_type: 'table', + datasource_id: dataset.id, + datasource_type: 'table', + params: JSON.stringify(params), + }); + expect(resp.ok()).toBe(true); + const chartId = await extractIdFromResponse(resp); + testAssets.trackChart(chartId); + + const explorePage = new ExplorePage(page); + const chartQueryFinished = page.waitForResponse( + response => + response.request().method() === 'POST' && + response.url().includes('/api/v1/chart/data'), + { timeout: TIMEOUT.API_RESPONSE }, + ); + await explorePage.goto(chartId); + await chartQueryFinished; + + await expect(explorePage.getChartContainer()).toContainText( + 'No results were returned for this query', + { timeout: TIMEOUT.CHART_RENDER }, + ); + }, +); diff --git a/superset-frontend/playwright/tests/explore/explore-save.spec.ts b/superset-frontend/playwright/tests/explore/explore-save.spec.ts new file mode 100644 index 00000000000..9994c9df189 --- /dev/null +++ b/superset-frontend/playwright/tests/explore/explore-save.spec.ts @@ -0,0 +1,179 @@ +/** + * 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. + */ + +/** + * Migrated from the deleted Cypress `_skip.link.test.ts` (save-as/overwrite + * coverage only; the view-query and iframe cases moved to RTL elsewhere). + */ +import { testWithAssets, expect } from '../../helpers/fixtures'; +import { + apiPostChart, + apiGetChart, + getChartsByName, +} from '../../helpers/api/chart'; +import { getDashboardsByName } from '../../helpers/api/dashboard'; +import { getDatasetByName } from '../../helpers/api/dataset'; +import { waitForPost, waitForPut } from '../../helpers/api/intercepts'; +import { extractIdFromResponse } from '../../helpers/api/assertions'; +import { ExplorePage } from '../../pages/ExplorePage'; +import { TIMEOUT } from '../../utils/constants'; + +const DATASET_NAME = 'birth_names'; + +async function createBaseChart( + page: import('@playwright/test').Page, + name: string, + datasetId: number, +): Promise<number> { + const params = { + datasource: `${datasetId}__table`, + viz_type: 'table', + query_mode: 'raw', + all_columns: ['name', 'gender', 'num'], + adhoc_filters: [], + order_by_cols: [], + row_limit: 1000, + server_pagination: false, + }; + const resp = await apiPostChart(page, { + slice_name: name, + viz_type: 'table', + datasource_id: datasetId, + datasource_type: 'table', + params: JSON.stringify(params), + }); + expect(resp.ok()).toBe(true); + return extractIdFromResponse(resp); +} + +testWithAssets( + 'save as a new chart, then overwrite it (exactly one chart by name)', + async ({ page, testAssets }, testInfo) => { + testWithAssets.setTimeout(TIMEOUT.SLOW_TEST); + + const dataset = await getDatasetByName(page, DATASET_NAME); + if (!dataset) throw new Error(`Dataset ${DATASET_NAME} not found`); + + const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`; + const baseChartName = `explore_save_base_${uniqueSuffix}`; + const newChartName = `explore_save_new_${uniqueSuffix}`; + + const baseChartId = await createBaseChart(page, baseChartName, dataset.id); + testAssets.trackChart(baseChartId); + + const explorePage = new ExplorePage(page); + await explorePage.goto(baseChartId); + + // Save as a brand-new chart. + const saveModal = await explorePage.openSaveModal(); + await saveModal.selectSaveAsNew(); + await saveModal.fillChartName(newChartName); + const created = waitForPost(page, 'api/v1/chart/'); + await saveModal.clickSave(); + const createdResponse = await created; + const newChartId = await extractIdFromResponse(createdResponse); + testAssets.trackChart(newChartId); + + await explorePage.waitForPageLoad(); + + // Overwrite the newly created chart. + const saveModal2 = await explorePage.openSaveModal(); + await saveModal2.selectOverwrite(); + const updated = waitForPut(page, `api/v1/chart/${newChartId}`, { + pathMatch: true, + }); + await saveModal2.clickSave(); + expect((await updated).ok()).toBe(true); + + const { count } = await getChartsByName(page, newChartName); + expect(count).toBe(1); + }, +); + +testWithAssets( + 'save as + add to new dashboard, then overwrite selecting the existing dashboard', + async ({ page, testAssets }, testInfo) => { + testWithAssets.setTimeout(TIMEOUT.SLOW_TEST); + + const dataset = await getDatasetByName(page, DATASET_NAME); + if (!dataset) throw new Error(`Dataset ${DATASET_NAME} not found`); + + const uniqueSuffix = `${Date.now()}_${testInfo.parallelIndex}`; + const baseChartName = `explore_save_dash_base_${uniqueSuffix}`; + const newChartName = `explore_save_dash_new_${uniqueSuffix}`; + const dashboardTitle = `explore_save_dash_${uniqueSuffix}`; + + const baseChartId = await createBaseChart(page, baseChartName, dataset.id); + testAssets.trackChart(baseChartId); + + const explorePage = new ExplorePage(page); + await explorePage.goto(baseChartId); + + // Save as a new chart, adding it to a brand-new dashboard (creatable select). + const saveModal = await explorePage.openSaveModal(); + await saveModal.selectSaveAsNew(); + await saveModal.fillChartName(newChartName); + await saveModal.selectDashboard(dashboardTitle); + const dashboardCreated = waitForPost(page, 'api/v1/dashboard/', { + pathMatch: true, + }); + const created = waitForPost(page, 'api/v1/chart/'); + await saveModal.clickSave(); + // Track the dashboard before the chart POST so a later failure still + // cleans it up. + const dashboardId = await extractIdFromResponse(await dashboardCreated); + testAssets.trackDashboard(dashboardId); + const createdResponse = await created; + const newChartId = await extractIdFromResponse(createdResponse); + testAssets.trackChart(newChartId); + + const dashboardsAfterCreate = await getDashboardsByName( + page, + dashboardTitle, + ); + expect(dashboardsAfterCreate.count).toBe(1); + expect(dashboardsAfterCreate.result[0].id).toBe(dashboardId); + + await explorePage.waitForPageLoad(); + + // Overwrite, selecting the now-existing dashboard by the same title. + const saveModal2 = await explorePage.openSaveModal(); + await saveModal2.selectOverwrite(); + await saveModal2.selectDashboard(dashboardTitle); + const updated = waitForPut(page, `api/v1/chart/${newChartId}`, { + pathMatch: true, + }); + await saveModal2.clickSave(); + expect((await updated).ok()).toBe(true); + + const chartsResult = await getChartsByName(page, newChartName); + expect(chartsResult.count).toBe(1); + + const dashboardsResult = await getDashboardsByName(page, dashboardTitle); + expect(dashboardsResult.count).toBe(1); + + // Confirm the chart is actually associated with the dashboard. + const chartDetail = await apiGetChart(page, newChartId); + const chartBody = await chartDetail.json(); + const dashboardIds: number[] = (chartBody.result?.dashboards ?? []).map( + (d: { id: number }) => d.id, + ); + expect(dashboardIds).toContain(dashboardsResult.result[0].id); + }, +);
