You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/06/15 15:53:20 UTC
(superset) branch master updated: test(pivot-table): embedded E2E that collapse state survives scrolling (#33406) (#40820)
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new e5b6642b186 test(pivot-table): embedded E2E that collapse state survives scrolling (#33406) (#40820)
e5b6642b186 is described below
commit e5b6642b186ec31fd48075a6bf97bc94023d8825
Author: Evan Rusackas <ev...@preset.io>
AuthorDate: Mon Jun 15 08:53:14 2026 -0700
test(pivot-table): embedded E2E that collapse state survives scrolling (#33406) (#40820)
Co-authored-by: Claude Code <no...@anthropic.com>
---
.../tests/embedded/pivot-collapse-state.spec.ts | 309 +++++++++++++++++++++
1 file changed, 309 insertions(+)
diff --git a/superset-frontend/playwright/tests/embedded/pivot-collapse-state.spec.ts b/superset-frontend/playwright/tests/embedded/pivot-collapse-state.spec.ts
new file mode 100644
index 00000000000..9ec36127c6b
--- /dev/null
+++ b/superset-frontend/playwright/tests/embedded/pivot-collapse-state.spec.ts
@@ -0,0 +1,309 @@
+/**
+ * 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.
+ */
+
+/**
+ * Regression for #33406: in a Pivot Table (v2) with nested rows, collapsing a
+ * row group with the [-] toggle should stay collapsed after the collapsed rows
+ * scroll out of the viewport and back. The bug reproduces specifically when the
+ * dashboard is embedded via an iframe — the collapse/expand state lives in the
+ * pivot renderer's local React state (`collapsedRows` initialised to `{}`), so
+ * anything that remounts the chart resets it and the rows re-expand.
+ *
+ * This spec runs on the embedded harness (the only place the bug is reported to
+ * reproduce). It collapses a top-level row, scrolls the embedded dashboard so
+ * the pivot leaves and re-enters the viewport, and asserts the row is still
+ * collapsed.
+ *
+ * CI green => collapse state survives the scroll round-trip; merging closes
+ * #33406 and guards against regressions.
+ * CI red => the rows re-expanded; the bug is live and the fix belongs in
+ * plugin-chart-pivot-table (lift collapse state out of transient
+ * component state, e.g. persist `collapsedRows`/`collapsedCols`).
+ *
+ * NOTE: the embedded suite only runs when the embedded SDK bundle is built and
+ * INCLUDE_EMBEDDED=true (CI sets both). It is skipped otherwise.
+ */
+import { test, expect, Browser, BrowserContext, Page } from '@playwright/test';
+import { createServer, IncomingMessage, ServerResponse, Server } from 'http';
+import { AddressInfo, Socket } from 'net';
+import { readFileSync, existsSync } from 'fs';
+import { join } from 'path';
+import {
+ apiEnableEmbedding,
+ getAccessToken,
+ getGuestToken,
+} from '../../helpers/api/embedded';
+import { apiPost, apiPut } from '../../helpers/api/requests';
+import { apiPostDashboard, apiDeleteDashboard } from '../../helpers/api/dashboard';
+import { apiDeleteChart } from '../../helpers/api/chart';
+import { EmbeddedPage } from '../../pages/EmbeddedPage';
+import { EMBEDDED } from '../../utils/constants';
+
+const SUPERSET_DOMAIN = (() => {
+ const url = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
+ return url.replace(/\/+$/, '');
+})();
+const SUPERSET_BASE_URL = SUPERSET_DOMAIN.endsWith('/')
+ ? SUPERSET_DOMAIN
+ : `${SUPERSET_DOMAIN}/`;
+
+const SDK_BUNDLE_PATH = join(
+ __dirname,
+ '../../../../superset-embedded-sdk/bundle/index.js',
+);
+const EMBED_APP_DIR = join(__dirname, '../../embedded-app');
+const INDEX_HTML_PATH = join(EMBED_APP_DIR, 'index.html');
+const DATASET_NAME = 'birth_names';
+
+interface EmbedAppServer {
+ server: Server;
+ url: string;
+ close: () => Promise<void>;
+}
+
+async function startEmbedAppServer(): Promise<EmbedAppServer> {
+ const sockets = new Set<Socket>();
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
+ const urlPath = req.url?.split('?')[0] || '/';
+ if (urlPath === '/sdk/index.js') {
+ if (!existsSync(SDK_BUNDLE_PATH)) {
+ res.writeHead(404);
+ res.end(
+ 'SDK bundle not found. Run: cd superset-embedded-sdk && npm ci && npm run build',
+ );
+ return;
+ }
+ res.writeHead(200, { 'Content-Type': 'text/javascript' });
+ res.end(readFileSync(SDK_BUNDLE_PATH));
+ return;
+ }
+ if (urlPath === '/' || urlPath === '/index.html') {
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(readFileSync(INDEX_HTML_PATH));
+ return;
+ }
+ res.writeHead(404);
+ res.end('Not found');
+ });
+ server.on('connection', socket => {
+ sockets.add(socket);
+ socket.once('close', () => sockets.delete(socket));
+ });
+ await new Promise<void>((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', () => {
+ server.removeListener('error', reject);
+ resolve();
+ });
+ });
+ const address = server.address() as AddressInfo;
+ return {
+ server,
+ url: `http://127.0.0.1:${address.port}`,
+ close: () =>
+ new Promise<void>(resolve => {
+ for (const socket of sockets) socket.destroy();
+ sockets.clear();
+ server.close(() => resolve());
+ }),
+ };
+}
+
+function createAdminContext(browser: Browser): Promise<BrowserContext> {
+ return browser.newContext({
+ storageState: 'playwright/.auth/user.json',
+ baseURL: SUPERSET_BASE_URL,
+ });
+}
+
+async function findDatasetIdByName(page: Page, name: string): Promise<number> {
+ const query = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
+ const resp = await page.request.get(`api/v1/dataset/?q=${query}`);
+ const body = await resp.json();
+ if (!body.result?.length) {
+ throw new Error(`Dataset ${name} not found`);
+ }
+ return body.result[0].id;
+}
+
+test.describe('Embedded Pivot Table collapse state (#33406)', () => {
+ test.describe.configure({ mode: 'serial' });
+ test.setTimeout(90000);
+
+ let appServer: EmbedAppServer;
+ let accessToken: string;
+ let embedUuid: string;
+ let dashboardId: number;
+ let chartId: number;
+
+ test.beforeAll(async ({ browser }) => {
+ test.skip(
+ !existsSync(SDK_BUNDLE_PATH),
+ 'Embedded SDK bundle not found. Build it with: cd superset-embedded-sdk && npm ci && npm run build',
+ );
+
+ appServer = await startEmbedAppServer();
+ const context = await createAdminContext(browser);
+ const setupPage = await context.newPage();
+ try {
+ const datasetId = await findDatasetIdByName(setupPage, DATASET_NAME);
+
+ const params = {
+ datasource: `${datasetId}__table`,
+ viz_type: 'pivot_table_v2',
+ groupbyRows: ['state', 'name'],
+ groupbyColumns: [],
+ metrics: ['count'],
+ metricsLayout: 'COLUMNS',
+ aggregateFunction: 'Count',
+ rowSubTotals: true,
+ rowTotals: true,
+ valueFormat: 'SMART_NUMBER',
+ row_limit: 1000,
+ order_desc: true,
+ };
+ const chartResp = await apiPost(setupPage, 'api/v1/chart/', {
+ slice_name: `pivot_collapse_repro_${Date.now()}`,
+ viz_type: 'pivot_table_v2',
+ datasource_id: datasetId,
+ datasource_type: 'table',
+ params: JSON.stringify(params),
+ });
+ chartId = (await chartResp.json()).id;
+
+ 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: 6,
+ height: 80,
+ sliceName: 'pivot_collapse_repro',
+ },
+ },
+ };
+ const dashResp = await apiPostDashboard(setupPage, {
+ dashboard_title: `pivot_collapse_repro_${Date.now()}`,
+ published: true,
+ position_json: JSON.stringify(positionJson),
+ });
+ const dashBody = await dashResp.json();
+ dashboardId = dashBody.id;
+ await apiPut(setupPage, `api/v1/chart/${chartId}`, {
+ dashboards: [dashboardId],
+ });
+
+ const embedded = await apiEnableEmbedding(setupPage, dashboardId);
+ embedUuid = embedded.uuid;
+ accessToken = await getAccessToken(setupPage);
+ } finally {
+ await context.close();
+ }
+ });
+
+ test.afterAll(async ({ browser }) => {
+ const context = await createAdminContext(browser);
+ try {
+ const cleanupPage = await context.newPage();
+ if (dashboardId !== undefined) {
+ await apiDeleteDashboard(cleanupPage, dashboardId, {
+ failOnStatusCode: false,
+ });
+ }
+ if (chartId !== undefined) {
+ await apiDeleteChart(cleanupPage, chartId, { failOnStatusCode: false });
+ }
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error('[pivot-collapse teardown] cleanup failed:', err);
+ } finally {
+ await context.close();
+ }
+ if (appServer) await appServer.close();
+ });
+
+ test('collapsed rows stay collapsed after a scroll round-trip', async ({
+ page,
+ }) => {
+ const embeddedPage = new EmbeddedPage(page);
+ await embeddedPage.exposeTokenFetcher(async () =>
+ getGuestToken(page, dashboardId, { accessToken }),
+ );
+ await embeddedPage.goto({
+ appUrl: appServer.url,
+ uuid: embedUuid,
+ supersetDomain: SUPERSET_DOMAIN,
+ });
+ await embeddedPage.waitForIframe();
+ await embeddedPage.waitForDashboardContent();
+ await embeddedPage.waitForChartRendered();
+
+ const rowLabels = embeddedPage.iframe.locator('.pvtRowLabel');
+ await expect
+ .poll(() => rowLabels.count(), { timeout: EMBEDDED.CHART_RENDER })
+ .toBeGreaterThan(1);
+ const expandedCount = await rowLabels.count();
+
+ // Collapse the first top-level row group via its [-] toggle. Scope to
+ // `.pvtTable` so we never match a stray `toggle` class elsewhere in the DOM.
+ await embeddedPage.iframe.locator('.pvtTable .toggle').first().click();
+ await expect
+ .poll(() => embeddedPage.iframe.locator('.pvtRowLabel').count(), {
+ timeout: EMBEDDED.CHART_RENDER,
+ })
+ .toBeLessThan(expandedCount);
+ const collapsedCount = await embeddedPage.iframe
+ .locator('.pvtRowLabel')
+ .count();
+
+ // Scroll the embedded dashboard so the pivot leaves the viewport, then back.
+ await embeddedPage.iframe.locator('body').evaluate(() => {
+ window.scrollTo(0, document.body.scrollHeight);
+ });
+ await page.waitForTimeout(800);
+ await embeddedPage.iframe.locator('body').evaluate(() => {
+ window.scrollTo(0, 0);
+ });
+ await page.waitForTimeout(1200);
+
+ // The collapsed group must remain collapsed (row-label count unchanged).
+ await expect(embeddedPage.iframe.locator('.pvtRowLabel')).toHaveCount(
+ collapsedCount,
+ );
+ });
+});