Compare commits

..

6 Commits

Author SHA1 Message Date
Claude Code
4b0d949f94 chore(sql-lab): finish SqlLab typed-dispatch migration for SaveDatasetModal
Continues #39927. Completes the SqlLab feature area by moving
SaveDatasetModal/index.tsx onto useAppDispatch. The companion test was
previously blocking this migration because it used
jest.spyOn(reactRedux, 'useSelector'/'useDispatch') to mock the hooks
directly — that pattern can't intercept calls routed through the typed
hooks in src/views/store, since the typed hooks capture the function
references at module evaluation time.

Test refactor:
- Drop the reactRedux namespace spy. Use the existing createWrapper +
  initialState pattern to preload the SqlLab user fixture into the mock
  store, so the component's useSelector(state => state.user) returns
  the expected value through normal Redux flow.
- Mock createDatasource to return a thunk that resolves to { id: 123 }.
  The mock store includes redux-thunk middleware (via RTK's
  getDefaultMiddleware), so dispatch(createDatasource(...)) correctly
  unwraps the thunk and the production .then chain receives the
  expected payload.
- Centralize the render+useRedux+initialState boilerplate into a small
  renderModal() helper.
- Fix the setupOverwriteFlow helper to advance fake timers via
  `await act(async () => jest.runAllTimers())` after opening the
  AsyncSelect dropdown. The dropdown's debounced fetch couldn't fire
  without this, leaving two pre-existing tests failing on master.

Production migration:
- SaveDatasetModal/index.tsx: useDispatch → useAppDispatch; drop the
  obsolete `useDispatch<(dispatch: any) => Promise<JsonObject>>()`
  workaround annotation and the now-unused JsonObject import.

Action signature update:
- createDatasource's return type tightened from Promise<unknown> to
  Promise<{ id: number }>, matching what /api/v1/dataset/ actually
  returns. The call site can now drop the `.then((data: { id: number })`
  annotation cast — TypeScript infers the right shape.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 20:21:59 -07:00
Claude Code
bc816e112f fix(sql-lab): correct createCtasDatasource return type and fix latent bug
Address codeant/bito review on #40037. The /api/v1/dataset/get_or_create/
endpoint returns { result: { table_id: number } } per its OpenAPI schema,
but createCtasDatasource was annotated as Promise<{ id: number }> and
passed json.result straight into createDatasourceSuccess (which builds
`${data.id}__table`). The result.id read was always undefined, so the
CTAS Explore flow has been silently constructing "undefined__table" as
the datasource identifier at runtime. The previous as-unknown-as cast in
ExploreCtasResultsButton hid the contract drift from TypeScript.

- createCtasDatasource now returns Promise<{ table_id: number }>, matching
  the actual API payload.
- Normalize result.table_id -> { id } when dispatching
  createDatasourceSuccess, so the existing reducer/action contract is
  preserved (createDatasource still calls it with a real { id }).
- Drop the cast in ExploreCtasResultsButton; data.table_id is now properly
  type-checked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 20:20:16 -07:00
Claude Code
7bfa8e9e00 chore(sql-lab): migrate useDispatch to useAppDispatch
First feature-area migration of #39927 (react-redux v8 prep).

Replaces useDispatch with the typed useAppDispatch in 18 SqlLab files.
useSelector is intentionally left alone in this PR — SqlLabRootState
provides better typing for the sqlLab slice than the global RootState
(whose sqlLab field inherits from a weakly-typed reducer), so swapping
to useAppSelector would regress local type quality.

ExploreCtasResultsButton previously worked around v7's loose thunk
typing with `useDispatch<(dispatch: any) => Promise<JsonObject>>()`;
that annotation is dropped now that useAppDispatch returns the proper
ThunkDispatch type. The follow-up `.then(...)` callback that read
`data.table_id` is preserved by casting at the call site — the thunk's
declared return type (`Promise<{ id: number }>`) doesn't match what
this caller reads, but reconciling that is a separate concern.

SaveDatasetModal/index.tsx is intentionally NOT migrated: its test
uses `jest.spyOn(reactRedux, 'useDispatch')` to mock the hook, which
won't intercept calls routed through `useAppDispatch`. That test needs
to be refactored to use createWrapper + a mock store before its
production code can move to the typed hook.

