mirror of
https://github.com/apache/superset.git
synced 2026-09-09 08:44:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d8ac94e14 | ||
|
|
fdc71e3e6a | ||
|
|
166d395a1e | ||
|
|
e5bbb793a4 | ||
|
|
e77d7eed89 |
@@ -160,6 +160,29 @@ export async function apiGetDashboard(
|
||||
return apiGet(page, `${ENDPOINTS.DASHBOARD}${dashboardId}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request to resolve a native-filter `filter_state` key for a dashboard.
|
||||
* The key is the `native_filters_key` URL param the filter bar publishes; a
|
||||
* 200 response carries `{ value: string }`, the serialized data mask.
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param dashboardId - ID of the dashboard the key belongs to
|
||||
* @param key - filter_state key to resolve
|
||||
* @param options - Optional request options
|
||||
* @returns API response with the stored filter state
|
||||
*/
|
||||
export async function apiGetDashboardFilterState(
|
||||
page: Page,
|
||||
dashboardId: number,
|
||||
key: string,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<APIResponse> {
|
||||
return apiGet(
|
||||
page,
|
||||
`${ENDPOINTS.DASHBOARD}${dashboardId}/filter_state/${key}`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request to remove a dashboard
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
|
||||
@@ -25,6 +25,15 @@ import { gotoWithRetry } from '../helpers/navigation';
|
||||
import { html5DragAndDrop } from '../helpers/dnd';
|
||||
import { TIMEOUT } from '../utils/constants';
|
||||
|
||||
/**
|
||||
* URL query param carrying the server-side `filter_state` key the native
|
||||
* filter bar publishes. Mirrors `URL_PARAMS.nativeFiltersKey.name` in
|
||||
* `src/constants.ts`, which cannot be imported here: it pulls in
|
||||
* `@superset-ui/core`, whose source graph is ESM-only under the Playwright
|
||||
* runner.
|
||||
*/
|
||||
const NATIVE_FILTERS_KEY_PARAM = 'native_filters_key';
|
||||
|
||||
/** Tabs of the dashboard builder side pane, by their rendered label. */
|
||||
type BuilderTab = 'Charts' | 'Layout elements';
|
||||
|
||||
@@ -235,6 +244,36 @@ export class DashboardPage {
|
||||
.toBe(tabName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a non-empty `native_filters_key` query param in the URL and return
|
||||
* it. The filter bar stamps the key via `history.replace` once its data mask
|
||||
* has been published to the backend `filter_state` store; Playwright treats
|
||||
* that History API change as a navigation, so `waitForURL` resolves only
|
||||
* after the driver-side frame URL has been updated.
|
||||
*
|
||||
* Only the URL is observed: on a page whose URL already carries the param
|
||||
* (a permalink, or `page.reload()`) this resolves immediately without any
|
||||
* publish having happened.
|
||||
*/
|
||||
async waitForNativeFiltersKey(options?: {
|
||||
timeout?: number;
|
||||
}): Promise<string> {
|
||||
const timeout = options?.timeout ?? TIMEOUT.API_RESPONSE;
|
||||
await this.page.waitForURL(
|
||||
url => !!url.searchParams.get(NATIVE_FILTERS_KEY_PARAM),
|
||||
{ timeout },
|
||||
);
|
||||
const key = new URL(this.page.url()).searchParams.get(
|
||||
NATIVE_FILTERS_KEY_PARAM,
|
||||
);
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
`${NATIVE_FILTERS_KEY_PARAM} not found in URL after publish`,
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the dashboard header actions menu (three-dot menu)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A fresh navigation is expected to reuse the same filter_state key, not mint
|
||||
* a new one: CreateFilterStateCommand caches by (session, tab, dashboard), so
|
||||
* this test uses `page.goto` on the bare dashboard URL rather than
|
||||
* `page.reload()` — a reload keeps `?native_filters_key=` in the address bar,
|
||||
* which would take the PUT (update) branch instead of re-exercising create.
|
||||
*
|
||||
* Reuse also requires every request to hit the same cache: the test config
|
||||
* uses a per-process SimpleCache behind a single non-recycling gunicorn
|
||||
* worker, so this is only deterministic under that setup.
|
||||
*/
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import {
|
||||
apiGetDashboardFilterState,
|
||||
ENDPOINTS,
|
||||
} from '../../helpers/api/dashboard';
|
||||
import { expectStatus } from '../../helpers/api/assertions';
|
||||
import { waitForPost } from '../../helpers/api/intercepts';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
import {
|
||||
buildFilterJsonMetadata,
|
||||
buildSelectFilter,
|
||||
createDashboardWithCharts,
|
||||
} from './dashboard-test-helpers';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
const FILTER_COLUMN = 'gender';
|
||||
|
||||
testWithAssets(
|
||||
'native filter bar mints a persisted, server-resolvable filter_state key and reuses it on a fresh navigation',
|
||||
async ({ page, testAssets }, testInfo) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
// The filter id is generated by the builder; capture it so the stored data
|
||||
// mask can be checked for this dashboard's filter specifically.
|
||||
let filterId = '';
|
||||
const { dashboardId } = await createDashboardWithCharts(
|
||||
page,
|
||||
testAssets,
|
||||
testInfo,
|
||||
{
|
||||
datasetName: DATASET_NAME,
|
||||
chartNamePrefix: 'nf_key',
|
||||
dashboardTitlePrefix: 'nf_key',
|
||||
chartSpecs: [
|
||||
{
|
||||
viz_type: 'big_number_total',
|
||||
params: { metric: 'count', adhoc_filters: [] },
|
||||
},
|
||||
],
|
||||
buildJsonMetadata: ({ charts, datasetId }) => {
|
||||
const chartsInScope = charts.map(chart => chart.id);
|
||||
const filter = buildSelectFilter({
|
||||
datasetId,
|
||||
column: FILTER_COLUMN,
|
||||
chartsInScope,
|
||||
name: 'Gender',
|
||||
});
|
||||
filterId = filter.id;
|
||||
return buildFilterJsonMetadata({
|
||||
chartsInScope,
|
||||
nativeFilters: [filter],
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const dashboard = new DashboardPage(page);
|
||||
const filterStateEndpoint = `${ENDPOINTS.DASHBOARD}${dashboardId}/filter_state`;
|
||||
|
||||
// Load the dashboard and return the URL key together with the key the
|
||||
// filter_state POST minted, so the two can be compared.
|
||||
const visitAndCaptureKeys = async () => {
|
||||
const created = waitForPost(page, filterStateEndpoint, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await dashboard.gotoById(dashboardId);
|
||||
await dashboard.waitForLoad();
|
||||
const urlKey = await dashboard.waitForNativeFiltersKey();
|
||||
const { key: postedKey } = await expectStatus(await created, 201).json();
|
||||
return { urlKey, postedKey };
|
||||
};
|
||||
|
||||
// Confirm the key resolves to this dashboard's stored data mask via the
|
||||
// backend filter_state GET endpoint — proving it is a real server-side
|
||||
// entry, not a client token. A client-only token would not resolve.
|
||||
const assertKeyResolves = async (key: string) => {
|
||||
const stateResp = await apiGetDashboardFilterState(
|
||||
page,
|
||||
dashboardId,
|
||||
key,
|
||||
{ failOnStatusCode: false },
|
||||
);
|
||||
expectStatus(stateResp, 200);
|
||||
const stateBody = await stateResp.json();
|
||||
// The stored value is the serialized data mask, keyed by filter id.
|
||||
expect(
|
||||
JSON.parse(stateBody.value),
|
||||
`filter_state key ${key} should carry the data mask for filter ${filterId}`,
|
||||
).toHaveProperty(filterId);
|
||||
};
|
||||
|
||||
const first = await visitAndCaptureKeys();
|
||||
expect(
|
||||
first.urlKey,
|
||||
'the key in the URL should be the one minted by the filter_state POST',
|
||||
).toBe(first.postedKey);
|
||||
await assertKeyResolves(first.urlKey);
|
||||
|
||||
// Fresh navigation: the backend reuses the existing key for this
|
||||
// (session, tab, dashboard), and the overwritten entry still resolves.
|
||||
const second = await visitAndCaptureKeys();
|
||||
expect(second.urlKey).toBe(second.postedKey);
|
||||
expect(
|
||||
second.urlKey,
|
||||
'a fresh navigation should reuse the same filter_state key for the session/tab',
|
||||
).toBe(first.urlKey);
|
||||
await assertKeyResolves(second.urlKey);
|
||||
},
|
||||
);
|
||||
@@ -151,8 +151,8 @@ interface SelectFilterOptions {
|
||||
|
||||
/**
|
||||
* Builds one `filter_select` native filter for a dashboard's `json_metadata`.
|
||||
* The filter id is generated here because no test needs to know it — filters are
|
||||
* addressed through the filter bar UI, not by id.
|
||||
* The filter id is generated here; most specs address filters through the filter
|
||||
* bar UI, and a spec that needs the id reads it off the returned config.
|
||||
*/
|
||||
export function buildSelectFilter(
|
||||
options: SelectFilterOptions,
|
||||
@@ -241,6 +241,15 @@ interface CreateDashboardWithChartsOptions {
|
||||
buildLayout?: (
|
||||
charts: readonly DashboardLayoutChart[],
|
||||
) => DashboardPositionJson;
|
||||
/**
|
||||
* Dashboard `json_metadata` (e.g. native filters via
|
||||
* `buildFilterJsonMetadata`); omitted when not provided. Receives the created
|
||||
* charts and the resolved dataset id so filters can target both.
|
||||
*/
|
||||
buildJsonMetadata?: (context: {
|
||||
charts: readonly DashboardLayoutChart[];
|
||||
datasetId: number;
|
||||
}) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,10 +298,15 @@ export async function createDashboardWithCharts(
|
||||
const positionJson = options.buildLayout
|
||||
? options.buildLayout(charts)
|
||||
: buildSingleRowDashboardLayout(charts);
|
||||
const jsonMetadata = options.buildJsonMetadata?.({
|
||||
charts,
|
||||
datasetId: dataset.id,
|
||||
});
|
||||
const dashResp = await apiPostDashboard(page, {
|
||||
dashboard_title: `${options.dashboardTitlePrefix}_${uniqueSuffix}`,
|
||||
published: true,
|
||||
position_json: JSON.stringify(positionJson),
|
||||
...(jsonMetadata && { json_metadata: JSON.stringify(jsonMetadata) }),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashboardId = await extractIdFromResponse(dashResp);
|
||||
|
||||
Reference in New Issue
Block a user