diff --git a/docs/static/img/tutorial/publish_button_dashboard.png b/docs/static/img/tutorial/publish_button_dashboard.png index c20a097dfdc..9c4f29e4055 100644 Binary files a/docs/static/img/tutorial/publish_button_dashboard.png and b/docs/static/img/tutorial/publish_button_dashboard.png differ diff --git a/docs/static/img/tutorial/tutorial_chart_resize.png b/docs/static/img/tutorial/tutorial_chart_resize.png index 50983127aec..c40b90da3c2 100644 Binary files a/docs/static/img/tutorial/tutorial_chart_resize.png and b/docs/static/img/tutorial/tutorial_chart_resize.png differ diff --git a/docs/static/img/tutorial/tutorial_edit_button.png b/docs/static/img/tutorial/tutorial_edit_button.png index 9028fa5743f..6ae85634f8f 100644 Binary files a/docs/static/img/tutorial/tutorial_edit_button.png and b/docs/static/img/tutorial/tutorial_edit_button.png differ diff --git a/docs/static/img/tutorial/tutorial_first_dashboard.png b/docs/static/img/tutorial/tutorial_first_dashboard.png index f0382290766..ce925328f24 100644 Binary files a/docs/static/img/tutorial/tutorial_first_dashboard.png and b/docs/static/img/tutorial/tutorial_first_dashboard.png differ diff --git a/docs/static/img/tutorial/tutorial_save_slice.png b/docs/static/img/tutorial/tutorial_save_slice.png index 89e26773838..91ba6f9685d 100644 Binary files a/docs/static/img/tutorial/tutorial_save_slice.png and b/docs/static/img/tutorial/tutorial_save_slice.png differ diff --git a/superset-frontend/playwright/generators/docs/docs-screenshots.spec.ts b/superset-frontend/playwright/generators/docs/docs-screenshots.spec.ts index 2cbd07d7616..d7bc20c45fe 100644 --- a/superset-frontend/playwright/generators/docs/docs-screenshots.spec.ts +++ b/superset-frontend/playwright/generators/docs/docs-screenshots.spec.ts @@ -39,6 +39,7 @@ import path from 'path'; import { Page } from '@playwright/test'; import { test, expect } from '@playwright/test'; import { URL } from '../../utils/urls'; +import { apiDelete, apiGet } from '../../helpers/api/requests'; const DOCS_STATIC = path.resolve(__dirname, '../../../../docs/static/img'); const SCREENSHOTS_DIR = path.join(DOCS_STATIC, 'screenshots'); @@ -53,6 +54,80 @@ async function settle(page: Page, ms = 1000): Promise { await page.waitForTimeout(ms); } +/** + * Navigates to the Sales Dashboard (from example data) and waits for charts + * to finish rendering. Used by several tutorial screenshots that show the + * dashboard in view or edit mode. + */ +async function openSalesDashboard(page: Page): Promise { + await page.goto(URL.DASHBOARD_LIST); + const searchInput = page.getByPlaceholder('Type a value'); + await expect(searchInput).toBeVisible({ timeout: 15000 }); + await searchInput.fill('Sales Dashboard'); + await searchInput.press('Enter'); + + const dashboardLink = page.getByRole('link', { name: /sales dashboard/i }); + await expect(dashboardLink).toBeVisible({ timeout: 10000 }); + await dashboardLink.click(); + + const dashboardWrapper = page.locator( + '[data-test="dashboard-content-wrapper"]', + ); + await expect(dashboardWrapper).toBeVisible({ timeout: 30000 }); + await expect( + page.locator('.dashboard-component-chart-holder').first(), + ).toBeVisible({ timeout: 15000 }); + await expect( + dashboardWrapper.locator('[data-test="loading-indicator"]'), + ).toHaveCount(0, { timeout: 30000 }); + await expect( + page.locator('.dashboard-component-chart-holder canvas').first(), + ).toBeVisible({ timeout: 15000 }); +} + +/** + * Delete all dashboards matching the given exact title, along with the + * charts attached to them. Used by the save-flow test to clean up after + * itself and to recover from prior failed runs (idempotent pre-cleanup). + * + * Only safe because the title is unique to the test ("Superset Duper + * Sales Dashboard"); don't reuse this against titles that could match + * example-data dashboards. + */ +async function deleteDashboardByTitle( + page: Page, + title: string, +): Promise { + const filter = `(filters:!((col:dashboard_title,opr:eq,value:'${title}')))`; + const resp = await apiGet(page, 'api/v1/dashboard/', { + params: { q: filter }, + failOnStatusCode: false, + }); + if (!resp.ok()) return; + const body = await resp.json(); + const dashboards: { id: number }[] = body.result || []; + + for (const dash of dashboards) { + const chartsResp = await apiGet( + page, + `api/v1/dashboard/${dash.id}/charts`, + { failOnStatusCode: false }, + ); + const chartIds: number[] = chartsResp.ok() + ? ((await chartsResp.json()).result || []) + .map((c: { id?: number }) => c.id) + .filter((id: unknown): id is number => typeof id === 'number') + : []; + + await apiDelete(page, `api/v1/dashboard/${dash.id}`, { + failOnStatusCode: false, + }); + for (const id of chartIds) { + await apiDelete(page, `api/v1/chart/${id}`, { failOnStatusCode: false }); + } + } +} + test('chart gallery screenshot', async ({ page }) => { await page.goto(URL.CHART_ADD); @@ -77,36 +152,7 @@ test('chart gallery screenshot', async ({ page }) => { }); test('dashboard screenshot', async ({ page }) => { - // Navigate to Sales Dashboard via the dashboard list (slug is null) - await page.goto(URL.DASHBOARD_LIST); - const searchInput = page.getByPlaceholder('Type a value'); - await expect(searchInput).toBeVisible({ timeout: 15000 }); - await searchInput.fill('Sales Dashboard'); - await searchInput.press('Enter'); - - // Click the Sales Dashboard link - const dashboardLink = page.getByRole('link', { name: /sales dashboard/i }); - await expect(dashboardLink).toBeVisible({ timeout: 10000 }); - await dashboardLink.click(); - - // Wait for dashboard to fully render - const dashboardWrapper = page.locator( - '[data-test="dashboard-content-wrapper"]', - ); - await expect(dashboardWrapper).toBeVisible({ timeout: 30000 }); - - // Wait for chart holders to appear, then wait for all loading spinners to clear - await expect( - page.locator('.dashboard-component-chart-holder').first(), - ).toBeVisible({ timeout: 15000 }); - await expect( - dashboardWrapper.locator('[data-test="loading-indicator"]'), - ).toHaveCount(0, { timeout: 30000 }); - - // Wait for at least one chart to finish rendering (ECharts renders to canvas) - await expect( - page.locator('.dashboard-component-chart-holder canvas').first(), - ).toBeVisible({ timeout: 15000 }); + await openSalesDashboard(page); // Open the filter bar (collapsed by default) const expandButton = page.locator('[data-test="filter-bar__expand-button"]'); @@ -289,3 +335,273 @@ test('chart type picker screenshot', async ({ page }) => { type: 'png', }); }); + +test('publish button dashboard screenshot', async ({ page }) => { + // Toggle Sales Dashboard to Draft, hover the label so the tooltip renders, + // then capture the header area plus enough room below for the tooltip. + // Always restores the dashboard to Published at the end. + await openSalesDashboard(page); + + const publishedLabel = page.getByText('Published', { exact: true }).first(); + await expect(publishedLabel).toBeVisible({ timeout: 10000 }); + await publishedLabel.click(); + + const draftLabel = page.getByText('Draft', { exact: true }).first(); + await expect(draftLabel).toBeVisible({ timeout: 10000 }); + + try { + await draftLabel.hover(); + await expect(page.getByRole('tooltip')).toBeVisible({ timeout: 5000 }); + await settle(page, 500); + + const headerBox = await page + .locator('[data-test="dashboard-header-container"]') + .boundingBox(); + if (!headerBox) { + throw new Error('Could not locate dashboard header container'); + } + await page.screenshot({ + path: path.join(TUTORIAL_DIR, 'publish_button_dashboard.png'), + type: 'png', + clip: { + x: headerBox.x, + y: headerBox.y, + width: headerBox.width, + height: headerBox.height + 140, + }, + }); + } finally { + // Restore: click Draft to re-publish so other runs start from a clean state + await page.mouse.move(0, 0); + await draftLabel.click(); + await expect( + page.getByText('Published', { exact: true }).first(), + ).toBeVisible({ timeout: 10000 }); + } +}); + +test('edit button screenshot', async ({ page }) => { + // Capture the right-side action buttons (Edit dashboard + "..." menu) + // rather than the edit button in isolation. + await openSalesDashboard(page); + await settle(page); + + const rightPanel = page.locator('.right-button-panel'); + await expect(rightPanel).toBeVisible({ timeout: 5000 }); + await rightPanel.screenshot({ + path: path.join(TUTORIAL_DIR, 'tutorial_edit_button.png'), + type: 'png', + }); +}); + +test('chart resize screenshot', async ({ page }) => { + // Enter edit mode, start a resize drag on the right-edge handle, then + // screenshot the chart mid-drag. While `DashboardGrid` is in the resizing + // state it renders vertical `grid-column-guide` overlays across the grid + // and the chart gets a blue `--resizing` outline — that's the state the + // original tutorial screenshot was capturing. + await openSalesDashboard(page); + + const editButton = page.locator('[data-test="edit-dashboard-button"]'); + await expect(editButton).toBeVisible(); + await editButton.click(); + + await expect( + page.locator('[data-test="dashboard-builder-sidepane"]'), + ).toBeVisible({ timeout: 10000 }); + + const chart = page.locator('.dashboard-component-chart-holder').first(); + await expect(chart).toBeVisible(); + const chartBox = await chart.boundingBox(); + if (!chartBox) { + throw new Error('Could not locate chart bounding box'); + } + + // Hover over the chart so the on-hover action buttons (drag/trash/settings) + // and resize handles become visible. + await page.mouse.move( + chartBox.x + chartBox.width / 2, + chartBox.y + chartBox.height / 2, + ); + await settle(page, 200); + + // The right-edge handle is a `` added by re-resizable with our + // custom class. Locating it by class is more reliable than computing + // coordinates from the chart-holder (which isn't the full resizable box). + const rightHandle = page + .locator('.resizable-container-handle--right') + .first(); + await expect(rightHandle).toBeVisible(); + const handleBox = await rightHandle.boundingBox(); + if (!handleBox) { + throw new Error('Could not locate right-edge resize handle'); + } + const handleX = handleBox.x + handleBox.width / 2; + const handleY = handleBox.y + handleBox.height / 2; + + await page.mouse.move(handleX, handleY); + await page.mouse.down(); + // Move far enough to snap at least one grid column, which puts + // DashboardGrid into isResizing=true so the column guides render. + await page.mouse.move(handleX + 80, handleY, { steps: 10 }); + await settle(page, 500); + + // Clip to the chart area plus a left gutter for the hover action rail + // and right padding that reaches past the dragged handle position. + const leftGutter = 32; + const rightPadding = 100; + const topPadding = 16; + const bottomPadding = 24; + await page.screenshot({ + path: path.join(TUTORIAL_DIR, 'tutorial_chart_resize.png'), + type: 'png', + clip: { + x: Math.max(0, chartBox.x - leftGutter), + y: Math.max(0, chartBox.y - topPadding), + width: chartBox.width + leftGutter + rightPadding, + height: chartBox.height + topPadding + bottomPadding, + }, + }); + + // Release back at the start to avoid persisting a size change. Edit-mode + // changes aren't saved (we never click the dashboard Save button). + await page.mouse.move(handleX, handleY, { steps: 6 }); + await page.mouse.up(); +}); + +test('save flow and first dashboard screenshots', async ({ page }) => { + // Captures two linked tutorial screenshots in a single flow so the second + // faithfully shows the dashboard the user just created: + // 1. tutorial_save_slice.png — Save modal with the "Add to dashboard" + // dropdown surfacing a creatable option for a new dashboard. + // 2. tutorial_first_dashboard.png — the freshly-created dashboard with + // the single saved chart (matches the tutorial narrative). + // + // Creates and then deletes a "Superset Duper Sales Dashboard" dashboard + // plus the duplicate chart it owns. Pre-cleans in case a prior run failed. + const NEW_DASHBOARD_NAME = 'Superset Duper Sales Dashboard'; + await deleteDashboardByTitle(page, NEW_DASHBOARD_NAME); + + // 1100px is wide enough to show the full "Superset Duper Sales Dashboard" + // title alongside the header actions without truncation. + await page.setViewportSize({ width: 1100, height: 800 }); + await page.goto(URL.CHART_LIST); + + const searchInput = page.getByPlaceholder('Type a value'); + await expect(searchInput).toBeVisible({ timeout: 15000 }); + await searchInput.fill('Scatter Plot'); + await searchInput.press('Enter'); + + const chartLink = page.getByRole('link', { name: /scatter plot/i }); + await expect(chartLink).toBeVisible({ timeout: 10000 }); + await chartLink.click(); + + await page.waitForURL('**/explore/**', { timeout: 15000 }); + const sliceContainer = page.locator('[data-test="slice-container"]'); + await expect(sliceContainer).toBeVisible({ timeout: 15000 }); + await expect( + sliceContainer.locator('[data-test="loading-indicator"]'), + ).toHaveCount(0, { timeout: 15000 }); + + const saveButton = page.locator('[data-test="query-save-button"]'); + await expect(saveButton).toBeVisible({ timeout: 10000 }); + await saveButton.click(); + + const modal = page.locator('.ant-modal-content').filter({ + has: page.locator('[data-test="save-modal-body"]'), + }); + await expect(modal).toBeVisible({ timeout: 10000 }); + + // Open the "Add to dashboard" select and type a new dashboard name so + // the dropdown surfaces the creatable option. + const dashboardSelect = page.getByRole('combobox', { + name: /select a dashboard/i, + }); + await dashboardSelect.click(); + await page.keyboard.type(NEW_DASHBOARD_NAME); + + // Ant Design portals the visible dropdown with the class + // `.ant-select-item-option` on each option (distinct from the hidden + // ARIA listbox options rendered inside the combobox itself). + const createOption = page + .locator('.ant-select-item-option') + .filter({ hasText: NEW_DASHBOARD_NAME }); + await expect(createOption).toBeVisible({ timeout: 10000 }); + await settle(page); + + try { + // Screenshot 1: save modal + portaled dropdown. + const modalBox = await modal.boundingBox(); + const optionBox = await createOption.boundingBox(); + if (!modalBox || !optionBox) { + throw new Error('Could not locate save modal or create-option'); + } + const padding = 16; + const top = Math.max(0, modalBox.y - padding); + const bottom = optionBox.y + optionBox.height + padding; + await page.screenshot({ + path: path.join(TUTORIAL_DIR, 'tutorial_save_slice.png'), + type: 'png', + clip: { + x: Math.max(0, modalBox.x - padding), + y: top, + width: modalBox.width + padding * 2, + height: bottom - top, + }, + }); + + // Pick the creatable option, then click "Save & go to dashboard" so the + // backend creates the dashboard + slice and redirects us to the new one. + await createOption.click(); + const saveAndGotoBtn = page.locator('#btn_modal_save_goto_dash'); + await expect(saveAndGotoBtn).toBeEnabled({ timeout: 5000 }); + await saveAndGotoBtn.click(); + + await page.waitForURL(/\/dashboard\/[^/]+\/?/, { timeout: 30000 }); + await expect( + page.locator('[data-test="dashboard-content-wrapper"]'), + ).toBeVisible({ timeout: 30000 }); + await expect( + page.locator('.dashboard-component-chart-holder').first(), + ).toBeVisible({ timeout: 30000 }); + await expect( + page.locator('.dashboard-component-chart-holder canvas').first(), + ).toBeVisible({ timeout: 15000 }); + + // Dismiss the "Chart [X] has been saved" toast so it doesn't appear in + // the screenshot. The close button is inside the toast container. + const toast = page.locator('[data-test="toast-container"]').first(); + if (await toast.isVisible().catch(() => false)) { + await toast.locator('.toast__close').click(); + await expect(toast).toBeHidden({ timeout: 5000 }); + } + await settle(page); + + // Screenshot 2: the newly-created single-chart dashboard (title + chart). + const headerBox = await page + .locator('[data-test="dashboard-header-wrapper"]') + .boundingBox(); + const chartBox = await page + .locator('.dashboard-component-chart-holder') + .first() + .boundingBox(); + if (!headerBox || !chartBox) { + throw new Error('Could not locate dashboard header or chart'); + } + // Trim right edge to just past the chart so the screenshot isn't padded + // with empty grid space. + const rightPadding = 16; + await page.screenshot({ + path: path.join(TUTORIAL_DIR, 'tutorial_first_dashboard.png'), + type: 'png', + clip: { + x: 0, + y: headerBox.y, + width: Math.min(1100, chartBox.x + chartBox.width + rightPadding), + height: chartBox.y + chartBox.height - headerBox.y + 16, + }, + }); + } finally { + await deleteDashboardByTitle(page, NEW_DASHBOARD_NAME); + } +}); diff --git a/superset-frontend/playwright/generators/docs/screenshot-manifest.yaml b/superset-frontend/playwright/generators/docs/screenshot-manifest.yaml index 1729aaeb036..1d10dd53034 100644 --- a/superset-frontend/playwright/generators/docs/screenshot-manifest.yaml +++ b/superset-frontend/playwright/generators/docs/screenshot-manifest.yaml @@ -29,15 +29,7 @@ # database-logo, badge, external, community: inventory only, no generation. images: - # --- tutorial (42) --- - - type: tutorial - page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard" - image_url: "https://superset.apache.org/img/tutorial/publish_button_dashboard.png" - output_path: docs/static/img/tutorial/publish_button_dashboard.png - alt: "" - source_file: docs/using-superset/creating-your-first-dashboard.mdx - app_path: null - selector: "[data-test=\"dashboard-header-container\"]" + # --- tutorial (37) --- - type: tutorial page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard" image_url: "https://superset.apache.org/img/tutorial/tutorial_01_add_database_connection.png" @@ -86,14 +78,6 @@ images: source_file: docs/using-superset/creating-your-first-dashboard.mdx app_path: null selector: null - - type: tutorial - page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard" - image_url: "https://superset.apache.org/img/tutorial/tutorial_chart_resize.png" - output_path: docs/static/img/tutorial/tutorial_chart_resize.png - alt: "" - source_file: docs/using-superset/creating-your-first-dashboard.mdx - app_path: null - selector: "[data-test=\"dashboard-content-wrapper\"]" - type: tutorial page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard" image_url: "https://superset.apache.org/img/tutorial/tutorial_column_properties.png" @@ -110,14 +94,6 @@ images: source_file: docs/using-superset/creating-your-first-dashboard.mdx app_path: null selector: .ant-modal-content - - type: tutorial - page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard" - image_url: "https://superset.apache.org/img/tutorial/tutorial_edit_button.png" - output_path: docs/static/img/tutorial/tutorial_edit_button.png - alt: "" - source_file: docs/using-superset/creating-your-first-dashboard.mdx - app_path: null - selector: "[data-test=\"edit-dashboard-button\"]" - type: tutorial page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard" image_url: "https://superset.apache.org/img/tutorial/tutorial_explore_run.jpg" @@ -134,14 +110,6 @@ images: source_file: docs/using-superset/creating-your-first-dashboard.mdx app_path: null selector: null - - type: tutorial - page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard" - image_url: "https://superset.apache.org/img/tutorial/tutorial_first_dashboard.png" - output_path: docs/static/img/tutorial/tutorial_first_dashboard.png - alt: "" - source_file: docs/using-superset/creating-your-first-dashboard.mdx - app_path: null - selector: "[data-test=\"dashboard-content-wrapper\"]" - type: tutorial page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard" image_url: "https://superset.apache.org/img/tutorial/tutorial_launch_explore.png" @@ -150,14 +118,6 @@ images: source_file: docs/using-superset/creating-your-first-dashboard.mdx app_path: null selector: null - - type: tutorial - page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard" - image_url: "https://superset.apache.org/img/tutorial/tutorial_save_slice.png" - output_path: docs/static/img/tutorial/tutorial_save_slice.png - alt: "" - source_file: docs/using-superset/creating-your-first-dashboard.mdx - app_path: null - selector: .ant-modal-content - type: tutorial page_url: "https://superset.apache.org/user-docs/using-superset/creating-your-first-dashboard" image_url: "https://superset.apache.org/img/tutorial/tutorial_sql_metric.png"