Also fix AppDispatch in src/views/store.ts: `typeof store.dispatch`
falls back to `Dispatch<AnyAction>` here because Superset annotates
getMiddleware as `ConfigureStoreOptions['middleware']`, which erases
the middleware tuple type. Declare AppDispatch as
`ThunkDispatch<RootState, undefined, AnyAction> & typeof store.dispatch`
so the typed hook actually accepts thunks — the case it exists for.
A wider refactor of the middleware setup could restore inference and
remove the workaround.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 20:20:16 -07:00
Evan Rusackas
5ab8583cd0 chore(gha): pin github/codeql-action to a SHA (#40043)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 20:18:55 -07:00
Evan Rusackas
e66fbc91c2 chore(gha): pass commenter login through env in claude.yml (#40042)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 20:00:27 -07:00
Mafi
187bb416e7 fix(plugin-chart-ag-grid-table): use display text for filter and sort on HTML cells (#39885)
Co-authored-by: Richard Fogaca Nienkotter <63572350+richardfogaca@users.noreply.github.com>
2026-05-11 20:29:16 -03:00
40 changed files with 323 additions and 1007 deletions

View File

@@ -59,15 +59,6 @@ build-assets() {
say "::endgroup::"
}
build-embedded-sdk() {
cd "$GITHUB_WORKSPACE/superset-embedded-sdk"
say "::group::Build embedded SDK bundle for E2E tests"
npm ci
npm run build
say "::endgroup::"
}
build-instrumented-assets() {
cd "$GITHUB_WORKSPACE/superset-frontend"

View File

@@ -17,13 +17,12 @@ jobs:
steps:
- name: Check if user is allowed
id: check
env:
COMMENTER: ${{ github.event.comment.user.login }}
run: |
# List of allowed users
ALLOWED_USERS="mistercrunch,rusackas"
# Get the commenter's username
COMMENTER="${{ github.event.comment.user.login }}"
echo "Checking permissions for user: $COMMENTER"
# Check if user is in allowed list
@@ -45,9 +44,12 @@ jobs:
steps:
- name: Comment access denied
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
COMMENTER_LOGIN: ${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }}
with:
script: |
const message = `👋 Hi @${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }}!
const commenter = process.env.COMMENTER_LOGIN;
const message = `👋 Hi @${commenter}!
Thanks for trying to use Claude Code, but currently only certain team members have access to this feature.

View File

@@ -41,7 +41,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -53,6 +53,6 @@ jobs:
- name: Perform CodeQL Analysis
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
with:
category: "/language:${{matrix.language}}"

View File

@@ -169,7 +169,6 @@ jobs:
PYTHONPATH: ${{ github.workspace }}
REDIS_PORT: 16379
GITHUB_TOKEN: ${{ github.token }}
SUPERSET_FEATURE_EMBEDDED_SUPERSET: "true"
services:
postgres:
image: postgres:17-alpine
@@ -240,11 +239,6 @@ jobs:
uses: ./.github/actions/cached-dependencies
with:
run: build-instrumented-assets
- name: Build embedded SDK
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: build-embedded-sdk
- name: Install Playwright
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies

View File

@@ -43,7 +43,6 @@ jobs:
PYTHONPATH: ${{ github.workspace }}
REDIS_PORT: 16379
GITHUB_TOKEN: ${{ github.token }}
SUPERSET_FEATURE_EMBEDDED_SUPERSET: "true"
services:
postgres:
image: postgres:17-alpine
@@ -114,11 +113,6 @@ jobs:
uses: ./.github/actions/cached-dependencies
with:
run: build-instrumented-assets
- name: Build embedded SDK
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: build-embedded-sdk
- name: Install Playwright
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies

View File

@@ -18,8 +18,6 @@
# Configuration for docker-compose-light.yml - disables Redis and uses minimal services
# Import all settings from the main config first
import os
from flask_caching.backends.filesystemcache import FileSystemCache
from superset_config import * # noqa: F403
@@ -38,31 +36,3 @@ THUMBNAIL_CACHE_CONFIG = CACHE_CONFIG
# Disable Celery entirely for lightweight mode
CELERY_CONFIG = None # type: ignore[assignment,misc]
# Honor SUPERSET_FEATURE_<NAME> env vars on top of any flags inherited from
# superset_config. Lets local dev/e2e enable features (e.g. EMBEDDED_SUPERSET)
# without editing shipped config files. Only the literal string "true"
# (case-insensitive) is treated as enabled — "1"/"yes"/"on" are not, matching
# the strict-string convention used elsewhere in Superset's env parsing.
FEATURE_FLAGS = {
**FEATURE_FLAGS, # noqa: F405
**{
name[len("SUPERSET_FEATURE_") :]: value.strip().lower() == "true"
for name, value in os.environ.items()
if name.startswith("SUPERSET_FEATURE_")
},
}
# Disable Talisman so /embedded/<uuid> doesn't return X-Frame-Options:SAMEORIGIN.
# Without this, browsers refuse to render Superset inside an iframe from a
# different origin (i.e. the embedded SDK use case). Production/CI configures
# Talisman with explicit `frame-ancestors`; for the lightweight local stack we
# just turn it off.
TALISMAN_ENABLED = False
# Guest tokens (used by the embedded SDK) inherit the "Public" role's perms.
# Out of the box Public has zero perms, so embedded dashboards immediately fail
# their first call (`/api/v1/me/roles/`) with 403. Mirror Public to Gamma —
# the standard read-only viewer role — so the embedded flow can authenticate
# and load dashboard data in local dev.
PUBLIC_ROLE_LIKE = "Gamma"

View File

@@ -95,7 +95,6 @@ export default defineConfig({
testIgnore: [
'**/tests/auth/**/*.spec.ts',
'**/tests/sqllab/**/*.spec.ts',
'**/tests/embedded/**/*.spec.ts',
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
],
use: {
@@ -133,22 +132,6 @@ export default defineConfig({
// No storageState = clean browser with no cached cookies
},
},
{
// Embedded dashboard tests - validates the full embedding flow:
// external app -> SDK -> iframe -> guest token -> dashboard render.
// Each spec file mutates per-dashboard embedding state (UUID,
// allowed_domains) on a single shared Superset, so files must not
// run in parallel even if more are added later.
name: 'chromium-embedded',
testMatch: '**/tests/embedded/**/*.spec.ts',
fullyParallel: false,
use: {
browserName: 'chromium',
testIdAttribute: 'data-test',
// Uses admin auth for API calls to configure embedding and get guest tokens
storageState: 'playwright/.auth/user.json',
},
},
],
// Web server setup - disabled in CI (Flask started separately in workflow)

View File

@@ -1,96 +0,0 @@
<!--
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.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embedded Dashboard Test App</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; }
#superset-container { width: 100%; height: 100vh; }
#superset-container iframe { width: 100%; height: 100%; border: none; }
#error { color: red; padding: 20px; display: none; }
#status { padding: 10px; font-family: monospace; font-size: 12px; }
</style>
</head>
<body>
<div id="status">Initializing embedded dashboard...</div>
<div id="error"></div>
<div id="superset-container" data-test="embedded-container"></div>
<script src="/sdk/index.js"></script>
<script>
(async function () {
const params = new URLSearchParams(window.location.search);
const uuid = params.get('uuid');
const supersetDomain = params.get('supersetDomain');
if (!uuid || !supersetDomain) {
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent =
'Missing required query params: uuid, supersetDomain';
return;
}
const statusEl = document.getElementById('status');
// fetchGuestToken is injected by Playwright via page.exposeFunction()
// Falls back to window.__guestToken for simple/static token injection
async function fetchGuestToken() {
if (typeof window.__fetchGuestToken === 'function') {
statusEl.textContent = 'Fetching guest token...';
const token = await window.__fetchGuestToken();
statusEl.textContent = 'Guest token received, loading dashboard...';
return token;
}
if (window.__guestToken) {
return window.__guestToken;
}
throw new Error('No guest token source available');
}
try {
// Parse optional UI config from query params
const uiConfig = {};
if (params.get('hideTitle') === 'true') uiConfig.hideTitle = true;
if (params.get('hideTab') === 'true') uiConfig.hideTab = true;
if (params.get('hideChartControls') === 'true') uiConfig.hideChartControls = true;
const dashboard = await supersetEmbeddedSdk.embedDashboard({
id: uuid,
supersetDomain: supersetDomain,
mountPoint: document.getElementById('superset-container'),
fetchGuestToken: fetchGuestToken,
dashboardUiConfig: Object.keys(uiConfig).length > 0 ? uiConfig : undefined,
debug: params.get('debug') === 'true',
});
statusEl.textContent = 'Dashboard embedded successfully';
// Expose dashboard API on window for Playwright assertions
window.__embeddedDashboard = dashboard;
} catch (err) {
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent = 'Embed failed: ' + err.message;
statusEl.textContent = 'Error';
}
})();
</script>
</body>
</html>

View File

@@ -132,14 +132,26 @@ export interface DashboardResult {
published?: boolean;
}
async function getDashboardByFilter(
/**
* Get a dashboard by its title
* @param page - Playwright page instance (provides authentication context)
* @param title - The dashboard_title to search for
* @returns Dashboard object if found, null if not found
*/
export async function getDashboardByName(
page: Page,
col: 'dashboard_title' | 'slug',
value: string,
title: string,
): Promise<DashboardResult | null> {
const queryParam = rison.encode({
filters: [{ col, opr: 'eq', value }],
});
const filter = {
filters: [
{
col: 'dashboard_title',
opr: 'eq',
value: title,
},
],
};
const queryParam = rison.encode(filter);
const response = await apiGet(
page,
`${ENDPOINTS.DASHBOARD}?q=${queryParam}`,
@@ -157,29 +169,3 @@ async function getDashboardByFilter(
return null;
}
/**
* Get a dashboard by its title
* @param page - Playwright page instance (provides authentication context)
* @param title - The dashboard_title to search for
* @returns Dashboard object if found, null if not found
*/
export async function getDashboardByName(
page: Page,
title: string,
): Promise<DashboardResult | null> {
return getDashboardByFilter(page, 'dashboard_title', title);
}
/**
* Get a dashboard by its slug
* @param page - Playwright page instance (provides authentication context)
* @param slug - The slug to search for
* @returns Dashboard object if found, null if not found
*/
export async function getDashboardBySlug(
page: Page,
slug: string,
): Promise<DashboardResult | null> {
return getDashboardByFilter(page, 'slug', slug);
}

View File

@@ -1,136 +0,0 @@
/**
* 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.
*/
import { Page } from '@playwright/test';
import { apiPost, apiPut, getCsrfToken } from './requests';
import { ENDPOINTS as DASHBOARD_ENDPOINTS } from './dashboard';
export const ENDPOINTS = {
SECURITY_LOGIN: 'api/v1/security/login',
GUEST_TOKEN: 'api/v1/security/guest_token/',
} as const;
export interface EmbeddedConfig {
uuid: string;
allowed_domains: string[];
dashboard_id: number;
}
/**
* Enable embedding on a dashboard and return the embedded UUID.
* Uses PUT (upsert) to preserve UUID across repeated calls.
* @param page - Playwright page instance (provides authentication context)
* @param dashboardIdOrSlug - Numeric dashboard id or slug
* @param allowedDomains - Domains allowed to embed; empty array allows all
* @returns Embedded config with UUID, allowed_domains, and dashboard_id
*/
export async function apiEnableEmbedding(
page: Page,
dashboardIdOrSlug: number | string,
allowedDomains: string[] = [],
): Promise<EmbeddedConfig> {
const response = await apiPut(
page,
`${DASHBOARD_ENDPOINTS.DASHBOARD}${dashboardIdOrSlug}/embedded`,
{ allowed_domains: allowedDomains },
);
const body = await response.json();
return body.result as EmbeddedConfig;
}
/**
* Login as admin and return the JWT access token used by the guest_token
* endpoint. Cache the result at suite level (`beforeAll`) and pass it into
* `getGuestToken` to avoid a fresh login on every test.
*
* Defaults match `playwright/global-setup.ts` so credentials come from one
* source (env vars). Overrides via `options` win.
*/
export async function getAccessToken(
page: Page,
options?: { username?: string; password?: string },
): Promise<string> {
const username =
options?.username ?? process.env.PLAYWRIGHT_ADMIN_USERNAME ?? 'admin';
const password =
options?.password ?? process.env.PLAYWRIGHT_ADMIN_PASSWORD ?? 'general';
const loginResponse = await apiPost(
page,
ENDPOINTS.SECURITY_LOGIN,
{
username,
password,
provider: 'db',
refresh: true,
},
{ allowMissingCsrf: true },
);
const loginBody = await loginResponse.json();
return loginBody.access_token;
}
/**
* Get a guest token for an embedded dashboard.
* If `accessToken` is provided, the login round-trip is skipped — preferred
* for tests that fetch many tokens from a single suite.
* @returns Signed guest token string
*/
export async function getGuestToken(
page: Page,
dashboardId: number | string,
options?: {
accessToken?: string;
username?: string;
password?: string;
rls?: Array<{ dataset: number; clause: string }>;
},
): Promise<string> {
const accessToken =
options?.accessToken ??
(await getAccessToken(page, {
username: options?.username,
password: options?.password,
}));
const rls = options?.rls ?? [];
// The guest_token endpoint authenticates via JWT Bearer, but if the
// request also carries a session cookie (which page.request inherits from
// storageState), Flask-WTF still requires a matching X-CSRFToken. Send it
// unconditionally so this works whether the caller is authenticated via
// session, JWT, or both.
const { token: csrfToken } = await getCsrfToken(page);
const guestResponse = await page.request.post(ENDPOINTS.GUEST_TOKEN, {
data: {
user: {
username: 'embedded_test_user',
first_name: 'Embedded',
last_name: 'TestUser',
},
resources: [{ type: 'dashboard', id: String(dashboardId) }],
rls,
},
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
},
});
const guestBody = await guestResponse.json();
return guestBody.token;
}

View File

@@ -39,7 +39,7 @@ function getBaseUrl(): string {
return url.endsWith('/') ? url : `${url}/`;
}
export interface CsrfResult {
interface CsrfResult {
token: string;
error?: string;
}
@@ -49,7 +49,7 @@ export interface CsrfResult {
* Superset provides a CSRF token via api/v1/security/csrf_token/
* The session cookie is automatically included by page.request
*/
export async function getCsrfToken(page: Page): Promise<CsrfResult> {
async function getCsrfToken(page: Page): Promise<CsrfResult> {
try {
const response = await page.request.get('api/v1/security/csrf_token/', {
failOnStatusCode: false,

View File

@@ -1,172 +0,0 @@
/**
* 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.
*/
import { Page, FrameLocator, Locator, expect } from '@playwright/test';
import { EMBEDDED } from '../utils/constants';
/**
* Page object for the embedded dashboard test app.
*
* The test app runs on a separate origin (its origin is assigned per-suite
* via an OS-allocated port) and uses the @superset-ui/embedded-sdk to render
* a Superset dashboard in an iframe. Playwright's page.exposeFunction()
* bridges the guest token from Node.js into the browser page.
*/
export class EmbeddedPage {
private readonly page: Page;
private static readonly SELECTORS = {
CONTAINER: '[data-test="embedded-container"]',
IFRAME: 'iframe[title="Embedded Dashboard"]',
STATUS: '#status',
ERROR: '#error',
} as const;
constructor(page: Page) {
this.page = page;
}
/**
* Set up the guest token bridge before navigating.
* Must be called BEFORE goto() since embedDashboard() calls fetchGuestToken
* immediately on page load.
*/
async exposeTokenFetcher(tokenFn: () => Promise<string>): Promise<void> {
await this.page.exposeFunction('__fetchGuestToken', tokenFn);
}
/**
* Navigate to the embedded test app with the given parameters.
* `appUrl` is the origin of the static test app (assigned dynamically by
* the spec's beforeAll fixture so workers don't collide on a fixed port).
*/
async goto(params: {
appUrl: string;
uuid: string;
supersetDomain: string;
hideTitle?: boolean;
hideTab?: boolean;
hideChartControls?: boolean;
debug?: boolean;
}): Promise<void> {
const searchParams = new URLSearchParams({
uuid: params.uuid,
supersetDomain: params.supersetDomain,
});
if (params.hideTitle) searchParams.set('hideTitle', 'true');
if (params.hideTab) searchParams.set('hideTab', 'true');
if (params.hideChartControls) searchParams.set('hideChartControls', 'true');
if (params.debug) searchParams.set('debug', 'true');
await this.page.goto(`${params.appUrl}/?${searchParams.toString()}`);
}
/**
* FrameLocator for the embedded dashboard iframe.
*/
get iframe(): FrameLocator {
return this.page.frameLocator(EmbeddedPage.SELECTORS.IFRAME);
}
/**
* Wait for the iframe to appear in the DOM AND have its src set.
* The SDK appends the iframe element before assigning src, so a bare
* `state: 'attached'` wait races the src read.
*/
async waitForIframe(options?: { timeout?: number }): Promise<void> {
const locator = this.page.locator(EmbeddedPage.SELECTORS.IFRAME);
await locator.waitFor({
state: 'attached',
timeout: options?.timeout ?? EMBEDDED.IFRAME_LOAD,
});
await expect(locator).toHaveAttribute('src', /.+/, {
timeout: options?.timeout ?? EMBEDDED.IFRAME_LOAD,
});
}
/**
* Wait for dashboard content to render inside the iframe.
* Looks for the grid-container which indicates charts are loading/loaded.
*/
async waitForDashboardContent(options?: { timeout?: number }): Promise<void> {
const frame = this.iframe;
await frame
.locator('.grid-container, [data-test="grid-container"]')
.first()
.waitFor({
state: 'visible',
timeout: options?.timeout ?? EMBEDDED.DASHBOARD_RENDER,
});
}
/**
* Matches a chart cell that has finished loading: it contains a real viz
* element (svg, canvas, table) AND no longer hosts the `Loading` spinner
* (`data-test="loading-indicator"`). Excluding the spinner matters —
* the spinner itself renders an SVG, so a `:has(svg)`-only check can match
* a still-loading chart for the wrong reason.
*/
static readonly RENDERED_CHART_SELECTOR =
'[data-test="chart-container"]:has(svg, canvas, table):not(:has([data-test="loading-indicator"]))';
/**
* Wait for at least one chart to finish rendering — viz drawn AND no
* loading spinner in that cell.
*/
async waitForChartRendered(options?: { timeout?: number }): Promise<void> {
await this.iframe
.locator(EmbeddedPage.RENDERED_CHART_SELECTOR)
.first()
.waitFor({
state: 'visible',
timeout: options?.timeout ?? EMBEDDED.CHART_RENDER,
});
}
/**
* Locator for the dashboard title input inside the iframe.
* Returned as a `Locator` so callers can use `expect(...).toBeVisible()` /
* `.toBeHidden()` with auto-retry instead of one-shot `.isVisible()`.
*/
get titleLocator(): Locator {
return this.iframe.locator(
'[data-test="dashboard-header-container"] [data-test="editable-title-input"]',
);
}
/**
* Get the status text from the test app.
*/
async getStatus(): Promise<string> {
return (
(await this.page.locator(EmbeddedPage.SELECTORS.STATUS).textContent()) ??
''
);
}
/**
* Get the error text, if any.
*/
async getError(): Promise<string> {
const errorEl = this.page.locator(EmbeddedPage.SELECTORS.ERROR);
const display = await errorEl.evaluate(el => getComputedStyle(el).display);
if (display === 'none') return '';
return (await errorEl.textContent()) ?? '';
}
}

View File

@@ -1,356 +0,0 @@
/**
* 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.
*/
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 { getDashboardBySlug } from '../../helpers/api/dashboard';
import { EmbeddedPage } from '../../pages/EmbeddedPage';
/**
* Superset domain (Flask server) — set by CI or defaults to local dev
*/
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}/`;
/**
* Path to the SDK bundle built from superset-embedded-sdk/
*/
const SDK_BUNDLE_PATH = join(
__dirname,
'../../../../superset-embedded-sdk/bundle/index.js',
);
/**
* Path to the embedded test app static files
*/
const EMBED_APP_DIR = join(__dirname, '../../embedded-app');
/**
* Create a minimal static file server for the embedded test app.
* Serves only a fixed allowlist of routes — the test app references just
* its index.html and the SDK bundle, so anything else is 404.
*/
const INDEX_HTML_PATH = join(EMBED_APP_DIR, 'index.html');
interface EmbedAppServer {
server: Server;
url: string;
close: () => Promise<void>;
}
/**
* Start the static test app on an OS-assigned ephemeral port. Tracks open
* sockets so close() doesn't hang on iframe keep-alive connections, and so
* different workers/retries never collide on a fixed port.
*/
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;
const url = `http://127.0.0.1:${address.port}`;
return {
server,
url,
close: () =>
new Promise<void>(resolve => {
for (const socket of sockets) socket.destroy();
sockets.clear();
server.close(() => resolve());
}),
};
}
/**
* Create a browser context authenticated as admin for API-only work
* (enabling embedding, restoring config). Caller is responsible for closing.
*/
function createAdminContext(browser: Browser): Promise<BrowserContext> {
return browser.newContext({
storageState: 'playwright/.auth/user.json',
baseURL: SUPERSET_BASE_URL,
});
}
// ─── Test Suite ────────────────────────────────────────────────────────────
// Describe wrapper is needed for shared server state and serial execution:
// all tests share a static file server and must not run in parallel.
test.describe('Embedded Dashboard E2E', () => {
test.describe.configure({ mode: 'serial' });
// The full embedded chain (login → guest token → iframe → dashboard render
// → chart render) routinely exceeds the 30s default on cold CI starts.
test.setTimeout(60000);
let appServer: EmbedAppServer;
let accessToken: string;
let embedUuid: string;
let dashboardId: number;
/**
* Set up a page to render the default embedded dashboard.
* Tests that need a different UUID or UI config should not use this helper.
*/
async function setupEmbeddedPage(page: Page): Promise<EmbeddedPage> {
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();
return embeddedPage;
}
test.beforeAll(async ({ browser }) => {
// Skip all tests if the SDK bundle hasn't been built
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();
// Use a fresh context with auth to set up test data via API
const context = await createAdminContext(browser);
const setupPage = await context.newPage();
try {
const dashboard = await getDashboardBySlug(setupPage, 'world_health');
if (!dashboard) {
throw new Error(
'Dashboard "world_health" not found. Ensure load_examples ran in CI setup.',
);
}
dashboardId = dashboard.id;
// Enable embedding on the dashboard (empty allowed_domains = allow all)
const embedded = await apiEnableEmbedding(setupPage, dashboardId);
embedUuid = embedded.uuid;
// Cache the JWT access token so tests don't re-login per guest token.
accessToken = await getAccessToken(setupPage);
} finally {
await context.close();
}
});
test.afterAll(async ({ browser }) => {
// Defensive restore in case the allowed_domains test failed mid-flight.
if (dashboardId !== undefined) {
const context = await createAdminContext(browser);
try {
const setupPage = await context.newPage();
await apiEnableEmbedding(setupPage, dashboardId, []);
} catch {
// Best-effort cleanup — never fail teardown.
} finally {
await context.close();
}
}
if (appServer) await appServer.close();
});
test('dashboard renders in embedded iframe', async ({ page }) => {
const embeddedPage = await setupEmbeddedPage(page);
// Verify the iframe src points to Superset's /embedded/ endpoint
await expect(
page.locator('iframe[title="Embedded Dashboard"]'),
).toHaveAttribute('src', new RegExp(`/embedded/${embedUuid}`));
// Verify no errors in the test app
expect(await embeddedPage.getError()).toBe('');
// Baseline: title should be visible when hideTitle is not set. This
// doubles as a positive existence check the `hideTitle` test relies on
// for distinguishing "title was hidden" from "selector is wrong".
await expect(embeddedPage.titleLocator).toBeVisible();
// Prove the dashboard actually renders, not just the chrome.
await embeddedPage.waitForChartRendered();
});
test('UI config hideTitle hides dashboard title', 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,
hideTitle: true,
});
await embeddedPage.waitForIframe();
await embeddedPage.waitForDashboardContent();
// The iframe URL should include uiConfig parameter
await expect(
page.locator('iframe[title="Embedded Dashboard"]'),
).toHaveAttribute('src', /uiConfig=/);
// hideTitle removes the header from the DOM (rather than CSS-hiding it),
// so toBeHidden + toHaveCount(0) together assert: not visible AND
// confirmed-removed (so the test can't pass for the wrong reason if the
// selector ever drifts — the baseline test asserts the selector matches
// when hideTitle is off).
await expect(embeddedPage.titleLocator).toBeHidden();
await expect(embeddedPage.titleLocator).toHaveCount(0);
});
test('charts render inside embedded iframe', async ({ page }) => {
const embeddedPage = await setupEmbeddedPage(page);
await embeddedPage.waitForChartRendered();
const renderedCharts = embeddedPage.iframe.locator(
EmbeddedPage.RENDERED_CHART_SELECTOR,
);
expect(await renderedCharts.count()).toBeGreaterThan(0);
});
test('allowed_domains blocks unauthorized referrer', async ({
page,
browser,
}) => {
const context = await createAdminContext(browser);
const setupPage = await context.newPage();
try {
// Restrict to a domain that is NOT the test app's origin
const restrictedEmbed = await apiEnableEmbedding(setupPage, dashboardId, [
'https://allowed.example.com',
]);
const embeddedPage = new EmbeddedPage(page);
await embeddedPage.exposeTokenFetcher(async () =>
getGuestToken(page, dashboardId, { accessToken }),
);
// The deterministic signal that the referrer check fired is the HTTP
// status of the /embedded/<uuid> response — assert that directly rather
// than racing against cross-origin iframe rendering.
const embeddedResponsePromise = page.waitForResponse(
resp =>
resp.url().includes(`/embedded/${restrictedEmbed.uuid}`) &&
resp.request().resourceType() === 'document',
);
await embeddedPage.goto({
appUrl: appServer.url,
uuid: restrictedEmbed.uuid,
supersetDomain: SUPERSET_DOMAIN,
});
const response = await embeddedResponsePromise;
expect(response.status()).toBe(403);
} finally {
// Restore the open embedding config for other tests in this file.
await apiEnableEmbedding(setupPage, dashboardId, []);
await context.close();
}
});
test('guest token enables dashboard data access', async ({ page }) => {
const embeddedPage = new EmbeddedPage(page);
let tokenCallCount = 0;
await embeddedPage.exposeTokenFetcher(async () => {
tokenCallCount += 1;
return getGuestToken(page, dashboardId, { accessToken });
});
await embeddedPage.goto({
appUrl: appServer.url,
uuid: embedUuid,
supersetDomain: SUPERSET_DOMAIN,
});
await embeddedPage.waitForIframe();
await embeddedPage.waitForDashboardContent();
await embeddedPage.waitForChartRendered();
// The SDK fetches the token exactly once per embed (caching is the
// SDK's responsibility, not ours) — assert the stronger invariant.
expect(tokenCallCount).toBe(1);
// Confirm at least one chart actually rendered with data, not just its shell
const renderedCharts = embeddedPage.iframe.locator(
EmbeddedPage.RENDERED_CHART_SELECTOR,
);
expect(await renderedCharts.count()).toBeGreaterThan(0);
});
});

View File

@@ -75,16 +75,3 @@ export const TIMEOUT = {
*/
SLOW_TEST: 60000, // 60s for tests that chain multiple slow operations
} as const;
/**
* Embedded dashboard test app configuration.
* The test app is served by a Node.js http server started in the test fixture.
*/
export const EMBEDDED = {
/** Timeout for iframe to appear in the DOM */
IFRAME_LOAD: 15000, // 15s
/** Timeout for dashboard content to render inside the iframe */
DASHBOARD_RENDER: 30000, // 30s
/** Timeout for individual chart cells to finish rendering */
CHART_RENDER: 30000, // 30s
} as const;

View File

@@ -0,0 +1,83 @@
/**
* 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.
*/
import { ValueGetterParams } from '@superset-ui/core/components/ThemedAgGridReact';
import htmlTextFilterValueGetter, {
htmlTextComparator,
} from './htmlTextFilterValueGetter';
const makeParams = (value: unknown): ValueGetterParams =>
({
data: { foo: value },
colDef: { field: 'foo' },
}) as unknown as ValueGetterParams;
test('htmlTextFilterValueGetter extracts visible text from HTML anchor', () => {
expect(
htmlTextFilterValueGetter(
makeParams(
'<a href="https://jira.example.com/123/S18_3232">S18_3232</a>',
),
),
).toBe('S18_3232');
});
test('htmlTextFilterValueGetter strips nested HTML markup', () => {
expect(
htmlTextFilterValueGetter(
makeParams('<div><strong>Hello</strong> <em>World</em></div>'),
),
).toBe('Hello World');
});
test('htmlTextFilterValueGetter passes plain strings through', () => {
expect(htmlTextFilterValueGetter(makeParams('plain value'))).toBe(
'plain value',
);
});
test('htmlTextFilterValueGetter passes non-string values through', () => {
expect(htmlTextFilterValueGetter(makeParams(42))).toBe(42);
expect(htmlTextFilterValueGetter(makeParams(null))).toBeNull();
expect(htmlTextFilterValueGetter(makeParams(undefined))).toBeUndefined();
});
test('htmlTextComparator orders by visible text, not raw HTML', () => {
// URL prefixes (zzz vs bbb) would flip the order under raw-HTML sort,
// but the visible labels (S700_4002 vs S72_3212) sort the other way.
const left = '<a href="https://jira.example.com/zzz/S700_4002">S700_4002</a>';
const right = '<a href="https://jira.example.com/bbb/S72_3212">S72_3212</a>';
expect(htmlTextComparator(left, right)).toBeLessThan(0);
});
test('htmlTextComparator handles nulls and numbers', () => {
expect(htmlTextComparator(null, null)).toBe(0);
expect(htmlTextComparator(null, 'x')).toBeLessThan(0);
expect(htmlTextComparator('x', null)).toBeGreaterThan(0);
expect(htmlTextComparator(1, 2)).toBeLessThan(0);
expect(htmlTextComparator(2, 1)).toBeGreaterThan(0);
});
test('htmlTextComparator preserves default codepoint ordering for plain strings', () => {
// AG Grid's default string comparator orders by codepoint, so 'Z' (90)
// sorts before 'a' (97). A locale-aware comparator would flip this —
// verify we match the default so plain string columns are unaffected.
expect(htmlTextComparator('Z', 'a')).toBeLessThan(0);
expect(htmlTextComparator('a', 'Z')).toBeGreaterThan(0);
expect(htmlTextComparator('apple', 'banana')).toBeLessThan(0);
});

View File

@@ -0,0 +1,74 @@
/**
* 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.
*/
import { isProbablyHTML, sanitizeHtml } from '@superset-ui/core';
import { ValueGetterParams } from '@superset-ui/core/components/ThemedAgGridReact';
const stripHtmlToText = (html: string): string => {
const doc = new DOMParser().parseFromString(sanitizeHtml(html), 'text/html');
return (doc.body.textContent || '').trim();
};
// Cache the comparator-ready form per raw string. Both the HTML-detection
// step (`isProbablyHTML`, which itself invokes DOMParser for HTML-looking
// values) and the extraction step (`stripHtmlToText`, also DOMParser) are
// expensive; sort runs `O(n log n)` comparator calls against the same set
// of cell values. Memoizing the combined detection + extraction means each
// unique cell value pays the cost once per session. Module-level scope;
// bounded by the count of unique string cell values seen.
const comparableTextCache = new Map<string, string>();
const toComparableText = (raw: string): string => {
const cached = comparableTextCache.get(raw);
if (cached !== undefined) return cached;
const normalized = isProbablyHTML(raw) ? stripHtmlToText(raw) : raw;
comparableTextCache.set(raw, normalized);
return normalized;
};
/**
* Returns the visible-text representation of an HTML cell value so AG Grid
* filters and sort operate on what the user sees, not the underlying markup.
* Pass-through for non-HTML values.
*/
const htmlTextFilterValueGetter = (params: ValueGetterParams) => {
const raw = params.data?.[params.colDef.field as string];
return typeof raw === 'string' ? toComparableText(raw) : raw;
};
/**
* Comparator that mirrors AG Grid's default string comparator (codepoint
* order, nulls first), but extracts visible text from HTML values first
* so HTML cells sort by their displayed label. Plain (non-HTML) values
* pass through unchanged, preserving default ordering — e.g. 'Z' still
* sorts before 'a' as it does under the default comparator.
*/
export const htmlTextComparator = (a: unknown, b: unknown): number => {
const toText = (v: unknown) =>
typeof v === 'string' ? toComparableText(v) : v;
const aT = toText(a);
const bT = toText(b);
if (aT == null && bT == null) return 0;
if (aT == null) return -1;
if (bT == null) return 1;
if (typeof aT === 'number' && typeof bT === 'number') return aT - bT;
if (aT === bT) return 0;
return aT < bT ? -1 : 1;
};
export default htmlTextFilterValueGetter;

View File

@@ -32,6 +32,9 @@ import {
} from '../types';
import getCellClass from './getCellClass';
import filterValueGetter from './filterValueGetter';
import htmlTextFilterValueGetter, {
htmlTextComparator,
} from './htmlTextFilterValueGetter';
import dateFilterComparator from './dateFilterComparator';
import DateWithFormatter from './DateWithFormatter';
import { getAggFunc } from './getAggFunc';
@@ -317,6 +320,24 @@ export const useColDefs = ({
...(isPercentMetric && {
filterValueGetter,
}),
...(dataType === GenericDataType.String &&
!serverPagination && {
// HTML cells (e.g. anchor markup) are rendered by TextCellRenderer
// via dangerouslySetInnerHTML; without these the filter and sort
// operate on raw HTML so the URL inside the markup dictates order
// and the "Contains" filter matches against the raw HTML string.
//
// Gated on !serverPagination: in server-pagination mode sort and
// filter are both delegated to the backend (which sees raw HTML
// in the database), so applying the visible-text getter only on
// the client would create a mismatch where the typed filter
// value is stripped client-side but the server query still
// operates on the raw HTML. Server-paginated tables with HTML
// columns are out of scope for this fix and would require
// server-side handling.
filterValueGetter: htmlTextFilterValueGetter,
comparator: htmlTextComparator,
}),
...(dataType === GenericDataType.Temporal && {
// Use dateFilterValueGetter so AG Grid correctly identifies null dates for blank filter
filterValueGetter: dateFilterValueGetter,

View File

@@ -1671,7 +1671,7 @@ export interface VizOptions {
export function createDatasource(
vizOptions: VizOptions,
): SqlLabThunkAction<Promise<unknown>> {
): SqlLabThunkAction<Promise<{ id: number }>> {
return (dispatch: AppDispatch) => {
dispatch(createDatasourceStarted());
const { dbId, catalog, schema, datasourceName, sql, templateParams } =
@@ -1691,9 +1691,10 @@ export function createDatasource(
}),
})
.then(({ json }) => {
dispatch(createDatasourceSuccess(json as { id: number }));
const result = json as { id: number };
dispatch(createDatasourceSuccess(result));
return Promise.resolve(json);
return result;
})
.catch(error => {
getClientErrorObject(error).then(e => {
@@ -1712,7 +1713,7 @@ export function createDatasource(
export function createCtasDatasource(
vizOptions: Record<string, unknown>,
): SqlLabThunkAction<Promise<{ id: number }>> {
): SqlLabThunkAction<Promise<{ table_id: number }>> {
return (dispatch: AppDispatch) => {
dispatch(createDatasourceStarted());
return SupersetClient.post({
@@ -1720,9 +1721,14 @@ export function createCtasDatasource(
jsonPayload: vizOptions,
})
.then(({ json }) => {
dispatch(createDatasourceSuccess(json.result));
const result = json.result as { table_id: number };
// The endpoint's `result.table_id` IS the dataset id; normalize so
// createDatasourceSuccess's `${data.id}__table` resolves correctly.
// Without this, the CTAS Explore button silently produced
// `"undefined__table"` because `result.id` doesn't exist.
dispatch(createDatasourceSuccess({ id: result.table_id }));
return json.result;
return result;
})
.catch(() => {
const errorMsg = t('An error occurred while creating the data source');

View File

@@ -19,7 +19,8 @@
import { useRef, useEffect, FC, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { logging } from '@apache-superset/core/utils';
import {
SqlLabRootState,
@@ -86,7 +87,7 @@ const EditorAutoSync: FC = () => {
const editorTabLastUpdatedAt = useSelector<SqlLabRootState, number>(
state => state.sqlLab.editorTabLastUpdatedAt,
);
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const lastSavedTimestampRef = useRef<number>(editorTabLastUpdatedAt);
const currentQueryEditorId = useSelector<SqlLabRootState, string>(

View File

@@ -17,7 +17,8 @@
* under the License.
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { usePrevious } from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/theme';
import { Global } from '@emotion/react';
@@ -136,7 +137,7 @@ const EditorWrapper = ({
height,
hotkeys,
}: EditorWrapperProps) => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const queryEditor = useQueryEditor(queryEditorId, [
'id',
'dbId',

View File

@@ -17,7 +17,8 @@
* under the License.
*/
import { useEffect, useMemo, useRef } from 'react';
import { useDispatch, useStore } from 'react-redux';
import { useStore } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { t } from '@apache-superset/core/translation';
import { getExtensionsRegistry } from '@superset-ui/core';
@@ -68,7 +69,7 @@ export function useKeywords(
catalog,
schema,
});
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const hasFetchedKeywords = useRef(false);
// skipFetch is used to prevent re-evaluating memoized keywords
// due to updated api results by skip flag

View File

@@ -16,9 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useSelector, useDispatch } from 'react-redux';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { t } from '@apache-superset/core/translation';
import { JsonObject, VizType } from '@superset-ui/core';
import { VizType } from '@superset-ui/core';
import {
createCtasDatasource,
addInfoToast,
@@ -45,7 +46,7 @@ const ExploreCtasResultsButton = ({
const errorMessage = useSelector(
(state: SqlLabRootState) => state.sqlLab.errorMessage,
);
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
const dispatch = useAppDispatch();
const buildVizOptions = {
table_name: table,
@@ -56,7 +57,7 @@ const ExploreCtasResultsButton = ({
const visualize = () => {
dispatch(createCtasDatasource(buildVizOptions))
.then((data: { table_id: number }) => {
.then(data => {
const formData = {
datasource: `${data.table_id}__table`,
metrics: ['count'],

View File

@@ -17,7 +17,8 @@
* under the License.
*/
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import URI from 'urijs';
import { pick } from 'lodash';
import { useComponentDidUpdate } from '@superset-ui/core';
@@ -49,7 +50,7 @@ const PopEditorTab: React.FC<{ children?: React.ReactNode }> = ({
({ sqlLab: { tabHistory } }) => tabHistory.slice(-1)[0],
);
const [updatedUrl, setUpdatedUrl] = useState<string>(SQL_LAB_URL);
const dispatch = useDispatch();
const dispatch = useAppDispatch();
useComponentDidUpdate(() => {
setQueryEditorId(assigned => assigned ?? activeQueryEditorId);
if (activeQueryEditorId) {

View File

@@ -17,7 +17,8 @@
* under the License.
*/
import { useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { isObject } from 'lodash';
import rison from 'rison';
import {
@@ -82,7 +83,7 @@ function QueryAutoRefresh({
.map(({ id }) => id),
),
);
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const checkForRefresh = () => {
const shouldRequestChecking = shouldCheckForQueries(queries);

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useDispatch } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { t } from '@apache-superset/core/translation';
import { Dropdown, Button } from '@superset-ui/core/components';
import { Menu } from '@superset-ui/core/components/Menu';
@@ -75,7 +75,7 @@ const QueryLimitSelect = ({
maxRow,
defaultQueryLimit,
}: QueryLimitSelectProps) => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const queryEditor = useQueryEditor(queryEditorId, ['id', 'queryLimit']);
const queryLimit = queryEditor.queryLimit || defaultQueryLimit;

View File

@@ -30,7 +30,8 @@ import ProgressBar from '@superset-ui/core/components/ProgressBar';
import { t } from '@apache-superset/core/translation';
import { QueryResponse, QueryState } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import {
queryEditorSetSql,
@@ -92,7 +93,7 @@ const QueryTable = ({
latestQueryId,
}: QueryTableProps) => {
const theme = useTheme();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const [selectedQuery, setSelectedQuery] = useState<QueryResponse | null>(
null,
);

View File

@@ -27,7 +27,8 @@ import {
} from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { useHistory } from 'react-router-dom';
import { pick } from 'lodash';
import {
@@ -231,7 +232,7 @@ const ResultSet = ({
canCopyClipboardSqlLab: canCopyClipboard,
} = usePermissions();
const history = useHistory();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const logAction = useLogAction({ queryId, sqlEditorId: query.sqlEditorId });
const { showConfirm, ConfirmModal } = useConfirmModal();

View File

@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import * as reactRedux from 'react-redux';
import { act } from 'react';
import { act, type ComponentProps } from 'react';
import {
cleanup,
fireEvent,
@@ -40,6 +39,19 @@ const mockedProps = {
datasource: testQuery,
};
// Render with the SqlLab user fixture preloaded into the mock store so the
// component's useSelector(state => state.user) returns a useful value.
// Previously this test used jest.spyOn(reactRedux, 'useSelector') to inject
// the user directly, which can't intercept calls routed through the typed
// useAppSelector hook.
const renderModal = (
props: Partial<ComponentProps<typeof SaveDatasetModal>> = {},
) =>
render(<SaveDatasetModal {...mockedProps} {...props} />, {
useRedux: true,
initialState: { user },
});
fetchMock.get('glob:*/api/v1/dataset/?*', {
result: mockdatasets,
dataset_count: 3,
@@ -47,17 +59,17 @@ fetchMock.get('glob:*/api/v1/dataset/?*', {
jest.useFakeTimers({ advanceTimers: true });
// Mock the user
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
beforeEach(() => {
useSelectorMock.mockClear();
cleanup();
});
// Mock the createDatasource action
const useDispatchMock = jest.spyOn(reactRedux, 'useDispatch');
// Mock createDatasource to return a thunk that resolves with the dataset's
// new id. The test's mock store includes redux-thunk middleware (from RTK's
// getDefaultMiddleware), so dispatch(createDatasource(...)) properly unwraps
// the thunk and the production code's .then((data) => clearDatasetCache(data.id))
// chain receives `{ id: 123 }`. Individual tests can override per-call as needed.
jest.mock('src/SqlLab/actions/sqlLab', () => ({
createDatasource: jest.fn(),
createDatasource: jest.fn(() => () => Promise.resolve({ id: 123 })),
}));
jest.mock('src/explore/exploreUtils/formData', () => ({
postFormData: jest.fn(),
@@ -70,7 +82,7 @@ jest.mock('src/utils/cachedSupersetGet', () => ({
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('SaveDatasetModal', () => {
test('renders a "Save as new" field', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
const saveRadioBtn = screen.getByRole('radio', {
name: /save as new/i,
@@ -87,7 +99,7 @@ describe('SaveDatasetModal', () => {
});
test('renders an "Overwrite existing" field', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
const overwriteRadioBtn = screen.getByRole('radio', {
name: /overwrite existing/i,
@@ -103,20 +115,20 @@ describe('SaveDatasetModal', () => {
});
test('renders a close button', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
});
test('renders a save button when "Save as new" is selected', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
// "Save as new" is selected when the modal opens by default
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
test('renders an overwrite button when "Overwrite existing" is selected', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
// Click the overwrite radio button to reveal the overwrite confirmation and back buttons
const overwriteRadioBtn = screen.getByRole('radio', {
@@ -130,8 +142,7 @@ describe('SaveDatasetModal', () => {
});
test('renders the overwrite button as disabled until an existing dataset is selected', async () => {
useSelectorMock.mockReturnValue({ ...user });
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
// Click the overwrite radio button
const overwriteRadioBtn = screen.getByRole('radio', {
@@ -168,8 +179,7 @@ describe('SaveDatasetModal', () => {
});
test('renders a confirm overwrite screen when overwrite is clicked', async () => {
useSelectorMock.mockReturnValue({ ...user });
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
// Click the overwrite radio button
const overwriteRadioBtn = screen.getByRole('radio', {
@@ -215,11 +225,7 @@ describe('SaveDatasetModal', () => {
});
test('sends the schema when creating the dataset', async () => {
const dummyDispatch = jest.fn().mockResolvedValue({});
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue({ ...user });
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
@@ -240,17 +246,9 @@ describe('SaveDatasetModal', () => {
});
test('sends the catalog when creating the dataset', async () => {
const dummyDispatch = jest.fn().mockResolvedValue({});
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue({ ...user });
render(
<SaveDatasetModal
{...mockedProps}
datasource={{ ...mockedProps.datasource, catalog: 'public' }}
/>,
{ useRedux: true },
);
renderModal({
datasource: { ...mockedProps.datasource, catalog: 'public' },
});
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
@@ -271,7 +269,7 @@ describe('SaveDatasetModal', () => {
});
test('does not renders a checkbox button when template processing is disabled', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
});
@@ -280,7 +278,7 @@ describe('SaveDatasetModal', () => {
global.featureFlags = {
[FeatureFlag.EnableTemplateProcessing]: true,
};
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
expect(screen.getByRole('checkbox')).toBeInTheDocument();
});
@@ -289,15 +287,11 @@ describe('SaveDatasetModal', () => {
global.featureFlags = {
[FeatureFlag.EnableTemplateProcessing]: true,
};
const propsWithTemplateParam = {
...mockedProps,
renderModal({
datasource: {
...testQuery,
templateParams: JSON.stringify({ my_param: 12 }),
},
};
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
useRedux: true,
});
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
@@ -324,15 +318,11 @@ describe('SaveDatasetModal', () => {
global.featureFlags = {
[FeatureFlag.EnableTemplateProcessing]: true,
};
const propsWithTemplateParam = {
...mockedProps,
renderModal({
datasource: {
...testQuery,
templateParams: JSON.stringify({ my_param: 12 }),
},
};
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
useRedux: true,
});
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });
@@ -393,19 +383,11 @@ describe('SaveDatasetModal', () => {
.spyOn(SupersetClient, 'put')
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
const dummyDispatch = jest.fn().mockResolvedValue({});
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue({ ...user });
const propsWithTemplateParam = {
...mockedProps,
renderModal({
datasource: {
...testQuery,
templateParams: JSON.stringify({ my_param: 12, _filters: 'foo' }),
},
};
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
useRedux: true,
});
// Check the "Include Template Parameters" checkbox
@@ -443,19 +425,11 @@ describe('SaveDatasetModal', () => {
.spyOn(SupersetClient, 'put')
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
const dummyDispatch = jest.fn().mockResolvedValue({});
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue({ ...user });
const propsWithTemplateParam = {
...mockedProps,
renderModal({
datasource: {
...testQuery,
templateParams: JSON.stringify({ my_param: 12 }),
},
};
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
useRedux: true,
});
// Do NOT check the "Include Template Parameters" checkbox
@@ -489,12 +463,9 @@ describe('SaveDatasetModal', () => {
'postFormData',
);
const dummyDispatch = jest.fn().mockResolvedValue({ id: 123 });
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue({ ...user });
postFormData.mockResolvedValue('chart_key_123');
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
renderModal();
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
fireEvent.change(inputFieldText, { target: { value: 'my dataset' } });

View File

@@ -34,7 +34,6 @@ import { t } from '@apache-superset/core/translation';
import {
SupersetClient,
JsonResponse,
JsonObject,
QueryResponse,
QueryFormData,
VizType,
@@ -44,7 +43,8 @@ import {
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { extendedDayjs as dayjs } from '@superset-ui/core/utils/dates';
import { useSelector, useDispatch } from 'react-redux';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import rison from 'rison';
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
import { addDangerToast } from 'src/components/MessageToasts/actions';
@@ -241,7 +241,7 @@ export const SaveDatasetModal = ({
const [loading, setLoading] = useState<boolean>(false);
const user = useSelector<SqlLabRootState, User>(state => state.user);
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
const dispatch = useAppDispatch();
const [includeTemplateParameters, setIncludeTemplateParameters] =
useState(false);

View File

@@ -17,7 +17,8 @@
* under the License.
*/
import { createRef, useCallback, useMemo } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { nanoid } from 'nanoid';
import Tabs from '@superset-ui/core/components/Tabs';
import { t } from '@apache-superset/core/translation';
@@ -105,7 +106,7 @@ const SouthPane = ({
const { id, tabViewId } = useQueryEditor(queryEditorId, ['tabViewId']);
const editorId = tabViewId ?? id;
const theme = useTheme();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const viewItems = views.getViews(ViewLocations.sqllab.panels) || [];
const { offline, tables } = useSelector(
({ sqlLab: { offline, tables } }: SqlLabRootState) => ({

View File

@@ -30,7 +30,8 @@ import {
import type { editors } from '@apache-superset/core';
import useEffectEvent from 'src/hooks/useEffectEvent';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import AutoSizer from 'react-virtualized-auto-sizer';
import { t } from '@apache-superset/core/translation';
import {
@@ -237,7 +238,7 @@ const SqlEditor: FC<Props> = ({
scheduleQueryWarning,
}) => {
const theme = useTheme();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const { database, latestQuery, currentQueryEditorId, hasSqlStatement } =
useSelector<

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { resetState } from 'src/SqlLab/actions/sqlLab';
import {
@@ -69,7 +69,7 @@ const SqlEditorLeftBar = ({ queryEditorId }: SqlEditorLeftBarProps) => {
const { db, catalog, schema, onDbChange, onCatalogChange, onSchemaChange } =
dbSelectorProps;
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const shouldShowReset = window.location.search === '?reset=1';
// Modal state for Database/Catalog/Schema selector

View File

@@ -19,7 +19,8 @@
import { useMemo, FC } from 'react';
import { bindActionCreators } from 'redux';
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { useSelector, shallowEqual } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { MenuDotsDropdown } from '@superset-ui/core/components';
import { Menu, MenuItemType } from '@superset-ui/core/components/Menu';
import { t } from '@apache-superset/core/translation';
@@ -90,7 +91,7 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
);
const StatusIcon = queryState ? STATE_ICONS[queryState] : STATE_ICONS.running;
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const actions = useMemo(
() =>
bindActionCreators(

View File

@@ -17,7 +17,8 @@
* under the License.
*/
import { useEffect, useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { SqlLabRootState } from 'src/SqlLab/types';
import {
@@ -41,7 +42,7 @@ export default function useDatabaseSelector(queryEditorId: string) {
SqlLabRootState,
SqlLabRootState['sqlLab']['databases']
>(({ sqlLab }) => sqlLab.databases);
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const queryEditor = useQueryEditor(queryEditorId, [
'dbId',
'catalog',

View File

@@ -17,7 +17,8 @@
* under the License.
*/
import { useState, useRef, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import type { QueryEditor, SqlLabRootState, Table } from 'src/SqlLab/types';
import {
ButtonGroup,
@@ -75,7 +76,7 @@ const Fade = styled.div`
const TableElement = ({ table, ...props }: TableElementProps) => {
const { dbId, catalog, schema, name, expanded, id } = table;
const theme = useTheme();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const {
currentData: tableMetadata,
isSuccess: isMetadataSuccess,

View File

@@ -25,7 +25,8 @@ import {
type ChangeEvent,
useMemo,
} from 'react';
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { useSelector, shallowEqual } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { styled, css, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import AutoSizer from 'react-virtualized-auto-sizer';
@@ -163,7 +164,7 @@ const savePinnedSchemasToStorage = (
};
const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const theme = useTheme();
const treeRef = useRef<TreeApi<TreeNodeData>>(null);
const tables = useSelector(

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { useMemo, useReducer, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { t } from '@apache-superset/core/translation';
import {
Table,
@@ -130,7 +130,7 @@ const useTreeData = ({
catalog,
pinnedTables,
}: UseTreeDataParams): UseTreeDataResult => {
const reduxDispatch = useDispatch();
const reduxDispatch = useAppDispatch();
// Schema data from API
const {
currentData: schemaData,

View File

@@ -17,7 +17,8 @@
* under the License.
*/
import { type FC, useCallback, useMemo, useRef, useState } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { shallowEqual, useSelector } from 'react-redux';
import { useAppDispatch } from 'src/views/store';
import { nanoid } from 'nanoid';
import { t } from '@apache-superset/core/translation';
import { ClientErrorObject, getExtensionsRegistry } from '@superset-ui/core';
@@ -110,7 +111,7 @@ const renderWell = (partitions: TableMetaData['partitions']) => {
};
const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const theme = useTheme();
const [databaseName, backend, disableDataPreview] = useSelector<
SqlLabRootState,

View File

@@ -22,12 +22,13 @@ import {
createListenerMiddleware,
StoreEnhancer,
} from '@reduxjs/toolkit';
import type { AnyAction } from 'redux';
import {
useDispatch,
useSelector,
type TypedUseSelectorHook,
} from 'react-redux';
import thunk from 'redux-thunk';
import thunk, { type ThunkDispatch } from 'redux-thunk';
import { api } from 'src/hooks/apiResources/queryApi';
import messageToastReducer from 'src/components/MessageToasts/reducers';
import charts from 'src/components/Chart/chartReducer';
@@ -188,6 +189,14 @@ export type RootState = ReturnType<typeof store.getState>;
// thunks resolve correctly), and `useAppSelector` infers `RootState` without
// callers having to annotate every selector. Required ahead of the
// react-redux v8+ bump, which tightens dispatch typing — see #39927.
export type AppDispatch = typeof store.dispatch;
//
// AppDispatch is declared as ThunkDispatch & store.dispatch rather than
// `typeof store.dispatch` because Superset annotates getMiddleware as
// ConfigureStoreOptions['middleware'], which erases the middleware tuple type
// and leaves store.dispatch typed as Dispatch<AnyAction>. The intersection
// restores thunk support without requiring a wider refactor of the middleware
// setup.
export type AppDispatch = ThunkDispatch<RootState, undefined, AnyAction> &
typeof store.dispatch;
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View File

@@ -78,15 +78,6 @@ FEATURE_FLAGS = {
WEBDRIVER_BASEURL = "http://0.0.0.0:8081/"
# Enable CORS for embedded dashboard E2E tests (test app on port 9000)
ENABLE_CORS = True
CORS_OPTIONS: dict = {
"origins": [
"http://localhost:9000",
],
"supports_credentials": True,
}
def GET_FEATURE_FLAGS_FUNC(ff): # noqa: N802
ff_copy = copy(ff)
@@ -95,7 +86,6 @@ def GET_FEATURE_FLAGS_FUNC(ff): # noqa: N802
TESTING = True
TALISMAN_ENABLED = False
WTF_CSRF_ENABLED = False
FAB_ROLES = {"TestRole": [["Security", "menu_access"], ["List Users", "menu_access"]]}