mirror of
https://github.com/apache/superset.git
synced 2026-06-11 02:29:19 +00:00
Compare commits
1 Commits
fix/chart-
...
tdd/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84ccbad1eb |
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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') {
|
||||
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.result?.id ?? 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()).toBeGreaterThan(1);
|
||||
const expandedCount = await rowLabels.count();
|
||||
|
||||
// Collapse the first top-level row group via its [-] toggle.
|
||||
await embeddedPage.iframe.locator('.toggle').first().click();
|
||||
await expect
|
||||
.poll(() => embeddedPage.iframe.locator('.pvtRowLabel').count())
|
||||
.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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user