You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2026/06/25 20:32:16 UTC

(superset) 01/01: test(dashboard): migrate drill-to-detail E2E from Cypress to Playwright

This is an automated email from the ASF dual-hosted git repository.

sadpandajoe pushed a commit to branch dashboard-pw-drilltodetail
in repository https://gitbox.apache.org/repos/asf/superset.git

commit deff1bd782ab9ff68f5f6443519788d7b88f80f0
Author: Joe Li <jo...@preset.io>
AuthorDate: Thu Jun 25 13:31:53 2026 -0700

    test(dashboard): migrate drill-to-detail E2E from Cypress to Playwright
    
    Port the Cypress "Drill to detail modal" suite (which was fully
    `describe.skip`'d) to the Playwright E2E framework. Each test API-builds a
    hermetic single-chart dashboard from birth_names, renders it, drives the real
    drill menus, and asserts the backend round-trip (the `/datasource/samples`
    POST and the filter the modal applies) — genuine E2E, not DOM-class checks.
    
    The original suite was skipped due to Cypress auto-scrolling the chart under
    its sticky header before each action and obstructing right-clicks. That failure
    mode is Cypress-specific; Playwright scrolls once and the target stays put.
    
    Coverage:
    - Modal mechanics: open from the chart "More Options" menu, paginate, reload
      resets to page 1.
    - Big number value right-click drills the whole chart (no filter).
    - Table cell right-click drills by that value; clearing the filter restores the
      full set.
    - Pivot table cell right-click drills by the cell value.
    - Pie slice and line point right-click (canvas) drill by the value under the
      cursor.
    
    Canvas charts previously relied on hard-coded pixel coordinates to hit a slice
    or point. Instead, the canvas tests scan a small set of candidate points until
    the context menu offers an enabled "Drill to detail by" submenu, then drill by
    whichever value the menu offers and assert that same value round-trips into the
    modal — keeping the assertion independent of exact chart geometry.
    
    Excluded (matching the original's own skips): Bar, Area, World Map, Radar.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <no...@anthropic.com>
---
 .../playwright/pages/DashboardPage.ts              | 117 ++++-
 .../dashboard/dashboard-drill-to-detail.spec.ts    | 512 +++++++++++++++++++++
 2 files changed, 628 insertions(+), 1 deletion(-)

diff --git a/superset-frontend/playwright/pages/DashboardPage.ts b/superset-frontend/playwright/pages/DashboardPage.ts
index a61ff3415c1..8bf45614002 100644
--- a/superset-frontend/playwright/pages/DashboardPage.ts
+++ b/superset-frontend/playwright/pages/DashboardPage.ts
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { Page, Download } from '@playwright/test';
+import { Page, Download, Locator } from '@playwright/test';
 import { Menu } from '../components/core';
 import { gotoWithRetry } from '../helpers/navigation';
 import { TIMEOUT } from '../utils/constants';
@@ -126,4 +126,119 @@ export class DashboardPage {
     await menu.selectSubmenuItem('Download', optionText);
     return downloadPromise;
   }
+
+  // ---------------------------------------------------------------------------
+  // Drill to detail
+  //
+  // Charts that implement the DRILL_TO_DETAIL behavior expose two entry points:
+  // the chart's "More Options" header menu, and a right-click context menu on
+  // the chart body (a cell, the big-number value, or a canvas data point). Both
+  // open the same DrillDetailModal, which renders the underlying sample rows for
+  // the (optionally filtered) chart by calling the `/datasource/samples` API.
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Locator for a chart card on the dashboard by its visualization type, e.g.
+   * `table`, `pie`, `big_number_total`.
+   */
+  chartByVizType(vizType: string): Locator {
+    return this.page.locator(`[data-test-viz-type="${vizType}"]`);
+  }
+
+  /**
+   * Open the "Drill to detail" item from a chart's "More Options" header menu.
+   * This is the whole-chart entry point (no row-level filters applied).
+   */
+  async openDrillToDetailFromMenu(vizType: string): Promise<void> {
+    await this.chartByVizType(vizType)
+      .getByLabel('More Options', { exact: true })
+      .click();
+    await this.page
+      .getByRole('menuitem', { name: 'Drill to detail', exact: true })
+      .click();
+  }
+
+  /**
+   * The DrillDetailModal dialog (titled "Drill to detail: <chart name>").
+   */
+  drillModal(): Locator {
+    return this.page.getByRole('dialog', { name: /^Drill to detail:/ });
+  }
+
+  /**
+   * Close the drill-to-detail modal if it is open (idempotent).
+   */
+  async closeDrillModal(): Promise<void> {
+    const close = this.page.locator('[data-test="close-drilltodetail-modal"]');
+    if (await close.count()) {
+      await close.first().click();
+      await this.drillModal().waitFor({ state: 'hidden' });
+    }
+  }
+
+  /**
+   * The applied-filter value tags inside the drill modal (`<col>=<val>`).
+   */
+  drillFilterValues(): Locator {
+    return this.page.locator('[data-test="filter-val"]');
+  }
+
+  /**
+   * Click the plain "Drill to detail" item in an open chart context menu
+   * (whole chart, no row-level filter).
+   */
+  async contextMenuDrillToDetail(): Promise<void> {
+    await this.page
+      .getByRole('menuitem', { name: 'Drill to detail', exact: true })
+      .click();
+  }
+
+  /**
+   * The "Drill to detail by" submenu parent (title) in an open context menu.
+   * Targeted by its submenu-title element rather than role+name because antd
+   * appends the arrow-icon name ("right") to the accessible name, and the leaf
+   * items ("Drill to detail by boy") would otherwise match a role+name lookup.
+   */
+  private drillBySubmenuTitle(): Locator {
+    return this.page.locator('.ant-dropdown-menu-submenu-title', {
+      hasText: 'Drill to detail by',
+    });
+  }
+
+  /** The "Drill to detail by" submenu popup (its leaf value items live here). */
+  private drillBySubmenu(): Locator {
+    return this.page.locator('.chart-context-submenu');
+  }
+
+  /**
+   * From an open chart context menu, hover the "Drill to detail by" submenu and
+   * click the entry for a specific value (e.g. "boy", "1965", "all").
+   */
+  async contextMenuDrillToDetailBy(value: string): Promise<void> {
+    await this.drillBySubmenuTitle().hover();
+    await this.drillBySubmenu()
+      .getByRole('menuitem', {
+        name: `Drill to detail by ${value}`,
+        exact: true,
+      })
+      .click();
+  }
+
+  /**
+   * From an open chart context menu, hover "Drill to detail by" and return the
+   * concrete values offered by the submenu (e.g. ["1965", "boy"]), skipping the
+   * aggregate "all" entry. Used by canvas charts where the value under the
+   * cursor is data-dependent: the test drills by whatever the menu actually
+   * offers and asserts that same value round-trips into the modal, which keeps
+   * the assertion independent of exact pixel/slice geometry.
+   */
+  async drillByOfferedValues(): Promise<string[]> {
+    await this.drillBySubmenuTitle().hover();
+    const items = this.drillBySubmenu().locator('[role="menuitem"]');
+    await items.first().waitFor();
+    const labels = await items.allInnerTexts();
+    return labels
+      .map(l => l.replace(/^Drill to detail by\s*/i, '').trim())
+      .filter(v => v.length > 0 && v.toLowerCase() !== 'all');
+  }
 }
diff --git a/superset-frontend/playwright/tests/dashboard/dashboard-drill-to-detail.spec.ts b/superset-frontend/playwright/tests/dashboard/dashboard-drill-to-detail.spec.ts
new file mode 100644
index 00000000000..5da39790069
--- /dev/null
+++ b/superset-frontend/playwright/tests/dashboard/dashboard-drill-to-detail.spec.ts
@@ -0,0 +1,512 @@
+/**
+ * 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.
+ */
+
+/**
+ * E2E migration of the Cypress "Drill to detail modal" suite
+ * (dashboard/drilltodetail.test.ts).
+ *
+ * Drill to detail lets a viewer open a modal of the underlying sample rows for a
+ * chart — optionally filtered to a single data point — by either the chart's
+ * "More Options" header menu or a right-click context menu on the chart body.
+ * The modal calls the real `/datasource/samples` API, so this is genuinely
+ * end-to-end: each test API-builds a hermetic dashboard from the `birth_names`
+ * dataset, renders it in the browser, drives the real menus, and asserts the
+ * resulting backend round-trip (the samples POST and the filter the modal
+ * applies).
+ *
+ * Why the original suite was fully `describe.skip`:
+ *   "it has issues with autoscrolling and the locked title flakes intricately
+ *    when the rightClick is obstructed by the title."
+ * That failure mode is Cypress-specific — Cypress auto-scrolls the target under
+ * the sticky chart header before every action. Playwright scrolls once and the
+ * target stays put, so the entry points are portable here.
+ *
+ * What is migrated, and how it is kept deterministic:
+ *   - Modal mechanics (open from header menu, pagination, reload-resets-page)
+ *     and the no-filter big-number drill use stable DOM elements.
+ *   - Table and Pivot drills right-click real DOM cells (no canvas pixels).
+ *   - Canvas charts (Pie, Line) DID rely on hard-coded pixel coordinates in
+ *     Cypress to land on a specific slice/point. Instead of reproducing those
+ *     brittle pixels, these tests right-click a stable region of the canvas,
+ *     read whichever value the drill submenu actually offers for the point under
+ *     the cursor, drill by that value, and assert the SAME value round-trips
+ *     into the modal filter. This exercises the full canvas → contextmenu →
+ *     datum → samples pipeline while staying independent of exact geometry.
+ *
+ * Excluded (kept out, matching the original's own `describe.skip`s): Bar, Area,
+ * World Map, Radar — skipped upstream for chart-specific reasons.
+ */
+import { testWithAssets, expect } from '../../helpers/fixtures';
+import type { Page } from '@playwright/test';
+import { apiPost, apiPut } from '../../helpers/api/requests';
+import { apiPostDashboard } from '../../helpers/api/dashboard';
+import { TIMEOUT } from '../../utils/constants';
+import { DashboardPage } from '../../pages/DashboardPage';
+
+const DATASET_NAME = 'birth_names';
+
+async function findDatasetIdByName(page: Page, name: string): Promise<number> {
+  const rison = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
+  const resp = await page.request.get(`api/v1/dataset/?q=${rison}`);
+  const body = await resp.json();
+  if (!body.result?.length) {
+    throw new Error(`Dataset ${name} not found`);
+  }
+  return body.result[0].id;
+}
+
+/**
+ * Parse a RowCountLabel value ("75.7k rows", "1,234 rows") into a number so
+ * tests can assert the *invariant* (filtered < unfiltered) without hard-coding
+ * the dataset-specific totals the original Cypress suite baked in.
+ */
+function parseRowCount(text: string): number {
+  const m = text.match(/([\d.,]+)\s*([kKmM]?)/);
+  if (!m) return NaN;
+  let n = parseFloat(m[1].replace(/,/g, ''));
+  const suffix = m[2].toLowerCase();
+  if (suffix === 'k') n *= 1e3;
+  if (suffix === 'm') n *= 1e6;
+  return n;
+}
+
+interface ChartSpec {
+  vizType: string;
+  sliceName: string;
+  params: Record<string, unknown>;
+}
+
+/**
+ * API-build a hermetic single-chart dashboard from birth_names and return its
+ * id. Mirrors the build pattern used by the other migrated dashboard specs
+ * (create chart → build positionJson → create dashboard → link chart).
+ */
+async function buildSingleChartDashboard(
+  page: Page,
+  testAssets: {
+    trackChart(id: number): void;
+    trackDashboard(id: number): void;
+  },
+  spec: ChartSpec,
+): Promise<number> {
+  const datasetId = await findDatasetIdByName(page, DATASET_NAME);
+  const datasource = `${datasetId}__table`;
+
+  const chartResp = await apiPost(page, 'api/v1/chart/', {
+    slice_name: `${spec.sliceName}_${Date.now()}`,
+    viz_type: spec.vizType,
+    datasource_id: datasetId,
+    datasource_type: 'table',
+    params: JSON.stringify({
+      datasource,
+      viz_type: spec.vizType,
+      ...spec.params,
+    }),
+  });
+  expect(chartResp.ok()).toBe(true);
+  const chart = await chartResp.json();
+  const chartId: number = chart.id ?? chart.result?.id;
+  testAssets.trackChart(chartId);
+
+  const chartLayoutKey = `CHART-${chartId}`;
+  const positionJson = {
+    DASHBOARD_VERSION_KEY: 'v2',
+    ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
+    GRID_ID: {
+      type: 'GRID',
+      id: 'GRID_ID',
+      children: ['ROW-1'],
+      parents: ['ROOT_ID'],
+    },
+    'ROW-1': {
+      type: 'ROW',
+      id: 'ROW-1',
+      children: [chartLayoutKey],
+      parents: ['ROOT_ID', 'GRID_ID'],
+      meta: { background: 'BACKGROUND_TRANSPARENT' },
+    },
+    [chartLayoutKey]: {
+      type: 'CHART',
+      id: chartLayoutKey,
+      children: [],
+      parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
+      meta: { chartId, width: 12, height: 60, sliceName: spec.sliceName },
+    },
+  };
+
+  const dashResp = await apiPostDashboard(page, {
+    dashboard_title: `${spec.sliceName}_dash_${Date.now()}`,
+    published: true,
+    position_json: JSON.stringify(positionJson),
+  });
+  expect(dashResp.ok()).toBe(true);
+  const dashBody = await dashResp.json();
+  const dashboardId: number = dashBody.result?.id ?? dashBody.id;
+  testAssets.trackDashboard(dashboardId);
+
+  const linkResp = await apiPut(page, `api/v1/chart/${chartId}`, {
+    dashboards: [dashboardId],
+  });
+  expect(linkResp.ok()).toBe(true);
+
+  return dashboardId;
+}
+
+/**
+ * Right-click an echarts canvas until a data point is hit — i.e. until the
+ * context menu offers an *enabled* "Drill to detail by" submenu (a miss renders
+ * that item disabled, as a plain menu item rather than a submenu title).
+ *
+ * echarts renders to a single canvas, so there is no per-datum DOM element to
+ * target and the exact pixel of a mark depends on chart geometry (donut hole,
+ * legend size, axis padding). Rather than hard-code Cypress's brittle pixel
+ * coordinates, this scans a small set of candidate points — a radial ring for
+ * pie/radial charts, a grid for cartesian charts — and stops at the first that
+ * lands on a mark. The drill value is then whatever that mark represents, so the
+ * caller asserts a value round-trip rather than a specific geometry.
+ */
+async function rightClickCanvasDatum(
+  page: Page,
+  canvas: ReturnType<Page['locator']>,
+  pattern: 'ring' | 'grid',
+): Promise<void> {
+  const box = await canvas.boundingBox();
+  if (!box) throw new Error('canvas has no bounding box');
+
+  const candidates: Array<{ x: number; y: number }> = [];
+  if (pattern === 'ring') {
+    const cx = box.width / 2;
+    const cy = box.height / 2;
+    const minSide = Math.min(box.width, box.height);
+    for (const rf of [0.3, 0.22, 0.38]) {
+      for (let a = 0; a < 360; a += 45) {
+        const rad = (a * Math.PI) / 180;
+        candidates.push({
+          x: cx + Math.cos(rad) * minSide * rf,
+          y: cy + Math.sin(rad) * minSide * rf,
+        });
+      }
+    }
+  } else {
+    for (const yf of [0.5, 0.4, 0.6, 0.3, 0.7]) {
+      for (const xf of [0.3, 0.45, 0.6, 0.2, 0.75]) {
+        candidates.push({ x: box.width * xf, y: box.height * yf });
+      }
+    }
+  }
+
+  // The submenu *title* element only exists when "Drill to detail by" is an
+  // enabled submenu (a real datum was hit); a miss renders a disabled item.
+  const enabledDrillBy = page
+    .locator('.ant-dropdown-menu-submenu-title')
+    .filter({ hasText: 'Drill to detail by' });
+
+  for (const pt of candidates) {
+    await canvas.click({ button: 'right', position: pt });
+    const hit = await enabledDrillBy
+      .waitFor({ state: 'visible', timeout: 400 })
+      .then(() => true)
+      .catch(() => false);
+    if (hit) return;
+    await page.keyboard.press('Escape');
+  }
+  throw new Error(
+    `no drillable datum found on canvas after scanning ${candidates.length} points`,
+  );
+}
+
+/** A samples POST fired (proves the modal hit the real backend). */
+function expectSamplesPost(page: Page) {
+  return page.waitForResponse(
+    r =>
+      r.url().includes('/datasource/samples') &&
+      r.request().method() === 'POST',
+    { timeout: TIMEOUT.API_RESPONSE },
+  );
+}
+
+async function loadDashboardWithChart(
+  dashboard: DashboardPage,
+  dashboardId: number,
+  vizType: string,
+): Promise<void> {
+  await dashboard.gotoById(dashboardId);
+  await dashboard.waitForLoad();
+  await dashboard
+    .chartByVizType(vizType)
+    .locator('[data-test="chart-container"]')
+    .first()
+    .waitFor({ state: 'visible', timeout: TIMEOUT.QUERY_EXECUTION });
+  await dashboard.waitForChartsToLoad();
+}
+
+testWithAssets(
+  'drill-to-detail modal: opens from the header menu, paginates, and reload resets to page 1',
+  async ({ page, testAssets }) => {
+    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
+    const dashboard = new DashboardPage(page);
+    const dashboardId = await buildSingleChartDashboard(page, testAssets, {
+      vizType: 'big_number_total',
+      sliceName: 'drill_bignum',
+      params: { metric: 'count', adhoc_filters: [] },
+    });
+
+    await loadDashboardWithChart(dashboard, dashboardId, 'big_number_total');
+
+    // Open the modal from the chart's "More Options" header menu.
+    const samplesOnOpen = expectSamplesPost(page);
+    await dashboard.openDrillToDetailFromMenu('big_number_total');
+    await samplesOnOpen;
+
+    const modal = dashboard.drillModal();
+    await expect(modal).toBeVisible();
+    await expect(modal).toContainText('Drill to detail:');
+    // The metadata bar and a real row count prove the modal loaded backend data.
+    await expect(modal.locator('[data-test="metadata-bar"]')).toBeVisible();
+    await expect(modal.locator('[data-test="row-count-label"]')).toContainText(
+      'rows',
+    );
+    // No drill filter was applied (whole-chart drill).
+    await expect(dashboard.drillFilterValues()).toHaveCount(0);
+
+    // The full dataset spans multiple pages, and the grid has rendered rows.
+    const pageItems = modal.locator('.ant-pagination-item');
+    expect(await pageItems.count()).toBeGreaterThan(1);
+    await expect(modal.locator('.virtual-table-cell').first()).toBeVisible();
+    await expect(modal.locator('.ant-pagination-item-active')).toContainText(
+      '1',
+    );
+
+    // Paginate forward: clicking page 2 fires a real samples fetch and moves the
+    // active page to 2.
+    const samplesOnPage2 = expectSamplesPost(page);
+    await modal.locator('.ant-pagination-item').nth(1).click();
+    await samplesOnPage2;
+    await expect(modal.locator('.ant-pagination-item-active')).toContainText(
+      '2',
+    );
+
+    // Reload re-fetches and resets back to the first page.
+    const samplesOnReload = expectSamplesPost(page);
+    await modal.getByRole('button', { name: 'Reload' }).click();
+    await samplesOnReload;
+    await expect(modal.locator('.ant-pagination-item-active')).toContainText(
+      '1',
+    );
+  },
+);
+
+testWithAssets(
+  'drill-to-detail modal: big number value right-click drills the whole chart (no filter)',
+  async ({ page, testAssets }) => {
+    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
+    const dashboard = new DashboardPage(page);
+    const dashboardId = await buildSingleChartDashboard(page, testAssets, {
+      vizType: 'big_number_total',
+      sliceName: 'drill_bignum_rc',
+      params: { metric: 'count', adhoc_filters: [] },
+    });
+
+    await loadDashboardWithChart(dashboard, dashboardId, 'big_number_total');
+
+    // Right-click the rendered number itself opens the context menu.
+    const samples = expectSamplesPost(page);
+    await dashboard
+      .chartByVizType('big_number_total')
+      .locator('.header-line')
+      .click({ button: 'right' });
+    await dashboard.contextMenuDrillToDetail();
+    await samples;
+
+    await expect(dashboard.drillModal()).toBeVisible();
+    // Whole-chart drill: no per-value filter tag.
+    await expect(dashboard.drillFilterValues()).toHaveCount(0);
+    await expect(
+      dashboard.drillModal().locator('[data-test="row-count-label"]'),
+    ).toContainText('rows');
+  },
+);
+
+testWithAssets(
+  'drill-to-detail modal: table cell right-click drills by that value and clearing the filter restores the full set',
+  async ({ page, testAssets }) => {
+    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
+    const dashboard = new DashboardPage(page);
+    const dashboardId = await buildSingleChartDashboard(page, testAssets, {
+      vizType: 'table',
+      sliceName: 'drill_table',
+      params: {
+        query_mode: 'aggregate',
+        groupby: ['gender'],
+        metrics: ['count'],
+        row_limit: 100,
+        server_pagination: false,
+      },
+    });
+
+    await loadDashboardWithChart(dashboard, dashboardId, 'table');
+
+    // Right-click the "boy" dimension cell and drill by it.
+    const samplesOnDrill = expectSamplesPost(page);
+    await dashboard
+      .chartByVizType('table')
+      .getByText('boy', { exact: true })
+      .first()
+      .click({ button: 'right' });
+    await dashboard.contextMenuDrillToDetailBy('boy');
+    await samplesOnDrill;
+
+    const modal = dashboard.drillModal();
+    await expect(modal).toBeVisible();
+    await expect(dashboard.drillFilterValues().first()).toContainText('boy');
+
+    const filteredCount = parseRowCount(
+      await modal.locator('[data-test="row-count-label"]').innerText(),
+    );
+    expect(filteredCount).toBeGreaterThan(0);
+
+    // Clearing the filter reloads the samples and restores the larger, unfiltered total.
+    const samplesOnClear = expectSamplesPost(page);
+    await modal.locator('[data-test="filter-col"]').getByLabel('Close').click();
+    await samplesOnClear;
+    await expect(dashboard.drillFilterValues()).toHaveCount(0);
+    await expect
+      .poll(async () =>
+        parseRowCount(
+          await modal.locator('[data-test="row-count-label"]').innerText(),
+        ),
+      )
+      .toBeGreaterThan(filteredCount);
+  },
+);
+
+testWithAssets(
+  'drill-to-detail modal: pivot table cell right-click drills by the cell value',
+  async ({ page, testAssets }) => {
+    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
+    const dashboard = new DashboardPage(page);
+    const dashboardId = await buildSingleChartDashboard(page, testAssets, {
+      vizType: 'pivot_table_v2',
+      sliceName: 'drill_pivot',
+      params: {
+        groupbyRows: ['gender'],
+        groupbyColumns: [],
+        metrics: ['count'],
+        aggregateFunction: 'Sum',
+        rowTotals: false,
+        colTotals: false,
+      },
+    });
+
+    await loadDashboardWithChart(dashboard, dashboardId, 'pivot_table_v2');
+
+    await dashboard
+      .chartByVizType('pivot_table_v2')
+      .locator('[role="gridcell"]')
+      .first()
+      .click({ button: 'right' });
+
+    // The cell's row dimension determines the offered value; drill by it and
+    // assert the same value lands in the modal filter.
+    const offered = await dashboard.drillByOfferedValues();
+    expect(offered.length).toBeGreaterThan(0);
+    const value = offered[0];
+    const samples = expectSamplesPost(page);
+    await dashboard.contextMenuDrillToDetailBy(value);
+    await samples;
+
+    await expect(dashboard.drillModal()).toBeVisible();
+    await expect(dashboard.drillFilterValues().first()).toContainText(value);
+  },
+);
+
+testWithAssets(
+  'drill-to-detail modal: pie slice right-click (canvas) drills by the slice value',
+  async ({ page, testAssets }) => {
+    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
+    const dashboard = new DashboardPage(page);
+    const dashboardId = await buildSingleChartDashboard(page, testAssets, {
+      vizType: 'pie',
+      sliceName: 'drill_pie',
+      params: { groupby: ['gender'], metric: 'count' },
+    });
+
+    await loadDashboardWithChart(dashboard, dashboardId, 'pie');
+
+    const canvas = dashboard.chartByVizType('pie').locator('canvas').first();
+    await expect(canvas).toBeVisible();
+
+    // Pie is a donut by default (center is a hole), so scan the ring for a slice.
+    await rightClickCanvasDatum(page, canvas, 'ring');
+
+    const offered = await dashboard.drillByOfferedValues();
+    expect(offered.length).toBeGreaterThan(0);
+    const value = offered[0];
+    const samples = expectSamplesPost(page);
+    await dashboard.contextMenuDrillToDetailBy(value);
+    await samples;
+
+    await expect(dashboard.drillModal()).toBeVisible();
+    await expect(dashboard.drillFilterValues().first()).toContainText(value);
+  },
+);
+
+testWithAssets(
+  'drill-to-detail modal: line chart point right-click (canvas) drills by the point value',
+  async ({ page, testAssets }) => {
+    testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
+    const dashboard = new DashboardPage(page);
+    const dashboardId = await buildSingleChartDashboard(page, testAssets, {
+      vizType: 'echarts_timeseries_line',
+      sliceName: 'drill_line',
+      params: {
+        x_axis: 'ds',
+        time_grain_sqla: 'P1Y',
+        metrics: ['count'],
+        groupby: ['gender'],
+        row_limit: 1000,
+      },
+    });
+
+    await loadDashboardWithChart(
+      dashboard,
+      dashboardId,
+      'echarts_timeseries_line',
+    );
+
+    const canvas = dashboard
+      .chartByVizType('echarts_timeseries_line')
+      .locator('canvas')
+      .first();
+    await expect(canvas).toBeVisible();
+
+    // Scan the plot grid for a point on one of the series lines.
+    await rightClickCanvasDatum(page, canvas, 'grid');
+
+    const offered = await dashboard.drillByOfferedValues();
+    expect(offered.length).toBeGreaterThan(0);
+    const value = offered[0];
+    const samples = expectSamplesPost(page);
+    await dashboard.contextMenuDrillToDetailBy(value);
+    await samples;
+
+    await expect(dashboard.drillModal()).toBeVisible();
+    await expect(dashboard.drillFilterValues().first()).toContainText(value);
+  },
+);