Compare commits

...

14 Commits

Author SHA1 Message Date
Michael S. Molina
a1eba0f9a1 chore: Cleanup changes in chat feature branch (#41008) 2026-06-12 16:58:21 -03:00
Enzo Martellucci
568337f370 feat(extensions): add dedicated chat contribution type (#41000) 2026-06-12 20:54:13 +02:00
Enzo Martellucci
f170dc1d9e refactor(chatbot): drop extension settings layer; resolve last-loaded chatbot (#40968) 2026-06-11 13:32:03 +02:00
Enzo Martellucci
09c09f3f6b refactor: rename chatbot registration location 2026-06-10 14:32:52 +02:00
Enzo Martellucci
c65c9523aa refactor(extensions): remove install/lifecycle/dependency machinery (#40916) 2026-06-09 22:20:40 +02:00
Enzo Martellucci
94e0071883 Merge branch 'master' into enxdev/chat-prototype
Bring the chatbot extension feature branch up to date with master. The
chatbot work lives in new paths (superset/extensions/*, the core chatbot
namespace, ChatbotMount, superset-core namespaces) and merged cleanly with
no conflicts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:55:53 +02:00
Daniel Vaz Gaspar
2f71771b56 fix(sqllab): prevent corrupted query state from blocking SQL Lab access (#40580)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-06-09 10:51:45 +01:00
Mehmet Salih Yavuz
d7ddf2023d fix(theme): SDK theme config overrides dashboard-level theme in embedded mode (#40763) 2026-06-09 12:01:57 +03:00
Evan Rusackas
c58408d76c fix(revert 40875): "ci: authenticate Docker Hub pulls for service containers" failed (#40879) 2026-06-09 11:17:59 +07:00
Evan Rusackas
1188cfef1d ci: make Docker-build npm ci resilient to transient network blips (#40874)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-09 08:58:01 +07:00
Evan Rusackas
fb0e7fecaf ci: authenticate Docker Hub pulls for service containers (#40875)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-09 08:57:31 +07:00
Evan Rusackas
3afbb48188 fix(uploads,dao): add zip-safety check to columnar reader and cap DAO page size (#40637)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-08 17:07:57 -07:00
Evan Rusackas
837f41986d fix: reject default guest/async JWT secrets at startup (#40649)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-08 16:53:37 -07:00
Enzo Martellucci
380e70060b feat(extensions): define the superset.chatbot contribution point (#40439) 2026-06-08 22:50:34 +02:00
66 changed files with 3626 additions and 313 deletions

View File

@@ -55,6 +55,13 @@ WORKDIR /app/superset-frontend
RUN mkdir -p /app/superset/static/assets \
/app/superset/translations
# Harden `npm ci` against transient npm-registry network blips (e.g. ECONNRESET),
# which otherwise fail the entire multi-platform image build with no retry.
ENV npm_config_fetch_retries=5 \
npm_config_fetch_retry_mintimeout=20000 \
npm_config_fetch_retry_maxtimeout=120000 \
npm_config_fetch_timeout=600000
# Mount package files and install dependencies if not in dev mode
# NOTE: we mount packages and plugins as they are referenced in package.json as workspaces
# ideally we'd COPY only their package.json. Here npm ci will be cached as long

View File

@@ -44,6 +44,20 @@ The embedded dashboard page now validates the origin of incoming `postMessage` e
Enforcement only applies when the Allowed Domains list is non-empty. If the list is empty (the default), any origin is accepted, so there is no behavior change for embeds that did not configure Allowed Domains.
### Default guest/async JWT secrets are rejected at startup
Superset already refuses to start in production (non-debug, non-testing) when `SECRET_KEY` is left at its built-in default, and when `GUEST_TOKEN_JWT_SECRET` is left at its default while `EMBEDDED_SUPERSET` is enabled. This behavior is extended to `GLOBAL_ASYNC_QUERIES_JWT_SECRET`: if the `GLOBAL_ASYNC_QUERIES` feature flag is enabled and the secret is still the publicly known default (`test-secret-change-me`), Superset logs a clear error and refuses to start.
As with the existing `SECRET_KEY` check, this only fails in production. In debug mode, testing mode, or under the test runner, a warning is logged instead of exiting, so local development is unaffected.
To resolve the error, set a strong random value in `superset_config.py`:
```python
GLOBAL_ASYNC_QUERIES_JWT_SECRET = "<output of: openssl rand -base64 42>"
```
The check is only active when the relevant feature is enabled, so deployments that do not use global async queries (or embedding) are not affected.
### Dataset import validates catalog against the target connection
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.

View File

@@ -18,6 +18,26 @@
"types": "./lib/authentication/index.d.ts",
"default": "./lib/authentication/index.js"
},
"./chat": {
"types": "./lib/chat/index.d.ts",
"default": "./lib/chat/index.js"
},
"./dashboard": {
"types": "./lib/dashboard/index.d.ts",
"default": "./lib/dashboard/index.js"
},
"./dataset": {
"types": "./lib/dataset/index.d.ts",
"default": "./lib/dataset/index.js"
},
"./explore": {
"types": "./lib/explore/index.d.ts",
"default": "./lib/explore/index.js"
},
"./navigation": {
"types": "./lib/navigation/index.d.ts",
"default": "./lib/navigation/index.js"
},
"./commands": {
"types": "./lib/commands/index.d.ts",
"default": "./lib/commands/index.js"

View File

@@ -0,0 +1,185 @@
/**
* 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.
*/
/**
* @fileoverview Chat contribution API for Superset extensions.
*
* Chat is a dedicated contribution type (not a view): an extension registers
* a chat via {@link registerChat} and the host owns where and how it is
* mounted. The host applies singleton resolution — multiple chat extensions
* may register, but exactly one is active at a time.
*
* @example
* ```typescript
* import { chat } from '@apache-superset/core';
*
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* () => <AcmeTrigger />,
* () => <AcmePanel />,
* );
* ```
*/
import { ReactElement } from 'react';
import type { Disposable, Event } from '../common';
export interface Chat {
/** The unique identifier for the chat. */
id: string;
/** The display name of the chat. */
name: string;
/** Optional description of the chat, for display in contribution manifests. */
description?: string;
}
export type ChatMode = 'floating' | 'panel';
/**
* Registers a chat provider. The host applies singleton resolution — only one
* chat is active at a time: the most recently registered chat wins, and
* disposing it restores the previously registered one. Re-registering an id
* replaces that registration in place.
*
* When a registration with a different id takes over the active slot (or the
* active chat is disposed), the host closes the panel first, firing
* {@link onDidClose}; an in-place same-id replacement keeps the open state.
*
* Disposing the returned Disposable unregisters the chat.
*
* @param chat The chat descriptor (id, name).
* @param trigger A function returning the collapsed bubble element. Owned by
* the extension — dynamic state such as unread counts and badges lives here.
* Hidden by the host when in panel mode.
* @param panel A function returning the chat panel element. Mounted by the
* host as a floating overlay in 'floating' mode, or docked at the side of
* the viewport in 'panel' mode (the reference host docks a fixed-width
* overlay at the right edge; hosts may integrate a true layout slot
* instead). Same component in both modes.
* @returns A Disposable that unregisters the chat when disposed.
*
* @example
* ```typescript
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* () => <AcmeTrigger />,
* () => <AcmePanel />,
* );
* ```
*/
export declare function registerChat(
chat: Chat,
trigger: () => ReactElement,
panel: () => ReactElement,
): Disposable;
/**
* Returns the active chat descriptor.
*
* @returns A copy of the active Chat descriptor, or undefined if none is
* registered. Mutating the returned object has no effect on the registry.
*/
export declare function getChat(): Chat | undefined;
/**
* Event fired when a chat is registered.
*/
export declare const onDidRegisterChat: Event<Chat>;
/**
* Event fired when a chat is unregistered.
*/
export declare const onDidUnregisterChat: Event<Chat>;
/**
* Opens the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when no chat is registered or the panel is already open.
*/
export declare function open(): void;
/**
* Closes the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when the panel is not open.
*/
export declare function close(): void;
/**
* Returns whether the active chat's panel is currently open.
*
* @returns True if the chat panel is open.
*/
export declare function isOpen(): boolean;
/**
* Event fired when the chat panel opens, with the descriptor of the chat
* whose panel opened. Listen to this rather than assuming your own chat is
* the one affected — another registration may hold the active slot.
*/
export declare const onDidOpen: Event<Chat>;
/**
* Event fired when the chat panel closes, with the descriptor of the chat
* whose panel closed. Also fired when the host closes the panel itself, e.g.
* because the active chat was disposed or displaced by a different chat.
*/
export declare const onDidClose: Event<Chat>;
/**
* Returns the current display mode.
*
* @returns The current ChatMode.
*/
export declare function getMode(): ChatMode;
/**
* Sets the display mode.
*
* The mode is host-global and applies to whichever chat is active, regardless
* of which extension calls it. Hosts may also change the mode through their
* own controls — use onDidChangeMode to observe all changes rather than
* assuming the last setMode() call won.
*
* @param mode The display mode to switch to.
*/
export declare function setMode(mode: ChatMode): void;
/**
* Event fired when the display mode changes, whether triggered by an
* extension via setMode() or by host-provided controls.
*/
export declare const onDidChangeMode: Event<ChatMode>;
/**
* Event fired when the panel is resized in panel mode.
*
* The host owns the resizer handle and drag interaction; a host without a
* resizer never fires this event. (The reference host mounts the panel at a
* fixed width and does not provide a resizer, so subscribers receive no
* events there.) Listen to this event to adapt internal layout to the
* available width; do not rely on it firing.
*/
export declare const onDidResizePanel: Event<{ width: number }>;
// TODO: client actions API — tool availability functions will be added here
// once the client_actions SIP is finalized. The chat namespace is the
// intended integration point between the two SIPs.

View File

@@ -213,6 +213,47 @@ export declare interface Event<T> {
(listener: (e: T) => any, thisArgs?: any): Disposable;
}
/**
* Context handed to an extension's `activate` function.
*
* `context.subscriptions` is provided for extensions to push their
* {@link Disposable}s into. The host provides the array but does not dispose
* it (lifecycle management is deferred).
*
* @example
* ```typescript
* export function activate(context: ExtensionContext) {
* context.subscriptions.push(
* commands.registerCommand('my_ext.hello', () => {}),
* );
* }
* ```
*/
export interface ExtensionContext {
/**
* Disposables pushed by the extension. Provided for extensions to track
* their own registrations; the host does not dispose them.
*/
subscriptions: { dispose(): void }[];
}
/**
* Shape of an extension's entry module (its `./index`).
*
* Extensions are encouraged to export an `activate(context)` function so that
* their registrations are tracked via `context.subscriptions` regardless of
* whether they run synchronously or asynchronously. For backward compatibility,
* a module may instead register its contributions as top-level side effects when
* the module is evaluated.
*/
export interface ExtensionModule {
/**
* Called by the host once the extension module has loaded. May be async; the
* host awaits it before considering the extension active.
*/
activate?(context: ExtensionContext): void | Promise<void>;
}
/**
* Represents a Superset extension with its metadata.
* Extensions are modular components that can extend Superset's functionality
@@ -223,8 +264,6 @@ export interface Extension {
dependencies: string[];
/** Human-readable description of the extension */
description: string;
/** List of other extensions that this extension depends on */
extensionDependencies: string[];
/** Unique identifier for the extension */
id: string;
/** Human-readable name of the extension */

View File

@@ -23,9 +23,10 @@
* This module defines the aggregate interfaces used by the extension.json
* manifest and the `superset-extensions` build command. Individual metadata
* types are defined in their respective namespace modules (commands, views,
* menus, editors) and re-exported here for the manifest schema.
* menus, editors, chat) and re-exported here for the manifest schema.
*/
import { Chat } from '../chat';
import { Command } from '../commands';
import { View } from '../views';
import { Menu } from '../menus';
@@ -71,7 +72,8 @@ export interface MenuContributions {
}
/**
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
* Aggregates all contributions (commands, menus, views, editors, and chat)
* provided by an extension or module.
*/
export interface Contributions {
/** List of commands. */
@@ -82,4 +84,10 @@ export interface Contributions {
views: ViewContributions;
/** List of editors. */
editors?: Editor[];
/**
* The chat contributed by the extension — at most one per extension, since
* the host applies singleton resolution and renders exactly one active
* chat at a time.
*/
chat?: Chat;
}

View File

@@ -0,0 +1,114 @@
/**
* 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.
*/
/**
* @fileoverview Dashboard namespace for Superset extensions (P3).
*
* Exposes dashboard identity and filter state as a stable semantic API.
* Extensions must not depend on the Redux dashboard slice structure directly.
*/
import { Event } from '../common';
/**
* A single native filter's current selected value(s).
* The value type is intentionally kept as `unknown` because filter values
* are heterogeneous (date ranges, string lists, numbers, etc.).
*/
export interface FilterValue {
/** The filter's stable id. */
filterId: string;
/** Display label of the filter. */
label: string;
/** Currently applied value, or `null` when the filter is cleared. */
value: unknown;
}
/**
* Summary of a single chart on the active dashboard.
*
* Exposes the identity, viz type, datasource, and current visibility of a
* chart so extensions can answer both "which charts are visible?" and
* "find the chart named X" without additional lookups.
*/
export interface ChartSummary {
/** Numeric chart (slice) id. */
chartId: number;
/** Display name of the chart. */
chartName: string;
/** Visualization type key (e.g. `'echarts_timeseries_bar'`). */
vizType: string;
/** Datasource id, or `null` when not resolvable. */
datasourceId: number | null;
/** Datasource name, or `null` when not resolvable. */
datasourceName: string | null;
/** Whether the chart is currently visible (e.g. on the active tab). */
isVisible: boolean;
}
/**
* Normalized dashboard context exposed to extensions on the Dashboard page.
*/
export interface DashboardContext {
/** Numeric dashboard id. */
dashboardId: number;
/** Display title of the dashboard. */
title: string;
/**
* Active native filter values keyed by filter id.
* Only includes filters that have a value applied.
*/
filters: FilterValue[];
/**
* Summaries of the dashboard's charts, including per-chart visibility.
*
* Optional: the contract is declared so extensions can compile against the
* stable shape, but population is delivered in a later phase (see
* CHATBOT_SIP.md §10/§11). The host returns an empty array until then.
*/
charts?: ChartSummary[];
}
/**
* Returns the normalized dashboard context for the page currently being viewed,
* or `undefined` when the user is not on a Dashboard page.
*
* @example
* ```typescript
* const dash = dashboard.getCurrentDashboard();
* if (dash) {
* console.log(dash.title, dash.filters);
* }
* ```
*/
export declare function getCurrentDashboard(): DashboardContext | undefined;
/**
* Event fired when the dashboard identity or its active filter values change.
* Fired on native filter value changes and on navigation to a different dashboard.
*
* @example
* ```typescript
* const sub = dashboard.onDidChangeDashboard(dash => {
* chatbot.updateContext({ dashboard: dash });
* });
* sub.dispose();
* ```
*/
export declare const onDidChangeDashboard: Event<DashboardContext>;

View File

@@ -0,0 +1,73 @@
/**
* 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.
*/
/**
* @fileoverview Dataset namespace for Superset extensions (P3).
*
* Exposes the dataset currently being viewed as a stable semantic API.
* Aligned with backend-enforced dataset visibility and column-access semantics.
*/
import { Event } from '../common';
/**
* Normalized dataset context exposed to extensions on the Dataset page.
*/
export interface DatasetContext {
/** Numeric dataset id. */
datasetId: number;
/** Display name (table name or virtual dataset name). */
datasetName: string;
/** Schema the dataset belongs to, if applicable. */
schema: string | null;
/** Catalog the dataset belongs to, if applicable. */
catalog: string | null;
/** Database name backing this dataset. */
databaseName: string | null;
/** Whether this is a virtual (SQL-defined) dataset. */
isVirtual: boolean;
}
/**
* Returns the normalized dataset context for the page currently being viewed,
* or `undefined` when the user is not on a Dataset page.
*
* @example
* ```typescript
* const ds = dataset.getCurrentDataset();
* if (ds) {
* console.log(ds.datasetName, ds.schema);
* }
* ```
*/
export declare function getCurrentDataset(): DatasetContext | undefined;
/**
* Event fired when the focused dataset changes (e.g. the user navigates to a
* different dataset detail page).
*
* @example
* ```typescript
* const sub = dataset.onDidChangeDataset(ds => {
* chatbot.updateContext({ dataset: ds });
* });
* sub.dispose();
* ```
*/
export declare const onDidChangeDataset: Event<DatasetContext>;

View File

@@ -0,0 +1,75 @@
/**
* 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.
*/
/**
* @fileoverview Explore namespace for Superset extensions (P3).
*
* Exposes the current chart/explore context as a stable semantic API.
* Normalized over Explore Redux state — extensions must not depend on
* the Redux slice structure directly.
*/
import { Event } from '../common';
/**
* Normalized chart context exposed to extensions during an Explore session.
* Covers saved chart identity and transient editing context; excludes raw
* form-data internals and datasource-implementation details.
*/
export interface ChartContext {
/** The saved chart id, or `null` when the chart has not been persisted. */
chartId: number | null;
/** Display name of the saved chart, or `null` for a new/unsaved chart. */
chartName: string | null;
/** The visualization type currently selected in the editor. */
vizType: string;
/** Id of the datasource backing the chart (physical or virtual dataset). */
datasourceId: number | null;
/** Human-readable datasource name. */
datasourceName: string | null;
}
/**
* Returns the normalized chart context for the active Explore session, or
* `undefined` when the user is not on the Explore page.
*
* @example
* ```typescript
* const chart = explore.getCurrentChart();
* if (chart) {
* console.log(chart.vizType, chart.chartName);
* }
* ```
*/
export declare function getCurrentChart(): ChartContext | undefined;
/**
* Event fired when the chart context changes within the active Explore session
* (e.g. when the viz type, datasource, or saved name changes).
* Not fired during route changes — subscribe to `navigation.onDidChangePage` for those.
*
* @example
* ```typescript
* const sub = explore.onDidChangeChart(chart => {
* chatbot.updateContext({ chart });
* });
* sub.dispose();
* ```
*/
export declare const onDidChangeChart: Event<ChartContext>;

View File

@@ -18,10 +18,15 @@
*/
export * as common from './common';
export * as authentication from './authentication';
export * as chat from './chat';
export * as commands from './commands';
export * as dashboard from './dashboard';
export * as dataset from './dataset';
export * as editors from './editors';
export * as explore from './explore';
export * as extensions from './extensions';
export * as menus from './menus';
export * as navigation from './navigation';
export * as sqlLab from './sqlLab';
export * as views from './views';
export * as contributions from './contributions';

View File

@@ -0,0 +1,84 @@
/**
* 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.
*/
/**
* @fileoverview Navigation namespace for Superset extensions (P3).
*
* Exposes the current application surface so extensions can react to route
* changes without polling. Entity-level context (chart, dashboard, dataset)
* is intentionally not included here — use the surface-specific namespace
* (`explore`, `dashboard`, `dataset`) to retrieve entity payloads.
*/
import { Event } from '../common';
/**
* The set of top-level application surfaces.
*
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
* editing/viewing surfaces where `explore.getCurrentChart()` /
* `dashboard.getCurrentDashboard()` / `dataset.getCurrentDataset()` resolve to a
* concrete entity. `'chart_list'`, `'dashboard_list'` and `'dataset_list'` are
* the browse/list surfaces, distinct from those because no single entity is
* active. `'sqllab'` is the SQL editor where `sqlLab.getCurrentTab()` resolves;
* `'query_history'` and `'saved_queries'` are the related SQL Lab browse pages,
* which are not the editor. `'other'` covers any route not explicitly enumerated.
*/
export type PageType =
| 'dashboard'
| 'dashboard_list'
| 'explore'
| 'chart_list'
| 'sqllab'
| 'query_history'
| 'saved_queries'
| 'dataset'
| 'dataset_list'
| 'home'
| 'other';
/**
* Returns the current page surface type.
*
* @example
* ```typescript
* const pageType = navigation.getPageType();
* if (pageType === 'dashboard') {
* const ctx = dashboard.getCurrentDashboard();
* }
* ```
*/
export declare function getPageType(): PageType;
/**
* Event fired whenever the user navigates to a different surface.
* Use the surface-specific namespace to read entity context after the event.
*
* @example
* ```typescript
* const sub = navigation.onDidChangePage(pageType => {
* if (pageType === 'dashboard') {
* const ctx = dashboard.getCurrentDashboard();
* }
* });
* // later:
* sub.dispose();
* ```
*/
export declare const onDidChangePage: Event<PageType>;

View File

@@ -508,6 +508,12 @@ export interface ThemeContextType {
clearLocalOverrides: () => void;
getCurrentCrudThemeId: () => string | null;
hasDevOverride: () => boolean;
/**
* True when an explicit theme config override is active (e.g. supplied via
* the Embedded SDK). Such an override takes precedence over a
* dashboard-level theme.
*/
hasThemeConfigOverride: boolean;
canSetMode: () => boolean;
canSetTheme: () => boolean;
canDetectOSPreference: () => boolean;

View File

@@ -48,6 +48,12 @@ export interface View {
name: string;
/** Optional description of the view, for display in contribution manifests. */
description?: string;
/**
* Optional icon identifier for the view, used in admin pickers and manifest
* listings. Static — set once at registerView() time.
* Dynamic icon states (e.g. notification badge) are the extension's concern.
*/
icon?: string;
}
/**
@@ -56,12 +62,12 @@ export interface View {
* The view provider function is called when the UI renders the location,
* and should return a React element to display.
*
* @param view The view descriptor (id and name).
* @param view The view descriptor (id, name, and optional icon/description).
* @param location The location where this view should appear (e.g. "sqllab.panels").
* @param provider A function that returns the React element to render.
* @returns A Disposable that unregisters the view when disposed.
*
* @example
* @example SQL Lab panel
* ```typescript
* views.registerView(
* { id: 'my_ext.result_stats', name: 'Result Stats' },

View File

@@ -25,6 +25,7 @@ import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { Logger } from 'src/logger/LogUtils';
import { EmptyState, Tooltip } from '@superset-ui/core/components';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import { detectOS } from 'src/utils/common';
import * as Actions from 'src/SqlLab/actions/sqlLab';
import { Icons } from '@superset-ui/core/components/Icons';
@@ -176,14 +177,16 @@ class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
key: qe.id,
label: <SqlEditorTabHeader queryEditor={qe} />,
children: (
<SqlEditor
queryEditor={qe}
defaultQueryLimit={this.props.defaultQueryLimit}
maxRow={this.props.maxRow}
displayLimit={this.props.displayLimit}
saveQueryWarning={this.props.saveQueryWarning}
scheduleQueryWarning={this.props.scheduleQueryWarning}
/>
<ErrorBoundary>
<SqlEditor
queryEditor={qe}
defaultQueryLimit={this.props.defaultQueryLimit}
maxRow={this.props.maxRow}
displayLimit={this.props.displayLimit}
saveQueryWarning={this.props.saveQueryWarning}
scheduleQueryWarning={this.props.scheduleQueryWarning}
/>
</ErrorBoundary>
),
}));

View File

@@ -0,0 +1,287 @@
/**
* 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 { act, render, screen } from 'spec/helpers/testing-library';
import { chat } from 'src/core/chat';
import ChatMount from '.';
const disposables: Array<{ dispose: () => void }> = [];
afterEach(() => {
act(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
// Reset host-owned state shared across tests in this module.
chat.close();
chat.setMode('floating');
});
});
test('renders nothing when no chat extension is registered', () => {
render(<ChatMount />);
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
});
test('renders the trigger bubble of the registered chat', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
// The panel stays unmounted until the chat is opened.
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
});
test('mounts the panel when the chat opens and unmounts it on close', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
act(() => chat.open());
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
// In floating mode the trigger stays mounted alongside the open panel.
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
act(() => chat.close());
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
});
test('renders the last-registered chat when several are installed', () => {
disposables.push(
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <div>First Bubble</div>,
() => <div>First Panel</div>,
),
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
),
);
render(<ChatMount />);
// Last-loaded wins: the second registration takes over the singleton slot.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
expect(screen.queryByText('First Bubble')).not.toBeInTheDocument();
});
test('reacts to a chat registering after the initial render', () => {
render(<ChatMount />);
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
});
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('a takeover mounts the incoming chat closed', () => {
disposables.push(
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <div>First Bubble</div>,
() => <div>First Panel</div>,
),
);
render(<ChatMount />);
act(() => chat.open());
expect(screen.getByText('First Panel')).toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
),
);
});
// The displaced chat's open state must not leak into the winner.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
expect(screen.queryByText('Second Panel')).not.toBeInTheDocument();
expect(screen.queryByText('First Panel')).not.toBeInTheDocument();
});
test('panel mode docks the open panel and hides the trigger', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
act(() => {
chat.setMode('panel');
chat.open();
});
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
expect(screen.queryByText('Acme Bubble')).not.toBeInTheDocument();
act(() => chat.close());
// A closed chat in panel mode renders nothing — the trigger is hidden too.
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
});
test('a crashing panel does not take the trigger down with it', () => {
const FailingPanel = () => {
throw new Error('panel blew up');
};
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <FailingPanel />,
),
);
render(<ChatMount />);
act(() => chat.open());
// The panel's boundary contains the crash; the trigger keeps rendering so
// the user is not stranded without a way back.
expect(screen.queryByText('panel blew up')).not.toBeInTheDocument();
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('isolates a failing trigger so it does not crash the host', () => {
const FailingTrigger = () => {
throw new Error('chat blew up');
};
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <FailingTrigger />,
() => <div>Acme Panel</div>,
),
);
// The host-owned error boundary catches the failure; render does not throw.
expect(() => render(<ChatMount />)).not.toThrow();
// The mount slot still renders (the boundary lives inside it), confirming
// the provider was actually exercised and contained.
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
});
test('isolates a chat whose provider function itself throws', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => {
throw new Error('provider blew up');
},
() => <div>Acme Panel</div>,
),
);
// ChatRenderer wraps provider() in a component so ErrorBoundary catches
// synchronous throws from the provider function, not just from its output.
expect(() => render(<ChatMount />)).not.toThrow();
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
});
test('recovers from a crashed chat when a different chat takes over', () => {
const FailingTrigger = () => {
throw new Error('first chat blew up');
};
disposables.push(
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <FailingTrigger />,
() => <div>First Panel</div>,
),
);
render(<ChatMount />);
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
),
);
});
// The boundary is keyed per registration, so the latched crash from the
// first chat does not blank the second one.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
});
test('recovers when a crashed chat re-registers a fixed version under the same id', () => {
const FailingTrigger = () => {
throw new Error('broken release');
};
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <FailingTrigger />,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
expect(screen.queryByText('Fixed Bubble')).not.toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <div>Fixed Bubble</div>,
() => <div>Acme Panel</div>,
),
);
});
// Same id, new registrationId: the remounted boundary renders the fix.
expect(screen.getByText('Fixed Bubble')).toBeInTheDocument();
});

View File

@@ -0,0 +1,149 @@
/**
* 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 { type ReactElement, useRef, useSyncExternalStore } from 'react';
import { t } from '@apache-superset/core/translation';
import { logging } from '@apache-superset/core/utils';
import { css, useTheme } from '@apache-superset/core/theme';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { store } from 'src/views/store';
import { getChatSnapshot, subscribeToChatState } from 'src/core/chat';
const CHAT_EDGE_MARGIN = 24;
const PANEL_MODE_WIDTH = 400;
/**
* Wraps a chat provider in a React component so that ErrorBoundary can catch
* synchronous throws from the provider function itself. Calling `provider()`
* inline (e.g. `{activeChat.panel()}`) would throw outside React's render
* boundary and crash the host.
*/
const ChatRenderer = ({ provider }: { provider: () => ReactElement }) =>
provider();
const ChatMount = () => {
const theme = useTheme();
// Notify at most once per registration; a crash can re-render and would
// otherwise re-toast, while a replacement (new registrationId) deserves a
// fresh notification if it crashes too.
const crashNotifiedFor = useRef<number | null>(null);
// The active chat, the open state, and the display mode are read from one
// immutable registry snapshot so a render never mixes state from two
// different store versions (the tearing useSyncExternalStore prevents).
const {
open: panelOpen,
mode,
active,
} = useSyncExternalStore(subscribeToChatState, getChatSnapshot);
if (!active) {
return null;
}
const { registrationId } = active;
const onProviderError = (error: Error) => {
// Fault isolation: contain the crash, log it, surface a one-time
// notification, and leave the slot empty rather than parking a
// persistent error card.
logging.error('[chat] provider crashed', error);
if (crashNotifiedFor.current !== registrationId) {
crashNotifiedFor.current = registrationId;
store.dispatch(addDangerToast(t('The chat failed to load.')));
}
};
if (mode === 'panel') {
// Panel mode hides the trigger and docks the panel to the right edge.
// Interim approximation of the "layout slot between header and footer"
// from the chat API contract — the dock overlays the page until the host
// grows a real layout slot and resizer chrome.
if (!panelOpen) {
return null;
}
return (
<div
data-test="chat-mount"
css={css`
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: ${PANEL_MODE_WIDTH}px;
background: ${theme.colorBgContainer};
box-shadow: ${theme.boxShadow};
/* Above dashboard content and the toast layer, below modal dialogs. */
z-index: ${theme.zIndexPopupBase + 2};
`}
>
<ErrorBoundary
key={registrationId}
showMessage={false}
onError={onProviderError}
>
<ChatRenderer provider={active.panel} />
</ErrorBoundary>
</div>
);
}
return (
<div
data-test="chat-mount"
css={css`
position: fixed;
right: ${CHAT_EDGE_MARGIN}px;
bottom: ${CHAT_EDGE_MARGIN}px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: ${theme.sizeUnit * 2}px;
/* Above dashboard content and the toast layer, below modal dialogs. */
z-index: ${theme.zIndexPopupBase + 2};
`}
>
{/*
Each provider gets its own boundary so a crashing panel cannot take
the trigger down with it (the trigger is the user's only way back).
Keyed by registrationId: Superset's ErrorBoundary latches its error
state, so a takeover, fallback, or same-id re-registration must
remount the boundary to recover.
*/}
{panelOpen && (
<ErrorBoundary
key={`panel-${registrationId}`}
showMessage={false}
onError={onProviderError}
>
<ChatRenderer provider={active.panel} />
</ErrorBoundary>
)}
<ErrorBoundary
key={`trigger-${registrationId}`}
showMessage={false}
onError={onProviderError}
>
<ChatRenderer provider={active.trigger} />
</ErrorBoundary>
</div>
);
};
export default ChatMount;

View File

@@ -25,6 +25,8 @@ import {
isThemeConfigDark,
} from '@apache-superset/core/theme';
import getBootstrapData from 'src/utils/getBootstrapData';
import { ThemeContext } from 'src/theme/ThemeProvider';
import type { ThemeContextType } from '@apache-superset/core/theme';
import CrudThemeProvider from './CrudThemeProvider';
jest.mock('@apache-superset/core/theme', () => ({
@@ -307,6 +309,59 @@ test('ignores non-array fontUrls in theme config without throwing', () => {
expect(fontStyle).toBeNull();
});
test('skips the dashboard theme when an SDK theme config override is active', () => {
const themeConfig = {
token: {
colorPrimary: '#ff0000',
fontUrls: ['https://fonts.example.com/dashboard.css'],
},
};
render(
<ThemeContext.Provider
value={{ hasThemeConfigOverride: true } as unknown as ThemeContextType}
>
<CrudThemeProvider
theme={{
id: 1,
theme_name: 'Custom Theme',
json_data: JSON.stringify(themeConfig),
}}
>
<div>Dashboard Content</div>
</CrudThemeProvider>
</ThemeContext.Provider>,
);
// The SDK override wins: the dashboard theme provider must not wrap children.
expect(screen.getByText('Dashboard Content')).toBeInTheDocument();
expect(
screen.queryByTestId('dashboard-theme-provider'),
).not.toBeInTheDocument();
// The override fully owns theming, so dashboard fonts must not be injected.
expect(document.querySelector('style[data-superset-fonts]')).toBeNull();
});
test('applies the dashboard theme when no SDK theme config override is active', () => {
const themeConfig = { token: { colorPrimary: '#ff0000' } };
render(
<ThemeContext.Provider
value={{ hasThemeConfigOverride: false } as unknown as ThemeContextType}
>
<CrudThemeProvider
theme={{
id: 1,
theme_name: 'Custom Theme',
json_data: JSON.stringify(themeConfig),
}}
>
<div>Dashboard Content</div>
</CrudThemeProvider>
</ThemeContext.Provider>,
);
expect(screen.getByTestId('dashboard-theme-provider')).toBeInTheDocument();
});
test('does not inject font style element when no fontUrls in config', () => {
const themeConfig = { token: { colorPrimary: '#ff0000' } };
render(

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode, useEffect, useMemo } from 'react';
import { ReactNode, useContext, useEffect, useMemo } from 'react';
import { logging } from '@apache-superset/core/utils';
import {
Theme,
@@ -24,6 +24,7 @@ import {
isThemeConfigDark,
} from '@apache-superset/core/theme';
import getBootstrapData from 'src/utils/getBootstrapData';
import { ThemeContext } from 'src/theme/ThemeProvider';
import type { Dashboard } from 'src/types/Dashboard';
interface CrudThemeProviderProps {
@@ -41,8 +42,18 @@ export default function CrudThemeProvider({
children,
theme,
}: CrudThemeProviderProps) {
// An explicit theme config override (e.g. supplied via the Embedded SDK)
// applies on the global theme controller and must win over the
// dashboard-level theme. When such an override is active, skip the
// dashboard theme so the override is not shadowed by this nested provider.
const themeContext = useContext(ThemeContext);
const hasThemeConfigOverride = themeContext?.hasThemeConfigOverride ?? false;
const { dashboardTheme, fontUrls } = useMemo(() => {
if (!theme?.json_data) {
// When an SDK override is active it fully owns theming, so skip parsing the
// dashboard theme entirely. This also prevents the font-injection effect
// below from loading dashboard fonts the override does not use.
if (hasThemeConfigOverride || !theme?.json_data) {
return { dashboardTheme: null, fontUrls: undefined };
}
try {
@@ -64,7 +75,7 @@ export default function CrudThemeProvider({
logging.warn('Failed to load dashboard theme:', error);
return { dashboardTheme: null, fontUrls: undefined };
}
}, [theme?.json_data]);
}, [theme?.json_data, hasThemeConfigOverride]);
useEffect(() => {
if (!dashboardTheme || !fontUrls?.length) return undefined;
@@ -83,7 +94,7 @@ export default function CrudThemeProvider({
};
}, [dashboardTheme, fontUrls]);
if (!dashboardTheme) {
if (!dashboardTheme || hasThemeConfigOverride) {
return <>{children}</>;
}

View File

@@ -0,0 +1,331 @@
/**
* 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 { createElement } from 'react';
import { chat, getActiveChat, getChatSnapshot } from './index';
const disposables: Array<{ dispose: () => void }> = [];
const trigger = () => createElement('button', null, 'Bubble');
const panel = () => createElement('div', null, 'Panel');
afterEach(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
// Reset host-owned state shared across tests in this module.
chat.close();
chat.setMode('floating');
});
test('getChat returns undefined when no chat is registered', () => {
expect(chat.getChat()).toBeUndefined();
expect(getActiveChat()).toBeUndefined();
});
test('registerChat resolves the registered chat with its providers', () => {
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
disposables.push(chat.registerChat(descriptor, trigger, panel));
expect(chat.getChat()).toEqual(descriptor);
expect(getActiveChat()).toMatchObject({ chat: descriptor, trigger, panel });
});
test('getChat returns a copy that cannot mutate the registry', () => {
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme Chat' }, trigger, panel),
);
const copy = chat.getChat();
copy!.name = 'Hijacked';
expect(chat.getChat()?.name).toBe('Acme Chat');
});
test('the last-registered chat wins when multiple are installed', () => {
disposables.push(
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
);
expect(chat.getChat()?.id).toBe('second.chat');
});
test('disposing the active chat falls back to the previous registration', () => {
disposables.push(
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
);
const second = chat.registerChat(
{ id: 'second.chat', name: 'Second' },
trigger,
panel,
);
expect(chat.getChat()?.id).toBe('second.chat');
second.dispose();
expect(chat.getChat()?.id).toBe('first.chat');
});
test('re-registering an id replaces the previous registration', () => {
const stalePanel = () => createElement('div', null, 'Stale');
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, stalePanel),
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
);
expect(chat.getChat()?.name).toBe('Acme v2');
expect(getActiveChat()?.panel).toBe(panel);
});
test('each registration gets a distinct registrationId, including same-id replacements', () => {
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
const first = getActiveChat()?.registrationId;
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
);
const second = getActiveChat()?.registrationId;
expect(first).toBeDefined();
expect(second).toBeDefined();
expect(second).not.toBe(first);
});
test('disposing a registration twice unregisters only once', () => {
const unregistered = jest.fn();
disposables.push(chat.onDidUnregisterChat(unregistered));
const registration = chat.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
registration.dispose();
registration.dispose();
expect(unregistered).toHaveBeenCalledTimes(1);
expect(chat.getChat()).toBeUndefined();
});
test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => {
const registered = jest.fn();
const unregistered = jest.fn();
disposables.push(
chat.onDidRegisterChat(registered),
chat.onDidUnregisterChat(unregistered),
);
const descriptor = { id: 'acme.chat', name: 'Acme' };
const registration = chat.registerChat(descriptor, trigger, panel);
expect(registered).toHaveBeenCalledWith(descriptor);
expect(unregistered).not.toHaveBeenCalled();
registration.dispose();
expect(unregistered).toHaveBeenCalledWith(descriptor);
});
test('a disposed event subscription stops receiving notifications', () => {
const registered = jest.fn();
const subscription = chat.onDidRegisterChat(registered);
subscription.dispose();
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
expect(registered).not.toHaveBeenCalled();
});
test('open and close toggle the panel and fire once with the active descriptor', () => {
const opened = jest.fn();
const closed = jest.fn();
disposables.push(chat.onDidOpen(opened), chat.onDidClose(closed));
const descriptor = { id: 'acme.chat', name: 'Acme' };
disposables.push(chat.registerChat(descriptor, trigger, panel));
expect(chat.isOpen()).toBe(false);
chat.open();
// Opening an already-open panel is a no-op and must not re-fire.
chat.open();
expect(chat.isOpen()).toBe(true);
expect(opened).toHaveBeenCalledTimes(1);
expect(opened).toHaveBeenCalledWith(descriptor);
chat.close();
chat.close();
expect(chat.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
expect(closed).toHaveBeenCalledWith(descriptor);
});
test('open is a no-op while no chat is registered', () => {
const opened = jest.fn();
disposables.push(chat.onDidOpen(opened));
chat.open();
expect(chat.isOpen()).toBe(false);
expect(opened).not.toHaveBeenCalled();
// A registration arriving later therefore starts closed.
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
expect(chat.isOpen()).toBe(false);
});
test('a takeover by a different id closes the displaced chat panel', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
const first = { id: 'first.chat', name: 'First' };
disposables.push(chat.registerChat(first, trigger, panel));
chat.open();
disposables.push(
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
);
// The incoming chat must not mount into an open state it never requested.
expect(chat.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
expect(closed).toHaveBeenCalledWith(first);
});
test('a same-id replacement keeps the open state', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
chat.open();
// Upgrade in place: same id, new providers.
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
);
expect(chat.isOpen()).toBe(true);
expect(closed).not.toHaveBeenCalled();
});
test('disposing the active chat while open closes it; the fallback starts closed', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
disposables.push(
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
);
const second = { id: 'second.chat', name: 'Second' };
const registration = chat.registerChat(second, trigger, panel);
chat.open();
registration.dispose();
expect(chat.getChat()?.id).toBe('first.chat');
expect(chat.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
expect(closed).toHaveBeenCalledWith(second);
});
test('disposing an inactive registration leaves the open state untouched', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
const inactive = chat.registerChat(
{ id: 'first.chat', name: 'First' },
trigger,
panel,
);
disposables.push(
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
);
chat.open();
inactive.dispose();
expect(chat.isOpen()).toBe(true);
expect(closed).not.toHaveBeenCalled();
});
test('disposing the last chat while open resets the open state', () => {
const registration = chat.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
chat.open();
expect(chat.isOpen()).toBe(true);
registration.dispose();
expect(chat.isOpen()).toBe(false);
// A registration arriving much later must not inherit a stale open state.
disposables.push(
chat.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel),
);
expect(chat.isOpen()).toBe(false);
});
test('mode defaults to floating and setMode fires only on change', () => {
const modeChanged = jest.fn();
disposables.push(chat.onDidChangeMode(modeChanged));
expect(chat.getMode()).toBe('floating');
// Setting the current mode is a no-op.
chat.setMode('floating');
expect(modeChanged).not.toHaveBeenCalled();
chat.setMode('panel');
expect(chat.getMode()).toBe('panel');
expect(modeChanged).toHaveBeenCalledWith('panel');
});
test('the snapshot is immutable per version and consistent with the registry', () => {
const before = getChatSnapshot();
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
chat.open();
const after = getChatSnapshot();
// Unchanged references for old snapshots; a new object per change.
expect(after).not.toBe(before);
expect(before.active).toBeUndefined();
expect(after).toMatchObject({
open: true,
mode: 'floating',
active: getActiveChat(),
});
expect(after.version).toBeGreaterThan(before.version);
// Stable reference between changes.
expect(getChatSnapshot()).toBe(after);
});

View File

@@ -0,0 +1,238 @@
/**
* 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.
*/
/**
* @fileoverview Host implementation of the `chat` contribution type.
*
* Chat is a dedicated contribution type, not a view: extensions register via
* the public `chat.registerChat()` and the host owns mounting, open/close
* state, and the display mode. Multiple chat extensions may register, but the
* host applies singleton resolution — the most-recently-registered chat is
* active; disposing it falls back to the previous one.
*
* Open-state policy across active-chat transitions: when the active chat's
* identity changes — a takeover by a different id, disposal falling back to a
* different id, or disposal of the last chat — the panel is closed (firing
* `onDidClose`) so the incoming chat never mounts into an open state it did
* not request. A same-id re-registration is an upgrade in place and keeps the
* open state.
*
* The public namespace (`chat`) is exposed to extensions on
* `window.superset`; the other exports are host-internal accessors for
* ChatMount and are NOT part of the public `@apache-superset/core` API.
*/
import { ReactElement } from 'react';
import type { chat as chatApi } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEmitter, createEventEmitter } from '../utils';
type Chat = chatApi.Chat;
type ChatMode = chatApi.ChatMode;
/** A registered chat: its descriptor plus the host-mountable providers. */
export interface RegisteredChat {
/** The chat descriptor passed to `registerChat`. */
chat: Chat;
/** Renders the collapsed bubble. Hidden by the host in panel mode. */
trigger: () => ReactElement;
/** Renders the chat panel, mounted per the current {@link ChatMode}. */
panel: () => ReactElement;
/**
* Unique per registration (a same-id re-registration gets a new one). The
* host UI keys mounts and fault containment on it, so a replacement resets
* crashed error boundaries instead of inheriting their latched state.
*/
registrationId: number;
}
/**
* Immutable snapshot of the whole chat state, rebuilt on every change.
* Returned by reference from `getChatSnapshot` so `useSyncExternalStore`
* consumers read registrations, open state, and mode from one consistent
* object instead of tearing across separate live reads.
*/
export interface ChatSnapshot {
/** Monotonic change counter, useful as a memo/effect dependency. */
version: number;
/** Whether the active chat's panel is open. */
open: boolean;
/** The current display mode. */
mode: ChatMode;
/** The active registration, or undefined when none is registered. */
active: RegisteredChat | undefined;
}
/** Registration order is the singleton-resolution order: last entry wins. */
const registrations: RegisteredChat[] = [];
let panelOpen = false;
let nextRegistrationId = 1;
const registerEmitter = createEventEmitter<Chat>();
const unregisterEmitter = createEventEmitter<Chat>();
const openEmitter = createEventEmitter<Chat>();
const closeEmitter = createEventEmitter<Chat>();
const resizePanelEmitter = createEventEmitter<{ width: number }>();
const modeEmitter = createEmitter<ChatMode>('floating');
/**
* Host-internal: resolves the active chat with its providers.
* The most-recently-registered chat wins; when it is disposed the previous
* registration takes over the slot again.
*/
export const getActiveChat = (): RegisteredChat | undefined =>
registrations[registrations.length - 1];
let snapshot: ChatSnapshot = {
version: 0,
open: false,
mode: modeEmitter.getCurrent(),
active: undefined,
};
const stateSubscribers = new Set<() => void>();
const notifyState = () => {
snapshot = {
version: snapshot.version + 1,
open: panelOpen,
mode: modeEmitter.getCurrent(),
active: getActiveChat(),
};
stateSubscribers.forEach(fn => fn());
};
export const subscribeToChatState = (listener: () => void): (() => void) => {
stateSubscribers.add(listener);
return () => {
stateSubscribers.delete(listener);
};
};
export const getChatSnapshot = (): ChatSnapshot => snapshot;
/** Closes the panel and fires `onDidClose` with the chat that was closed. */
const closePanel = (closedChat: Chat) => {
panelOpen = false;
closeEmitter.fire(closedChat);
};
const registerChat: typeof chatApi.registerChat = (
chat: Chat,
trigger: () => ReactElement,
panel: () => ReactElement,
): Disposable => {
const previousActive = getActiveChat();
// Re-registering an id replaces the previous entry and moves it to the
// most-recent position, mirroring the view registry's same-id semantics.
const existingIndex = registrations.findIndex(r => r.chat.id === chat.id);
if (existingIndex !== -1) {
registrations.splice(existingIndex, 1);
}
const entry: RegisteredChat = {
chat,
trigger,
panel,
registrationId: nextRegistrationId,
};
nextRegistrationId += 1;
registrations.push(entry);
registerEmitter.fire(chat);
// A takeover by a different id closes the displaced chat's panel so the
// incoming chat never mounts already-open; a same-id replacement is an
// upgrade in place and keeps the open state.
if (panelOpen && previousActive && previousActive.chat.id !== chat.id) {
closePanel(previousActive.chat);
}
notifyState();
return new Disposable(() => {
const index = registrations.indexOf(entry);
if (index === -1) {
// Already removed — replaced by a same-id registration or disposed twice.
return;
}
const wasActive = getActiveChat() === entry;
registrations.splice(index, 1);
unregisterEmitter.fire(chat);
// Disposing the active chat closes its panel; the fallback chat (if any)
// starts closed. Disposing an inactive registration leaves the open
// state of the active chat untouched.
if (panelOpen && wasActive) {
closePanel(chat);
}
notifyState();
});
};
const getChat: typeof chatApi.getChat = (): Chat | undefined => {
const active = getActiveChat();
// Copy so extensions cannot mutate another extension's descriptor.
return active ? { ...active.chat } : undefined;
};
const open: typeof chatApi.open = (): void => {
const active = getActiveChat();
// Open state only exists while a chat is registered; opening an empty slot
// would otherwise leak `open` into a future, unrelated registration.
if (panelOpen || !active) return;
panelOpen = true;
openEmitter.fire(active.chat);
notifyState();
};
const close: typeof chatApi.close = (): void => {
const active = getActiveChat();
if (!panelOpen || !active) return;
closePanel(active.chat);
notifyState();
};
const isOpen: typeof chatApi.isOpen = (): boolean => panelOpen;
const getMode: typeof chatApi.getMode = (): ChatMode =>
modeEmitter.getCurrent();
const setMode: typeof chatApi.setMode = (mode: ChatMode): void => {
if (mode === modeEmitter.getCurrent()) return;
modeEmitter.fire(mode);
notifyState();
};
export const chat: typeof chatApi = {
registerChat,
getChat,
onDidRegisterChat: registerEmitter.event,
onDidUnregisterChat: unregisterEmitter.event,
open,
close,
isOpen,
onDidOpen: openEmitter.event,
onDidClose: closeEmitter.event,
getMode,
setMode,
onDidChangeMode: modeEmitter.event,
// The host fires this from its panel resizer; until that chrome exists the
// event is exposed but never fires.
onDidResizePanel: resizePanelEmitter.event,
};

View File

@@ -0,0 +1,220 @@
/**
* 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.
*/
// ---------------------------------------------------------------------------
// Captured listeners — allows tests to trigger action notifications manually.
// ---------------------------------------------------------------------------
type ListenerEntry = {
predicate: (action: { type: string }) => boolean;
effect: (action: { type: string }) => void;
};
const capturedListeners: ListenerEntry[] = [];
// Declared before jest.mock so the factory closure can reference it.
let mockState: Record<string, unknown>;
jest.mock('src/views/store', () => ({
store: { getState: () => mockState, dispatch: jest.fn() },
listenerMiddleware: {
startListening: (opts: {
predicate: (action: { type: string }) => boolean;
effect: (action: { type: string }) => void;
}) => {
const entry = { predicate: opts.predicate, effect: opts.effect };
capturedListeners.push(entry);
return () => {
const idx = capturedListeners.indexOf(entry);
if (idx !== -1) capturedListeners.splice(idx, 1);
};
},
},
}));
jest.mock('../navigation', () => ({
navigation: { getPageType: jest.fn(() => 'dashboard') },
}));
function dispatch(actionType: string) {
const action = { type: actionType };
capturedListeners
.filter(e => e.predicate(action))
.forEach(e => e.effect(action));
}
// Imported after mocks
// eslint-disable-next-line import/first
import { dashboard } from './index';
function makeState(
overrides: Partial<{
dashboardInfo: unknown;
nativeFilters: unknown;
dataMask: unknown;
sliceEntities: unknown;
dashboardLayout: unknown;
}> = {},
) {
return {
dashboardInfo: { id: 1, dashboard_title: 'Sales', slug: 'sales' },
nativeFilters: { filters: { 'filter-1': { name: 'Region' } } },
dataMask: { 'filter-1': { filterState: { value: ['West'] } } },
sliceEntities: { slices: {} },
dashboardLayout: { present: {} },
...overrides,
};
}
beforeEach(() => {
mockState = makeState();
});
afterEach(() => {
capturedListeners.length = 0;
jest.restoreAllMocks();
});
test('getCurrentDashboard returns undefined when not on dashboard page', () => {
const { navigation } = jest.requireMock('../navigation');
(navigation.getPageType as jest.Mock).mockReturnValueOnce('explore');
expect(dashboard.getCurrentDashboard()).toBeUndefined();
});
test('getCurrentDashboard returns undefined when dashboardInfo is absent', () => {
mockState = makeState({ dashboardInfo: undefined });
expect(dashboard.getCurrentDashboard()).toBeUndefined();
});
test('getCurrentDashboard returns dashboard context with active filters', () => {
expect(dashboard.getCurrentDashboard()).toEqual({
dashboardId: 1,
title: 'Sales',
filters: [{ filterId: 'filter-1', label: 'Region', value: ['West'] }],
// No charts on the (empty) layout fixture.
charts: [],
});
});
test('getCurrentDashboard reports charts placed on the dashboard layout', () => {
mockState = makeState({
sliceEntities: {
slices: {
42: {
slice_name: 'Revenue by Region',
viz_type: 'echarts_timeseries_bar',
datasource_id: 7,
datasource_name: 'cleaned_sales',
},
},
},
dashboardLayout: {
present: {
'CHART-abc': { id: 'CHART-abc', type: 'CHART', meta: { chartId: 42 } },
// A chart id with no matching slice entity still appears, with blanks.
'CHART-def': { id: 'CHART-def', type: 'CHART', meta: { chartId: 99 } },
// Non-chart components are ignored.
'TAB-xyz': { id: 'TAB-xyz', type: 'TAB', meta: {} },
},
},
});
expect(dashboard.getCurrentDashboard()?.charts).toEqual([
{
chartId: 42,
chartName: 'Revenue by Region',
vizType: 'echarts_timeseries_bar',
datasourceId: 7,
datasourceName: 'cleaned_sales',
isVisible: true,
},
{
chartId: 99,
chartName: '',
vizType: '',
datasourceId: null,
datasourceName: null,
isVisible: true,
},
]);
});
test('getCurrentDashboard excludes filters with null value', () => {
mockState = makeState({
dataMask: { 'filter-1': { filterState: { value: null } } },
});
expect(dashboard.getCurrentDashboard()?.filters).toHaveLength(0);
});
test('getCurrentDashboard excludes dataMask entries not in nativeFilters', () => {
mockState = makeState({
dataMask: { 'chart-filter': { filterState: { value: 'foo' } } },
});
expect(dashboard.getCurrentDashboard()?.filters).toHaveLength(0);
});
test('filter array value is a defensive copy — mutation does not affect Redux state', () => {
const ctx = dashboard.getCurrentDashboard();
const original = [
...(mockState as any).dataMask['filter-1'].filterState.value,
];
(ctx!.filters[0].value as string[]).push('East');
expect((mockState as any).dataMask['filter-1'].filterState.value).toEqual(
original,
);
});
// Action type strings match the constants in src/dashboard/actions/hydrate
// and src/dataMask/actions — kept as literals so this test file has no
// import dependency on those modules.
test.each([
'HYDRATE_DASHBOARD',
'UPDATE_DATA_MASK',
'SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE',
])('onDidChangeDashboard fires on action type %s', actionType => {
const listener = jest.fn();
const disposable = dashboard.onDidChangeDashboard(listener);
dispatch(actionType);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({ dashboardId: 1, title: 'Sales' }),
);
disposable.dispose();
});
test('onDidChangeDashboard does not fire when not on dashboard page', () => {
const { navigation } = jest.requireMock('../navigation');
(navigation.getPageType as jest.Mock).mockReturnValue('explore');
const listener = jest.fn();
const disposable = dashboard.onDidChangeDashboard(listener);
dispatch('HYDRATE_DASHBOARD');
expect(listener).not.toHaveBeenCalled();
(navigation.getPageType as jest.Mock).mockReturnValue('dashboard');
disposable.dispose();
});
test('disposed listener is not called', () => {
const listener = jest.fn();
const disposable = dashboard.onDidChangeDashboard(listener);
disposable.dispose();
dispatch('HYDRATE_DASHBOARD');
expect(listener).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,123 @@
/**
* 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.
*/
/**
* Host-internal implementation of the `dashboard` namespace.
*
* Wraps Redux dashboardInfo and dataMask state and normalizes them into the
* stable `DashboardContext` contract. Extensions must not depend on the Redux
* slice structure directly.
*/
import type { dashboard as dashboardApi } from '@apache-superset/core';
import type { DataMaskStateWithId } from '@superset-ui/core';
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
import {
UPDATE_DATA_MASK,
SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE,
} from 'src/dataMask/actions';
import { store, RootState } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
import getChartIdsFromLayout from 'src/dashboard/util/getChartIdsFromLayout';
import { createActionListener } from '../utils';
import { navigation } from '../navigation';
type DashboardContext = dashboardApi.DashboardContext;
type FilterValue = dashboardApi.FilterValue;
type ChartSummary = NonNullable<DashboardContext['charts']>[number];
function buildChartSummaries(state: RootState): ChartSummary[] {
const slices = state.sliceEntities?.slices ?? {};
const layout = state.dashboardLayout?.present ?? {};
// Only charts actually placed on the dashboard layout — `slices` can also
// hold entities that are not on the current dashboard.
return getChartIdsFromLayout(layout).map(chartId => {
const slice = slices[chartId];
return {
chartId,
chartName: slice?.slice_name ?? '',
vizType: slice?.viz_type ?? '',
datasourceId: slice?.datasource_id ?? null,
datasourceName: slice?.datasource_name ?? null,
// Tab-accurate visibility is a deferred phase; every chart on the
// dashboard is reported visible for now.
isVisible: true,
};
});
}
function buildDashboardContext(): DashboardContext | undefined {
if (navigation.getPageType() !== 'dashboard') return undefined;
// `store.getState()` is already typed as RootState, so the slices below are
// read with their real types — the host owns this normalization and must
// stay type-safe against slice reshapes.
const state = store.getState();
const info = state.dashboardInfo;
if (!info?.id) return undefined;
const nativeFilters = state.nativeFilters?.filters ?? {};
const dataMask: DataMaskStateWithId = state.dataMask ?? {};
const filters: FilterValue[] = Object.entries(dataMask)
.filter(([id, mask]) => {
if (!(id in nativeFilters)) return false;
const value = mask?.filterState?.value;
return value !== null && value !== undefined;
})
.map(([id, mask]) => {
const raw = mask.filterState?.value;
return {
filterId: id,
label: nativeFilters[id]?.name ?? id,
value: Array.isArray(raw) ? [...raw] : raw,
};
});
return {
dashboardId: info.id,
title: info.dashboard_title ?? info.slug ?? String(info.id),
filters,
charts: buildChartSummaries(state),
};
}
const dashboardChangePredicate: AnyListenerPredicate<RootState> = action =>
action.type === HYDRATE_DASHBOARD ||
action.type === UPDATE_DATA_MASK ||
action.type === SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE;
const getCurrentDashboard: typeof dashboardApi.getCurrentDashboard = () =>
buildDashboardContext();
const onDidChangeDashboard: typeof dashboardApi.onDidChangeDashboard = (
listener: (ctx: DashboardContext) => void,
thisArgs?: any,
) =>
createActionListener<DashboardContext>(
dashboardChangePredicate,
listener,
() => buildDashboardContext() ?? null,
thisArgs,
);
export const dashboard: typeof dashboardApi = {
getCurrentDashboard,
onDidChangeDashboard,
};

View File

@@ -0,0 +1,63 @@
/**
* 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.
*/
/**
* Host-internal implementation of the `dataset` namespace.
*
* Dataset page components call `setCurrentDataset` to publish context as they
* load. Extensions consume the stable `DatasetContext` contract; they are
* isolated from the page's internal data-fetching implementation.
*/
import type { dataset as datasetApi } from '@apache-superset/core';
import { createEmitter } from '../utils';
type DatasetContext = datasetApi.DatasetContext;
const emitter = createEmitter<DatasetContext | undefined>(undefined);
/**
* Host-internal: called by the Dataset page when its entity loads or changes.
* Not part of the public `@apache-superset/core` API.
*/
export const setCurrentDataset = (ctx: DatasetContext | undefined): void => {
emitter.fire(ctx);
};
const getCurrentDataset: typeof datasetApi.getCurrentDataset = () => {
const current = emitter.getCurrent();
return current ? { ...current } : undefined;
};
const onDidChangeDataset: typeof datasetApi.onDidChangeDataset = (
listener: (ctx: DatasetContext) => void,
thisArgs?: unknown,
) => {
const bound = thisArgs ? listener.bind(thisArgs) : listener;
// The public contract only emits a concrete context; skip `undefined` clears
// so subscribers are never handed an empty value.
return emitter.event(ctx => {
if (ctx) bound(ctx);
});
};
export const dataset: typeof datasetApi = {
getCurrentDataset,
onDidChangeDataset,
};

View File

@@ -0,0 +1,157 @@
/**
* 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.
*/
// ---------------------------------------------------------------------------
// Captured listeners — allows tests to trigger action notifications manually.
// ---------------------------------------------------------------------------
type ListenerEntry = {
predicate: (action: { type: string }) => boolean;
effect: (action: { type: string }) => void;
};
const capturedListeners: ListenerEntry[] = [];
// Declared before jest.mock so the factory closure can reference it.
let mockState: Record<string, unknown>;
jest.mock('src/views/store', () => ({
store: { getState: () => mockState, dispatch: jest.fn() },
listenerMiddleware: {
startListening: (opts: {
predicate: (action: { type: string }) => boolean;
effect: (action: { type: string }) => void;
}) => {
const entry = { predicate: opts.predicate, effect: opts.effect };
capturedListeners.push(entry);
return () => {
const idx = capturedListeners.indexOf(entry);
if (idx !== -1) capturedListeners.splice(idx, 1);
};
},
},
}));
jest.mock('../navigation', () => ({
navigation: { getPageType: jest.fn(() => 'explore') },
}));
function dispatch(actionType: string) {
const action = { type: actionType };
capturedListeners
.filter(e => e.predicate(action))
.forEach(e => e.effect(action));
}
// Imported after mocks
// eslint-disable-next-line import/first
import { explore } from './index';
beforeEach(() => {
mockState = {
explore: {
slice: { slice_id: 42, slice_name: 'My Chart' },
datasource: { id: 7, table_name: 'orders' },
controls: { viz_type: { value: 'bar' } },
sliceName: 'My Chart',
form_data: {},
},
};
});
afterEach(() => {
capturedListeners.length = 0;
jest.restoreAllMocks();
});
test('getCurrentChart returns undefined when not on explore page', () => {
const { navigation } = jest.requireMock('../navigation');
(navigation.getPageType as jest.Mock).mockReturnValueOnce('dashboard');
expect(explore.getCurrentChart()).toBeUndefined();
});
test('getCurrentChart returns undefined when explore state is absent', () => {
mockState = {};
expect(explore.getCurrentChart()).toBeUndefined();
});
test('getCurrentChart returns chart context from Redux state', () => {
expect(explore.getCurrentChart()).toEqual({
chartId: 42,
chartName: 'My Chart',
vizType: 'bar',
datasourceId: 7,
datasourceName: 'orders',
});
});
test('getCurrentChart returns null chartId for unsaved chart', () => {
mockState = {
explore: {
slice: null,
datasource: { id: 1, table_name: 'events' },
controls: { viz_type: { value: 'line' } },
sliceName: null,
form_data: { viz_type: 'line' },
},
};
expect(explore.getCurrentChart()?.chartId).toBeNull();
});
// Action type strings match the constants in src/explore/actions/exploreActions
// and src/explore/actions/datasourcesActions — kept as literals so this test
// file has no import dependency on those modules.
test.each([
'HYDRATE_EXPLORE',
'UPDATE_FORM_DATA', // SET_FORM_DATA constant resolves to this string
'UPDATE_CHART_TITLE',
'SET_DATASOURCE',
'CREATE_NEW_SLICE',
'SLICE_UPDATED',
])('onDidChangeChart fires on action type %s', actionType => {
const listener = jest.fn();
const disposable = explore.onDidChangeChart(listener);
dispatch(actionType);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({ chartId: 42, vizType: 'bar' }),
);
disposable.dispose();
});
test('onDidChangeChart does not fire when page type is not explore', () => {
const { navigation } = jest.requireMock('../navigation');
(navigation.getPageType as jest.Mock).mockReturnValue('dashboard');
const listener = jest.fn();
const disposable = explore.onDidChangeChart(listener);
dispatch('HYDRATE_EXPLORE');
expect(listener).not.toHaveBeenCalled();
(navigation.getPageType as jest.Mock).mockReturnValue('explore');
disposable.dispose();
});
test('disposed listener is not called', () => {
const listener = jest.fn();
const disposable = explore.onDidChangeChart(listener);
disposable.dispose();
dispatch('HYDRATE_EXPLORE');
expect(listener).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,92 @@
/**
* 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.
*/
/**
* Host-internal implementation of the `explore` namespace.
*
* Wraps Redux explore state and normalizes it into the stable `ChartContext`
* contract. Extensions must not depend on the Redux slice structure directly.
*/
import type { explore as exploreApi } from '@apache-superset/core';
import { HYDRATE_EXPLORE } from 'src/explore/actions/hydrateExplore';
import {
CREATE_NEW_SLICE,
SET_FORM_DATA,
SLICE_UPDATED,
UPDATE_CHART_TITLE,
} from 'src/explore/actions/exploreActions';
import { SET_DATASOURCE } from 'src/explore/actions/datasourcesActions';
import { store, RootState } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
import { createActionListener } from '../utils';
import { navigation } from '../navigation';
type ChartContext = exploreApi.ChartContext;
function buildChartContext(): ChartContext | undefined {
if (navigation.getPageType() !== 'explore') return undefined;
// `store.getState()` is already RootState; read the typed `explore` slice
// directly rather than casting it away.
const state = store.getState();
const exploreState = state.explore;
if (!exploreState) return undefined;
const { slice, datasource, controls } = exploreState;
const vizType: string =
(controls?.viz_type?.value as string) ??
exploreState.form_data?.viz_type ??
'';
return {
chartId: slice?.slice_id ?? null,
chartName: exploreState.sliceName ?? slice?.slice_name ?? null,
vizType,
datasourceId: datasource?.id ?? null,
datasourceName:
datasource?.table_name ?? datasource?.datasource_name ?? null,
};
}
const exploreChangePredicate: AnyListenerPredicate<RootState> = action =>
action.type === HYDRATE_EXPLORE ||
action.type === SET_FORM_DATA ||
action.type === UPDATE_CHART_TITLE ||
action.type === SET_DATASOURCE ||
action.type === CREATE_NEW_SLICE ||
action.type === SLICE_UPDATED;
const getCurrentChart: typeof exploreApi.getCurrentChart = () =>
buildChartContext();
const onDidChangeChart: typeof exploreApi.onDidChangeChart = (
listener: (ctx: ChartContext) => void,
thisArgs?: any,
) =>
createActionListener<ChartContext>(
exploreChangePredicate,
listener,
() => buildChartContext() ?? null,
thisArgs,
);
export const explore: typeof exploreApi = {
getCurrentChart,
onDidChangeChart,
};

View File

@@ -27,11 +27,16 @@ export const core: typeof coreType = {
};
export * from './authentication';
export * from './chat';
export * from './commands';
export * from './dashboard';
export * from './dataset';
export * from './editors';
export * from './explore';
export * from './extensions';
export * from './menus';
export * from './models';
export * from './navigation';
export * from './sqlLab';
export * from './utils';
export * from './views';

View File

@@ -0,0 +1,121 @@
/**
* 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.
*/
// Reset module state between tests so currentPageType is re-initialized.
beforeEach(() => {
jest.resetModules();
Object.defineProperty(window, 'location', {
writable: true,
value: { pathname: '/' },
});
});
async function importNavigation() {
const mod = await import('./index');
return mod;
}
test('getPageType returns "other" for unknown pathname', async () => {
const { navigation } = await importNavigation();
expect(navigation.getPageType()).toBe('other');
});
test('getPageType derives page type from window.location.pathname', async () => {
window.location.pathname = '/superset/dashboard/42/';
const { navigation } = await importNavigation();
expect(navigation.getPageType()).toBe('dashboard');
});
test('notifyPageChange updates the current page type', async () => {
const { navigation, notifyPageChange } = await importNavigation();
notifyPageChange('/explore/?form_data={}');
expect(navigation.getPageType()).toBe('explore');
});
test('notifyPageChange fires listeners on page type change', async () => {
const { navigation, notifyPageChange } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
notifyPageChange('/superset/dashboard/1/');
expect(listener).toHaveBeenCalledWith('dashboard');
disposable.dispose();
});
test('notifyPageChange does not fire listeners when page type is unchanged', async () => {
window.location.pathname = '/superset/dashboard/1/';
const { navigation, notifyPageChange } = await importNavigation();
const listener = jest.fn();
navigation.onDidChangePage(listener);
notifyPageChange('/superset/dashboard/2/');
expect(listener).not.toHaveBeenCalled();
});
test('onDidChangePage listener is removed after dispose', async () => {
const { navigation, notifyPageChange } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
disposable.dispose();
notifyPageChange('/superset/dashboard/1/');
expect(listener).not.toHaveBeenCalled();
});
test('sqllab path is matched with and without trailing slash', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/sqllab');
expect(navigation.getPageType()).toBe('sqllab');
notifyPageChange('/explore/');
notifyPageChange('/sqllab/');
expect(navigation.getPageType()).toBe('sqllab');
});
test('chart and dashboard list pages get their own page types', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/chart/list/');
expect(navigation.getPageType()).toBe('chart_list');
notifyPageChange('/dashboard/list/');
expect(navigation.getPageType()).toBe('dashboard_list');
});
test('dataset list and single-dataset pages get distinct page types', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/tablemodelview/list/');
expect(navigation.getPageType()).toBe('dataset_list');
notifyPageChange('/dataset/42');
expect(navigation.getPageType()).toBe('dataset');
});
test('sqllab editor, query history, and saved queries get distinct page types', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/sqllab/');
expect(navigation.getPageType()).toBe('sqllab');
notifyPageChange('/sqllab/history/');
expect(navigation.getPageType()).toBe('query_history');
notifyPageChange('/savedqueryview/list/');
expect(navigation.getPageType()).toBe('saved_queries');
});
test('chart/add resolves to explore, not chart_list', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/chart/add');
expect(navigation.getPageType()).toBe('explore');
});

View File

@@ -0,0 +1,82 @@
/**
* 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.
*/
/**
* Host-internal implementation of the `navigation` namespace.
*
* Backed by browser location — no Redux dependency.
* The app shell calls `notifyPageChange(pathname)` whenever the route changes.
*/
import type { navigation as navigationApi } from '@apache-superset/core';
import { Disposable } from '../models';
type PageType = navigationApi.PageType;
const listeners = new Set<(pageType: PageType) => void>();
function derivePageType(pathname: string): PageType {
if (pathname.startsWith('/superset/dashboard/')) return 'dashboard';
if (pathname.startsWith('/dashboard/list')) return 'dashboard_list';
if (pathname.startsWith('/explore/')) return 'explore';
if (pathname.startsWith('/superset/explore/')) return 'explore';
if (pathname.startsWith('/chart/add')) return 'explore';
if (pathname.startsWith('/chart/list')) return 'chart_list';
if (pathname.startsWith('/sqllab/history')) return 'query_history';
if (pathname.startsWith('/savedqueryview/list')) return 'saved_queries';
if (pathname === '/sqllab' || pathname.startsWith('/sqllab/'))
return 'sqllab';
if (pathname.startsWith('/tablemodelview/list')) return 'dataset_list';
if (pathname.startsWith('/dataset/')) return 'dataset';
if (pathname.startsWith('/superset/welcome/')) return 'home';
return 'other';
}
let currentPageType: PageType | undefined;
function getOrInitPageType(): PageType {
if (currentPageType === undefined) {
currentPageType = derivePageType(window.location.pathname);
}
return currentPageType;
}
/** Called by ExtensionsStartup whenever the React Router location changes. */
export const notifyPageChange = (pathname: string): void => {
const next = derivePageType(pathname);
if (next === getOrInitPageType()) return;
currentPageType = next;
listeners.forEach(fn => fn(next));
};
const getPageType: typeof navigationApi.getPageType = () => getOrInitPageType();
const onDidChangePage: typeof navigationApi.onDidChangePage = (
listener: (pageType: PageType) => void,
thisArgs?: any,
): Disposable => {
const bound = thisArgs ? listener.bind(thisArgs) : listener;
listeners.add(bound);
return new Disposable(() => listeners.delete(bound));
};
export const navigation: typeof navigationApi = {
getPageType,
onDidChangePage,
};

View File

@@ -46,6 +46,11 @@ const registerView: typeof viewsApi.registerView = (
): Disposable => {
const { id } = view;
const previousLocation = viewRegistry.get(id)?.location;
if (previousLocation && previousLocation !== location) {
locationIndex.get(previousLocation)?.delete(id);
}
viewRegistry.set(id, { view, location, provider });
const ids = locationIndex.get(location) ?? new Set();
@@ -53,8 +58,9 @@ const registerView: typeof viewsApi.registerView = (
locationIndex.set(location, ids);
return new Disposable(() => {
const registeredLocation = viewRegistry.get(id)?.location ?? location;
viewRegistry.delete(id);
locationIndex.get(location)?.delete(id);
locationIndex.get(registeredLocation)?.delete(id);
});
};

View File

@@ -1,88 +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 { render, waitFor } from 'spec/helpers/testing-library';
import ExtensionsList from './ExtensionsList';
import fetchMock from 'fetch-mock';
beforeAll(() => fetchMock.unmockGlobal());
// Mock initial state for the store
const mockInitialState = {
extensions: {
loading: false,
resourceCount: 2,
resourceCollection: [
{
id: 1,
name: 'Test Extension 1',
enabled: true,
},
{
id: 2,
name: 'Test Extension 2',
enabled: false,
},
],
bulkSelectEnabled: false,
},
};
const defaultProps = {
addDangerToast: jest.fn(),
addSuccessToast: jest.fn(),
};
const renderWithStore = (props = {}) =>
render(<ExtensionsList {...defaultProps} {...props} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
initialState: mockInitialState,
});
test('renders extensions list with basic structure', async () => {
renderWithStore();
// Check that the component renders
expect(document.body).toBeInTheDocument();
});
test('displays extension names in the list', async () => {
renderWithStore();
await waitFor(() => {
// These texts should appear somewhere in the rendered component
expect(document.body).toHaveTextContent(/Extensions/);
});
});
test('calls toast functions when provided', () => {
const addDangerToast = jest.fn();
const addSuccessToast = jest.fn();
renderWithStore({
addDangerToast,
addSuccessToast,
});
// The component should accept these props without error
expect(addDangerToast).toBeDefined();
expect(addSuccessToast).toBeDefined();
});

View File

@@ -1,95 +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 { t } from '@apache-superset/core/translation';
import { FunctionComponent, useMemo } from 'react';
import { useListViewResource } from 'src/views/CRUD/hooks';
import { ListView } from 'src/components';
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
import withToasts from 'src/components/MessageToasts/withToasts';
const PAGE_SIZE = 25;
type Extension = {
id: number;
name: string;
enabled: boolean;
};
interface ExtensionsListProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
}
const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
addDangerToast,
addSuccessToast,
}) => {
const {
state: { loading, resourceCount, resourceCollection },
fetchData,
refreshData,
} = useListViewResource<Extension>(
'extensions',
t('Extensions'),
addDangerToast,
);
const columns = useMemo(
() => [
{
Header: t('Name'),
accessor: 'name',
size: 'lg',
id: 'name',
Cell: ({
row: {
original: { name },
},
}: any) => name,
},
],
[loading], // We need to monitor loading to avoid stale state in actions
);
const menuData: SubMenuProps = {
activeChild: 'Extensions',
name: t('Extensions'),
buttons: [],
};
return (
<>
<SubMenu {...menuData} />
<ListView<Extension>
columns={columns}
count={resourceCount}
data={resourceCollection}
initialSort={[{ id: 'name', desc: false }]}
pageSize={PAGE_SIZE}
fetchData={fetchData}
loading={loading}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
refreshData={refreshData}
/>
</>
);
};
export default withToasts(ExtensionsList);

View File

@@ -29,15 +29,20 @@ function createMockExtension(overrides: Partial<Extension> = {}): Extension {
name: 'Test Extension',
description: 'A test extension',
version: '1.0.0',
dependencies: [],
remoteEntry: '',
extensionDependencies: [],
...overrides,
};
}
beforeEach(() => {
(ExtensionsLoader as any).instance = undefined;
// Minimal host registry surface the loader wraps during module evaluation.
(window as any).superset = {
commands: { registerCommand: jest.fn() },
menus: { registerMenuItem: jest.fn() },
editors: { registerEditor: jest.fn() },
views: { registerView: jest.fn() },
};
});
test('creates a singleton instance', () => {
@@ -142,3 +147,59 @@ test('logs error when initializeExtensions fails', async () => {
errorSpy.mockRestore();
});
/**
* Stubs the module-federation machinery `loadModule` depends on so a fake
* extension entry module (its `./index` factory) can be loaded in jsdom.
* Returns a cleanup function that restores the patched globals.
*/
function mockRemoteModule(containerName: string, factory: () => unknown) {
const appendChildSpy = jest
.spyOn(document.head, 'appendChild')
.mockImplementation((element: Node) => {
if (element instanceof HTMLScriptElement && element.onload) {
setTimeout(() => (element.onload as any)(new Event('load')), 0);
}
return element;
});
(global as any).__webpack_init_sharing__ = jest
.fn()
.mockResolvedValue(undefined);
(global as any).__webpack_share_scopes__ = { default: {} };
(window as any)[containerName] = {
init: jest.fn().mockResolvedValue(undefined),
get: jest.fn().mockResolvedValue(factory),
};
return () => {
appendChildSpy.mockRestore();
delete (global as any).__webpack_init_sharing__;
delete (global as any).__webpack_share_scopes__;
delete (window as any)[containerName];
};
}
const remoteExtension = (overrides: Partial<Extension> = {}) =>
createMockExtension({
id: 'remote-ext',
remoteEntry: 'http://example/remoteEntry.js',
...overrides,
});
test('runs activate(context) hook for modern-style extensions', async () => {
const loader = ExtensionsLoader.getInstance();
const activate = jest.fn().mockResolvedValue(undefined);
const factory = () => ({ activate });
const cleanup = mockRemoteModule('remote-ext', factory);
await loader.initializeExtension(remoteExtension());
expect(activate).toHaveBeenCalledTimes(1);
// The context object passed to activate must have a subscriptions array.
expect(activate).toHaveBeenCalledWith(
expect.objectContaining({ subscriptions: expect.any(Array) }),
);
cleanup();
});

View File

@@ -17,10 +17,17 @@
* under the License.
*/
import { SupersetClient } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { logging } from '@apache-superset/core/utils';
import type { common as core } from '@apache-superset/core';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { store } from 'src/views/store';
// Side-effect import: brings the `window.superset` global augmentation into scope.
import 'src/extensions/supersetGlobal';
type Extension = core.Extension;
type ExtensionContext = core.ExtensionContext;
type ExtensionModule = core.ExtensionModule;
/**
* Loads extension modules via webpack module federation.
@@ -81,7 +88,8 @@ class ExtensionsLoader {
/**
* Initializes a single extension.
* If the extension has a remote entry, loads the module (which triggers
* If the extension has a remote entry, loads the module and runs its
* `activate(context)` hook (or, for legacy extensions, its top-level
* side-effect registrations for commands, views, menus, and editors).
* @param extension The extension to initialize.
*/
@@ -96,12 +104,15 @@ class ExtensionsLoader {
`Failed to initialize extension ${extension.name}\n`,
error,
);
store.dispatch(
addDangerToast(t('Extension "%s" failed to load.', extension.name)),
);
}
}
/**
* Loads a single extension module via webpack module federation.
* The module's top-level side effects fire contribution registrations.
* Loads a single extension module via webpack module federation and runs its
* `activate(context)` hook.
* @param extension The extension to load.
*/
private async loadModule(extension: Extension): Promise<void> {
@@ -149,8 +160,21 @@ class ExtensionsLoader {
await container.init(__webpack_share_scopes__.default);
const factory = await container.get('./index');
// Execute the module factory - side effects fire registrations
factory();
// `context.subscriptions` is provided for extensions to push their
// Disposables into. The host does not dispose them (lifecycle management is
// deferred); extensions own the array for as long as they are active.
const context: ExtensionContext = { subscriptions: [] };
// Evaluate the module factory. Extensions may register contributions as
// top-level side effects here, or return a module exposing `activate`.
const module = factory() as ExtensionModule | undefined;
// Preferred path: hand the extension its context so it can track every
// registration it makes, synchronous or asynchronous.
if (typeof module?.activate === 'function') {
await module.activate(context);
}
}
/**

View File

@@ -72,6 +72,7 @@ afterEach(() => {
test('renders without crashing', () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -88,6 +89,7 @@ test('sets up global superset object when user is logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -95,6 +97,7 @@ test('sets up global superset object when user is logged in', async () => {
// Verify the global superset object is set up
expect((window as any).superset).toBeDefined();
expect((window as any).superset.authentication).toBeDefined();
expect((window as any).superset.chat).toBeDefined();
expect((window as any).superset.core).toBeDefined();
expect((window as any).superset.commands).toBeDefined();
expect((window as any).superset.extensions).toBeDefined();
@@ -109,6 +112,7 @@ test('sets up global superset object when user is logged in', async () => {
test('does not set up global superset object when user is not logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialStateNoUser,
});
@@ -127,6 +131,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -144,6 +149,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
test('does not initialize ExtensionsLoader when user is not logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialStateNoUser,
});
@@ -169,6 +175,7 @@ test('only initializes once even with multiple renders', async () => {
const { rerender } = render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -205,6 +212,7 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -234,6 +242,7 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -268,6 +277,7 @@ test('continues rendering children even when ExtensionsLoader initialization fai
</ExtensionsStartup>,
{
useRedux: true,
useRouter: true,
initialState: mockInitialState,
},
);

View File

@@ -16,48 +16,67 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState } from 'react';
// eslint-disable-next-line no-restricted-syntax
import * as supersetCore from '@apache-superset/core';
import { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { logging } from '@apache-superset/core/utils';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import {
authentication,
chat,
core,
commands,
dashboard,
dataset,
editors,
explore,
extensions,
menus,
navigation,
sqlLab,
views,
} from 'src/core';
import { notifyPageChange } from 'src/core/navigation';
import { useSelector } from 'react-redux';
import { RootState } from 'src/views/store';
import ExtensionsLoader from './ExtensionsLoader';
declare global {
interface Window {
superset: {
authentication: typeof authentication;
core: typeof core;
commands: typeof commands;
editors: typeof editors;
extensions: typeof extensions;
menus: typeof menus;
sqlLab: typeof sqlLab;
views: typeof views;
};
}
}
// Side-effect import: brings the `window.superset` global augmentation into scope.
import 'src/extensions/supersetGlobal';
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
children,
}) => {
const [initialized, setInitialized] = useState(false);
const location = useLocation();
const prevPathname = useRef<string | null>(null);
const userId = useSelector<RootState, number | undefined>(
({ user }) => user.userId,
);
// Notify the navigation namespace on every route change.
useEffect(() => {
if (prevPathname.current !== location.pathname) {
prevPathname.current = location.pathname;
notifyPageChange(location.pathname);
}
}, [location.pathname]);
// Log unhandled rejections that may originate from extension code.
// Registered once for the lifetime of the app; does not suppress the
// browser's default error surfacing so host error reporting is unaffected.
useEffect(() => {
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
logging.error('[extensions] Unhandled rejection:', event.reason);
};
window.addEventListener('unhandledrejection', handleUnhandledRejection);
return () => {
window.removeEventListener(
'unhandledrejection',
handleUnhandledRejection,
);
};
}, []);
useEffect(() => {
if (initialized) return;
@@ -67,27 +86,34 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
return;
}
// Provide the implementations for @apache-superset/core
// Provide the implementations for @apache-superset/core.
// Namespaces are listed explicitly — do not spread the core package here,
// as that would leak un-contracted symbols onto window.superset.
window.superset = {
...supersetCore,
authentication,
chat,
core,
commands,
dashboard,
dataset,
editors,
explore,
extensions,
menus,
navigation,
sqlLab,
views,
};
const setup = async () => {
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
await ExtensionsLoader.getInstance().initializeExtensions();
}
setInitialized(true);
};
// Render the host immediately; extension bundles load in the background.
// ChatMount re-resolves reactively once a chat extension registers (via
// subscribeToChatState / getChatSnapshot), so the bubble appears
// without blocking the UI.
setInitialized(true);
setup();
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
ExtensionsLoader.getInstance().initializeExtensions();
}
}, [initialized, userId]);
if (!initialized) {

View File

@@ -0,0 +1,66 @@
/**
* 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.
*/
/**
* Global `window.superset` type augmentation.
*
* Lives in its own module (rather than inline in ExtensionsStartup) so every
* file that reads or writes `window.superset` — notably ExtensionsLoader —
* sees the type regardless of how files are batched during compilation. Both
* the startup component and the loader import this module for its side effect.
*/
import type {
authentication,
chat,
commands,
core,
dashboard,
dataset,
editors,
explore,
extensions,
menus,
navigation,
sqlLab,
views,
} from 'src/core';
/** The host namespaces exposed to extensions on `window.superset`. */
export interface SupersetGlobal {
authentication: typeof authentication;
core: typeof core;
chat: typeof chat;
commands: typeof commands;
dashboard: typeof dashboard;
dataset: typeof dataset;
editors: typeof editors;
explore: typeof explore;
extensions: typeof extensions;
menus: typeof menus;
navigation: typeof navigation;
sqlLab: typeof sqlLab;
views: typeof views;
}
declare global {
interface Window {
superset: SupersetGlobal;
}
}

View File

@@ -21,12 +21,18 @@ import { render, screen } from 'spec/helpers/testing-library';
import EditDataset from './index';
const DATASET_ENDPOINT = 'glob:*api/v1/dataset/1/related_objects';
// EditPage also fetches the dataset entity itself to publish the `dataset`
// extension-namespace context (setCurrentDataset).
const DATASET_RESOURCE_ENDPOINT = 'glob:*api/v1/dataset/1';
const mockedProps = {
id: '1',
};
fetchMock.get(DATASET_ENDPOINT, { charts: { results: [], count: 2 } });
fetchMock.get(DATASET_RESOURCE_ENDPOINT, {
result: { id: 1, table_name: 'test_table', schema: 'public' },
});
test('should render edit dataset view with tabs', async () => {
render(<EditDataset {...mockedProps} />);

View File

@@ -16,9 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect } from 'react';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import useGetDatasetRelatedCounts from 'src/features/datasets/hooks/useGetDatasetRelatedCounts';
import { useSingleViewResource } from 'src/views/CRUD/hooks';
import { setCurrentDataset } from 'src/core/dataset';
import { Badge } from '@superset-ui/core/components';
import Tabs from '@superset-ui/core/components/Tabs';
@@ -47,6 +50,13 @@ interface EditPageProps {
id: string;
}
// Stable no-op error handler so `useSingleViewResource`'s `fetchResource`
// keeps a stable identity across renders (it lists the handler in its deps).
// An inline handler would change every render and re-trigger the fetch effect,
// causing an update loop. Fetch failure is non-fatal here — the dataset
// context simply stays empty.
const noopErrorHandler = () => {};
const TRANSLATIONS = {
USAGE_TEXT: t('Usage'),
COLUMNS_TEXT: t('Columns'),
@@ -62,6 +72,45 @@ const TABS_KEYS = {
const EditPage = ({ id }: EditPageProps) => {
const { usageCount } = useGetDatasetRelatedCounts(id);
// Publish the focused dataset to the `dataset` extension namespace so chatbot
// extensions can read which dataset the user is editing. Cleared on unmount.
const {
state: { resource: datasetResource },
fetchResource,
} = useSingleViewResource<{
id: number;
table_name?: string;
schema?: string | null;
catalog?: string | null;
sql?: string | null;
is_sqllab_view?: boolean;
database?: { database_name?: string };
}>('dataset', t('dataset'), noopErrorHandler);
useEffect(() => {
const datasetId = Number(id);
if (!Number.isNaN(datasetId)) {
fetchResource(datasetId);
}
// `fetchResource` is stable (noopErrorHandler keeps its identity fixed);
// fetch only when the id changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
useEffect(() => {
if (!datasetResource) return undefined;
setCurrentDataset({
datasetId: datasetResource.id,
datasetName: datasetResource.table_name ?? String(datasetResource.id),
schema: datasetResource.schema ?? null,
catalog: datasetResource.catalog ?? null,
databaseName: datasetResource.database?.database_name ?? null,
isVirtual:
Boolean(datasetResource.sql) || !!datasetResource.is_sqllab_view,
});
return () => setCurrentDataset(undefined);
}, [datasetResource]);
const usageTab = (
<TabStyles>
<span>{TRANSLATIONS.USAGE_TEXT}</span>

View File

@@ -24,10 +24,12 @@ import { useSqlLabInitialState } from 'src/hooks/apiResources/sqlLab';
import type { InitialState } from 'src/hooks/apiResources/sqlLab';
import { resetState } from 'src/SqlLab/actions/sqlLab';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { ErrorAlert } from 'src/components/ErrorMessage';
import type { SqlLabRootState } from 'src/SqlLab/types';
import { SqlLabGlobalStyles } from 'src/SqlLab//SqlLabGlobalStyles';
import App from 'src/SqlLab/components/App';
import { Loading } from '@superset-ui/core/components';
import { Button, Loading } from '@superset-ui/core/components';
import { t } from '@apache-superset/core/translation';
import EditorAutoSync from 'src/SqlLab/components/EditorAutoSync';
import useEffectEvent from 'src/hooks/useEffectEvent';
import { LocationProvider } from './LocationContext';
@@ -36,7 +38,7 @@ export default function SqlLab() {
const lastInitializedAt = useSelector<SqlLabRootState, number>(
state => state.sqlLab.queriesLastUpdate || 0,
);
const { data, isLoading, isError, error, fulfilledTimeStamp } =
const { data, isLoading, isError, error, fulfilledTimeStamp, refetch } =
useSqlLabInitialState();
const shouldInitialize = lastInitializedAt <= (fulfilledTimeStamp || 0);
const dispatch = useDispatch();
@@ -55,11 +57,39 @@ export default function SqlLab() {
}
}, [data, initBootstrapData]);
useEffect(() => {
if (isError) {
dispatch(addDangerToast(error?.message || t('An error occurred')));
}
}, [isError, error, dispatch]);
if (isLoading || shouldInitialize) return <Loading />;
if (isError && error?.message) {
dispatch(addDangerToast(error?.message));
return null;
if (isError) {
return (
<div
css={css`
padding: 24px;
`}
>
<ErrorAlert
errorType={t('Could not load SQL Lab')}
message={t(
'An error occurred while loading SQL Lab. This may be caused by a corrupted query state.',
)}
>
<Button
buttonStyle="primary"
onClick={refetch}
css={css`
margin-top: 16px;
`}
>
{t('Reload SQL Lab')}
</Button>
</ErrorAlert>
</div>
);
}
return (

View File

@@ -99,6 +99,11 @@ export class ThemeController {
private dashboardCrudTheme: AnyThemeConfig | null = null;
// Tracks whether an explicit theme config override has been applied via
// setThemeConfig (e.g. from the Embedded SDK). When set, it must take
// precedence over a dashboard-level theme.
private themeConfigOverride = false;
// Track loaded font URLs to avoid duplicate injections
private loadedFontUrls: Set<string> = new Set();
@@ -467,6 +472,15 @@ export class ThemeController {
return this.devThemeOverride !== null;
}
/**
* Checks if an explicit theme config override has been applied via
* setThemeConfig (e.g. from the Embedded SDK). When true, this override
* takes precedence over any dashboard-level theme.
*/
public hasThemeConfigOverride(): boolean {
return this.themeConfigOverride;
}
/**
* Gets the applied theme ID (for UI display purposes).
*/
@@ -512,6 +526,7 @@ export class ThemeController {
public setThemeConfig(config: SupersetThemeConfig): void {
this.defaultTheme = config.theme_default;
this.darkTheme = config.theme_dark || null;
this.themeConfigOverride = true;
let newMode: ThemeMode;
try {

View File

@@ -33,7 +33,7 @@ import {
} from '@apache-superset/core/theme';
import { ThemeController } from './ThemeController';
const ThemeContext = createContext<ThemeContextType | null>(null);
export const ThemeContext = createContext<ThemeContextType | null>(null);
interface ThemeProviderProps {
children: React.ReactNode;
@@ -52,6 +52,10 @@ export function SupersetThemeProvider({
themeController.getCurrentMode(),
);
const [hasThemeConfigOverride, setHasThemeConfigOverride] = useState<boolean>(
themeController.hasThemeConfigOverride(),
);
useEffect(() => {
// TODO: Once we migrate to react>=18 is should be possible
// to replace the useState and useEffect with a singular
@@ -59,6 +63,7 @@ export function SupersetThemeProvider({
const updateState = (theme: Theme) => {
setCurrentTheme(theme);
setCurrentThemeMode(themeController.getCurrentMode());
setHasThemeConfigOverride(themeController.hasThemeConfigOverride());
document.documentElement.setAttribute(
'data-theme-mode',
themeController.getCurrentModeResolved(),
@@ -143,6 +148,7 @@ export function SupersetThemeProvider({
clearLocalOverrides,
getCurrentCrudThemeId,
hasDevOverride,
hasThemeConfigOverride,
canSetMode,
canSetTheme,
canDetectOSPreference,
@@ -159,6 +165,7 @@ export function SupersetThemeProvider({
clearLocalOverrides,
getCurrentCrudThemeId,
hasDevOverride,
hasThemeConfigOverride,
canSetMode,
canSetTheme,
canDetectOSPreference,

View File

@@ -1082,6 +1082,24 @@ test('setThemeConfig sets complete theme configuration', () => {
expect(controller.canSetMode()).toBe(true);
});
test('setThemeConfig flags an active theme config override', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({ default: {}, dark: {} }),
);
const controller = createController({ defaultTheme: { token: {} } });
// No override until setThemeConfig is called (e.g. from the Embedded SDK).
expect(controller.hasThemeConfigOverride()).toBe(false);
controller.setThemeConfig({
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
});
expect(controller.hasThemeConfigOverride()).toBe(true);
});
test('setThemeConfig handles theme_default only', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({

View File

@@ -86,6 +86,7 @@ describe('SupersetThemeProvider', () => {
clearLocalOverrides: jest.fn(),
getCurrentCrudThemeId: jest.fn().mockReturnValue(null),
hasDevOverride: jest.fn().mockReturnValue(false),
hasThemeConfigOverride: jest.fn().mockReturnValue(false),
canSetMode: jest.fn().mockReturnValue(true),
canSetTheme: jest.fn().mockReturnValue(true),
canDetectOSPreference: jest.fn().mockReturnValue(true),

View File

@@ -38,7 +38,9 @@ import { Logger, LOG_ACTIONS_SPA_NAVIGATION } from 'src/logger/LogUtils';
import setupCodeOverrides from 'src/setup/setupCodeOverrides';
import { logEvent } from 'src/logger/actions';
import { store } from 'src/views/store';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import ExtensionsStartup from 'src/extensions/ExtensionsStartup';
import ChatMount from 'src/components/ChatMount';
import { RootContextProviders } from './RootContextProviders';
import { ScrollToTop } from './ScrollToTop';
@@ -112,6 +114,13 @@ const App = () => (
</Route>
))}
</Switch>
{/*
The singleton chat slot. Rendered as a sibling of the route
Switch — inside ExtensionsStartup so chat extensions have been
loaded and registered, but outside the Switch so the chat persists
across route changes.
*/}
{isFeatureEnabled(FeatureFlag.EnableExtensions) && <ChatMount />}
</ExtensionsStartup>
<ToastContainer />
</RootContextProviders>

View File

@@ -128,10 +128,6 @@ const Tags = lazy(
() => import(/* webpackChunkName: "Tags" */ 'src/pages/Tags'),
);
const Extensions = lazy(
() => import(/* webpackChunkName: "Tags" */ 'src/extensions/ExtensionsList'),
);
const RowLevelSecurityList = lazy(
() =>
import(
@@ -363,13 +359,6 @@ if (isAdmin) {
Component: GroupsList,
},
);
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
routes.push({
path: '/extensions/list/',
Component: Extensions,
});
}
}
if (authRegistrationEnabled) {

View File

@@ -14,6 +14,13 @@
# limitations under the License.
FROM node:22-alpine AS build
# Harden `npm ci` against transient npm-registry network blips (e.g. ECONNRESET),
# which otherwise fail the image build with no retry.
ENV npm_config_fetch_retries=5 \
npm_config_fetch_retry_mintimeout=20000 \
npm_config_fetch_retry_maxtimeout=120000 \
npm_config_fetch_timeout=600000
WORKDIR /home/superset-websocket
COPY . ./
@@ -24,7 +31,12 @@ RUN npm ci && \
FROM node:22-alpine
ENV NODE_ENV=production
# Retry npm-registry fetches so a transient blip doesn't fail the build.
ENV NODE_ENV=production \
npm_config_fetch_retries=5 \
npm_config_fetch_retry_mintimeout=20000 \
npm_config_fetch_retry_maxtimeout=120000 \
npm_config_fetch_timeout=600000
WORKDIR /home/superset-websocket
COPY --from=build /home/superset-websocket/dist ./dist

View File

@@ -23,6 +23,7 @@ from zipfile import BadZipfile, is_zipfile, ZipFile
import pandas as pd
import pyarrow.parquet as pq
from flask import current_app
from flask_babel import lazy_gettext as _
from pyarrow.lib import ArrowException
from werkzeug.datastructures import FileStorage
@@ -33,10 +34,47 @@ from superset.commands.database.uploaders.base import (
FileMetadata,
ReaderOptions,
)
from superset.exceptions import SupersetException
from superset.utils.core import check_is_safe_zip
logger = logging.getLogger(__name__)
def _check_file_size(file: FileStorage) -> None:
"""
Reject an uploaded file whose raw (on-the-wire) size exceeds the configured
limit before its contents are buffered into memory.
This is complementary to the ZIP decompression-ratio guard: it bounds the
raw bytes accepted regardless of whether the payload is compressed.
:param file: The uploaded file to check.
:throws DatabaseUploadFailed: if the file exceeds the configured limit.
"""
max_size = current_app.config.get("UPLOAD_MAX_FILE_SIZE_BYTES")
if not max_size:
return
stream = file.stream
try:
current_position = stream.tell()
stream.seek(0, 2) # seek to end
size = stream.tell()
stream.seek(current_position)
except (AttributeError, OSError):
# If the stream is not seekable we cannot determine the size cheaply;
# skip the check and rely on downstream guards.
return
if size > max_size:
raise DatabaseUploadFailed(
_(
"File size %(size)s bytes exceeds the maximum allowed "
"upload size of %(max_size)s bytes",
size=size,
max_size=max_size,
)
)
class ColumnarReaderOptions(ReaderOptions, total=False):
columns_read: list[str]
@@ -80,6 +118,7 @@ class ColumnarReader(BaseDataReader):
:param file: The file to yield files from.
:return: A generator that yields files.
"""
_check_file_size(file)
file_suffix = Path(file.filename).suffix
if not file_suffix:
raise DatabaseUploadFailed(_("Unexpected no file extension found"))
@@ -89,6 +128,12 @@ class ColumnarReader(BaseDataReader):
raise DatabaseUploadFailed(_("Not a valid ZIP file"))
try:
with ZipFile(file) as zip_file:
# guard against decompression bombs before reading entries,
# mirroring the importer path
try:
check_is_safe_zip(zip_file)
except SupersetException as ex:
raise DatabaseUploadFailed(str(ex)) from ex
# check if all file types are of the same extension
file_suffixes = {Path(name).suffix for name in zip_file.namelist()}
if len(file_suffixes) > 1:

View File

@@ -14,21 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from flask_appbuilder import expose
from flask_appbuilder.security.decorators import has_access, permission_name
from superset.constants import MODEL_VIEW_RW_METHOD_PERMISSION_MAP
from superset.superset_typing import FlaskResponse
from superset.views.base import BaseSupersetView
class ExtensionsView(BaseSupersetView):
route_base = "/extensions"
class_permission_name = "Extensions"
method_permission_name = MODEL_VIEW_RW_METHOD_PERMISSION_MAP
@expose("/list/")
@has_access
@permission_name("read")
def list(self) -> FlaskResponse:
return super().render_app_template()

View File

@@ -51,7 +51,11 @@ from sqlalchemy.orm.query import Query
from superset.advanced_data_type.plugins.internet_address import internet_address
from superset.advanced_data_type.plugins.internet_port import internet_port
from superset.advanced_data_type.types import AdvancedDataType
from superset.constants import CHANGE_ME_GUEST_TOKEN_JWT_SECRET, CHANGE_ME_SECRET_KEY
from superset.constants import (
CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET,
CHANGE_ME_GUEST_TOKEN_JWT_SECRET,
CHANGE_ME_SECRET_KEY,
)
from superset.jinja_context import BaseTemplateProcessor
from superset.key_value.types import JsonKeyValueCodec
from superset.stats_logger import DummyStatsLogger
@@ -178,6 +182,11 @@ VIZ_TIME_COMPARE_MAX = 50
# amplification from a single multi-layer request.
DECK_MULTI_MAX_SLICES = 50
# Upper bound on the page size accepted by the generic DAO list/pagination layer.
# Caps how many rows a single paginated query can request, regardless of the
# requested page size, to keep query result sets bounded.
SQLALCHEMY_DAO_MAX_PAGE_SIZE = 1000
# SupersetClient HTTP retry configuration
# Controls retry behavior for all HTTP requests made through SupersetClient
# This helps handle transient server errors (like 502 Bad Gateway) automatically
@@ -1131,6 +1140,12 @@ SCREENSHOT_TILED_VIEWPORT_HEIGHT = 2000 # Height of each tile in pixels
UPLOAD_FOLDER = BASE_DIR + "/static/uploads/"
UPLOAD_CHUNK_SIZE = 4096
# Upper bound, in bytes, on the size of a single uploaded data file (e.g. CSV,
# Excel, columnar). Files larger than this are rejected before their contents
# are buffered into memory, keeping the resources consumed by a single upload
# bounded. Set to ``None`` to disable the check. Defaults to 100 MB.
UPLOAD_MAX_FILE_SIZE_BYTES: int | None = 100 * 1024 * 1024
# ---------------------------------------------------
# Cache configuration
# ---------------------------------------------------
@@ -2355,7 +2370,7 @@ GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE: None | (Literal["None", "Lax", "Strict
None
)
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_DOMAIN = None
GLOBAL_ASYNC_QUERIES_JWT_SECRET = "test-secret-change-me" # noqa: S105
GLOBAL_ASYNC_QUERIES_JWT_SECRET = CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET
# Lifetime of the async-query JWT, in seconds. After this period the token
# expires and a fresh one is issued on the next request.
GLOBAL_ASYNC_QUERIES_JWT_EXPIRATION_SECONDS = int(timedelta(hours=1).total_seconds())

View File

@@ -29,6 +29,7 @@ EMPTY_STRING = "<empty string>"
CHANGE_ME_SECRET_KEY = "CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET" # noqa: S105
CHANGE_ME_GUEST_TOKEN_JWT_SECRET = "test-guest-secret-change-me" # noqa: S105
CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET = "test-secret-change-me" # noqa: S105
SKIP_VISIBILITY_FILTER_CLASSES = "_skip_visibility_filter_classes"

View File

@@ -33,6 +33,7 @@ from typing import (
)
import sqlalchemy as sa
from flask import current_app
from flask_appbuilder.models.filters import BaseFilter
from flask_appbuilder.models.sqla.interface import SQLAInterface
from pydantic import BaseModel, Field
@@ -772,7 +773,19 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
else:
query = query.order_by(asc(column))
page = page
page_size = max(page_size, 1)
# Clamp the page size to a sane range: at least 1, and no larger than
# the configured upper bound, to keep result sets bounded.
# Normalize the configured maximum to a positive integer so that a
# misconfigured value (non-int or <= 0) cannot produce a non-positive
# page size, which would break pagination or yield unbounded queries.
try:
max_page_size = int(
current_app.config.get("SQLALCHEMY_DAO_MAX_PAGE_SIZE", 1000)
)
except (TypeError, ValueError):
max_page_size = 1000
max_page_size = max(max_page_size, 1)
page_size = min(max(page_size, 1), max_page_size)
query = query.offset(page * page_size).limit(page_size)
items = query.all()
# If columns are specified, SQLAlchemy returns Row objects (not tuples or

View File

@@ -15,34 +15,41 @@
# specific language governing permissions and limitations
# under the License.
import mimetypes
import re
from io import BytesIO
from typing import Any
from flask import send_file
from flask.wrappers import Response
from flask_appbuilder.api import BaseApi, expose, protect, safe
from flask_appbuilder.api import expose, protect, safe
from superset.extensions.utils import (
build_extension_data,
get_extensions,
)
from superset.views.base_api import BaseSupersetApi
# Allowlist for publisher and name path parameters — alphanumeric, hyphens,
# underscores only. Rejects path-traversal attempts (../), URL-encoded slashes,
# and any other characters that could escape EXTENSIONS_PATH.
_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
class ExtensionsRestApi(BaseApi):
def _validate_segment(value: str) -> bool:
"""Return True if *value* is a safe publisher or name segment."""
return bool(_SEGMENT_RE.match(value))
class ExtensionsRestApi(BaseSupersetApi):
allow_browser_login = True
resource_name = "extensions"
def response(self, status_code: int, **kwargs: Any) -> Response:
"""Helper method to create JSON responses."""
from flask import jsonify
return jsonify(kwargs), status_code
def response_404(self) -> Response:
"""Helper method to create 404 responses."""
from flask import jsonify
return jsonify({"message": "Not found"}), 404
class_permission_name = "Extensions"
base_permissions = [
"can_get_list",
"can_get",
"can_content",
"can_info",
]
@expose("/_info", methods=("GET",))
@protect()
@@ -72,13 +79,13 @@ class ExtensionsRestApi(BaseApi):
@safe
@expose("/", methods=("GET",))
def get_list(self, **kwargs: Any) -> Response:
"""List all enabled extensions.
"""List all installed extensions.
---
get_list:
summary: List all enabled extensions.
summary: List all installed extensions.
responses:
200:
description: List of all enabled extensions
description: List of all installed extensions
content:
application/json:
schema:
@@ -158,7 +165,8 @@ class ExtensionsRestApi(BaseApi):
500:
$ref: '#/components/responses/500'
"""
# Reconstruct composite ID from publisher and name
if not _validate_segment(publisher) or not _validate_segment(name):
return self.response(400, message="Invalid publisher or name.")
composite_id = f"{publisher}.{name}"
extensions = get_extensions()
extension = extensions.get(composite_id)
@@ -210,7 +218,8 @@ class ExtensionsRestApi(BaseApi):
500:
$ref: '#/components/responses/500'
"""
# Reconstruct composite ID from publisher and name
if not _validate_segment(publisher) or not _validate_segment(name):
return self.response(400, message="Invalid publisher or name.")
composite_id = f"{publisher}.{name}"
extensions = get_extensions()
extension = extensions.get(composite_id)

View File

@@ -238,10 +238,10 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
manifest = extension.manifest
extension_data: dict[str, Any] = {
"id": manifest.id,
"publisher": manifest.publisher,
"name": extension.name,
"version": extension.version,
"description": manifest.description or "",
"dependencies": manifest.dependencies,
}
if manifest.frontend:
frontend = manifest.frontend

View File

@@ -37,7 +37,11 @@ from flask_compress import Compress
from flask_session import Session
from werkzeug.middleware.proxy_fix import ProxyFix
from superset.constants import CHANGE_ME_GUEST_TOKEN_JWT_SECRET, CHANGE_ME_SECRET_KEY
from superset.constants import (
CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET,
CHANGE_ME_GUEST_TOKEN_JWT_SECRET,
CHANGE_ME_SECRET_KEY,
)
from superset.databases.utils import make_url_safe
from superset.extensions import (
_event_logger,
@@ -173,7 +177,6 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
from superset.explore.api import ExploreRestApi
from superset.explore.form_data.api import ExploreFormDataRestApi
from superset.explore.permalink.api import ExplorePermalinkRestApi
from superset.extensions.view import ExtensionsView
from superset.importexport.api import ImportExportRestApi
from superset.queries.api import QueryRestApi
from superset.queries.saved_queries.api import SavedQueryRestApi
@@ -414,17 +417,6 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
category_icon="",
)
appbuilder.add_view(
ExtensionsView,
"Extensions",
label=_("Extensions"),
category="Manage",
category_label=_("Manage"),
menu_cond=lambda: feature_flag_manager.is_feature_enabled(
"ENABLE_EXTENSIONS"
),
)
appbuilder.add_view(
TaskModelView,
"Tasks",
@@ -691,6 +683,32 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
)
sys.exit(1)
def check_async_query_secret(self) -> None:
"""Refuse to start with the default async JWT secret when GAQ is enabled."""
if not feature_flag_manager.is_feature_enabled("GLOBAL_ASYNC_QUERIES"):
return
if (
self.config.get("GLOBAL_ASYNC_QUERIES_JWT_SECRET")
!= CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET
):
return
self._log_config_warning(
"GLOBAL_ASYNC_QUERIES is enabled but GLOBAL_ASYNC_QUERIES_JWT_SECRET "
"has not been changed from its default value.\n"
"The default value is publicly known and must be replaced before "
"running in production.\n"
"Set a strong random value (at least 32 bytes) in superset_config.py:\n"
" GLOBAL_ASYNC_QUERIES_JWT_SECRET = "
"'<output of: openssl rand -base64 42>'"
)
if self.superset_app.debug or self.superset_app.config["TESTING"] or is_test():
return
logger.error(
"Refusing to start: insecure GLOBAL_ASYNC_QUERIES_JWT_SECRET "
"with GLOBAL_ASYNC_QUERIES enabled"
)
sys.exit(1)
def configure_session(self) -> None:
if self.config["SESSION_SERVER_SIDE"]:
Session(self.superset_app)
@@ -776,6 +794,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
# conditionally
self.configure_feature_flags()
self.check_guest_token_secret()
self.check_async_query_secret()
self.configure_db_encrypt()
self.setup_db()
@@ -1011,6 +1030,16 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
def configure_async_queries(self) -> None:
if feature_flag_manager.is_feature_enabled("GLOBAL_ASYNC_QUERIES"):
# In production, check_async_query_secret() already aborts startup when
# the default secret is present, so this branch is never reached with it.
# In debug/testing the check only warns, so skip async-query init here to
# avoid AsyncQueryManager.init_app() hard-failing on the too-short default
# secret and crashing startup despite the warn-only intent.
if (
self.config.get("GLOBAL_ASYNC_QUERIES_JWT_SECRET")
== CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET
):
return
async_query_manager_factory.init_app(self.superset_app)
def configure_task_manager(self) -> None:

View File

@@ -0,0 +1,47 @@
# 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.
"""Add extension_settings table for chatbot admin selection and enable/disable.
Revision ID: b2c3d4e5f6a7
Revises: 33d7e0e21daa
Create Date: 2026-05-25 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "b2c3d4e5f6a7"
down_revision = "33d7e0e21daa"
def upgrade() -> None:
op.create_table(
"extension_settings",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("active_chatbot_id", sa.String(250), nullable=True),
)
op.create_table(
"extension_enabled",
sa.Column("extension_id", sa.String(250), primary_key=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
)
def downgrade() -> None:
op.drop_table("extension_enabled")
op.drop_table("extension_settings")

View File

@@ -0,0 +1,43 @@
# 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.
"""Drop extension_enabled table (ExtensionEnabled model removed in chatbot SIP).
Revision ID: d1e2f3a4b5c6
Revises: b2c3d4e5f6a7
Create Date: 2026-06-09 00:00:00.000000
"""
import sqlalchemy as sa
from superset.migrations.shared.utils import create_table, drop_table
# revision identifiers, used by Alembic.
revision = "d1e2f3a4b5c6"
down_revision = "b2c3d4e5f6a7"
def upgrade() -> None:
drop_table("extension_enabled")
def downgrade() -> None:
create_table(
"extension_enabled",
sa.Column("extension_id", sa.String(250), primary_key=True),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
)

View File

@@ -0,0 +1,46 @@
# 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.
"""Drop extension_settings table (ExtensionSettings model removed in chatbot SIP).
The active chatbot is now resolved purely from the view registry (last-loaded
wins), so the admin-pin settings table is no longer read or written.
Revision ID: e2f3a4b5c6d7
Revises: d1e2f3a4b5c6
Create Date: 2026-06-10 00:00:00.000000
"""
import sqlalchemy as sa
from superset.migrations.shared.utils import create_table, drop_table
# revision identifiers, used by Alembic.
revision = "e2f3a4b5c6d7"
down_revision = "d1e2f3a4b5c6"
def upgrade() -> None:
drop_table("extension_settings")
def downgrade() -> None:
create_table(
"extension_settings",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("active_chatbot_id", sa.String(250), nullable=True),
)

View File

@@ -596,6 +596,21 @@ class TabState(AuditMixinNullable, ExtraJSONMixin, Model):
saved_query = relationship("SavedQuery", foreign_keys=[saved_query_id])
def to_dict(self) -> dict[str, Any]:
latest_query = None
try:
if self.latest_query:
latest_query = self.latest_query.to_dict()
except Exception:
query = self.__dict__.get("latest_query")
logger.warning(
"Failed to load/serialize latest_query for tab state %s "
"(latest_query_id=%s, query_status=%s)",
self.id,
self.latest_query_id,
getattr(query, "status", "N/A"),
exc_info=True,
)
return {
"id": self.id,
"user_id": self.user_id,
@@ -607,7 +622,7 @@ class TabState(AuditMixinNullable, ExtraJSONMixin, Model):
"table_schemas": [ts.to_dict() for ts in self.table_schemas],
"sql": self.sql,
"query_limit": self.query_limit,
"latest_query": self.latest_query.to_dict() if self.latest_query else None,
"latest_query": latest_query,
"autorun": self.autorun,
"template_params": self.template_params,
"hide_left_bar": self.hide_left_bar,

View File

@@ -271,6 +271,19 @@ def execute_query( # pylint: disable=too-many-statements, too-many-locals # no
log_params,
)
db.session.commit()
# Eagerly reload query attributes so no lazy-load triggers a new
# metadata DB connection during the (potentially long) cursor
# execution. With NullPool each lazy-load opens a fresh connection
# that stays idle for the query duration; if the query runs longer
# than the DB's idle_in_transaction_session_timeout the connection
# is killed, leaving the query stuck in "running" state forever.
db.session.expire_on_commit = False
try:
db.session.refresh(query)
_ = query.database
db.session.commit()
finally:
db.session.expire_on_commit = True
with event_logger.log_context(
action="execute_sql",
database=database,

View File

@@ -17,10 +17,12 @@
import io
import tempfile
from typing import Any
from zipfile import ZipFile
from unittest.mock import patch
from zipfile import ZIP_DEFLATED, ZipFile
import numpy as np
import pytest
from flask import current_app
from werkzeug.datastructures import FileStorage
from superset.commands.database.exceptions import DatabaseUploadFailed
@@ -230,6 +232,87 @@ def test_columnar_reader_bad_zip():
assert str(ex.value) == "Not a valid ZIP file"
def _make_high_ratio_zip() -> io.BytesIO:
"""
Build a ZIP whose single entry has a very high decompression ratio,
well above the default ``ZIP_FILE_MAX_COMPRESS_RATIO`` threshold.
"""
buffer = io.BytesIO()
with ZipFile(buffer, "w", ZIP_DEFLATED) as zip_file:
# A megabyte of zeros compresses to roughly a kilobyte, far exceeding
# the default 200:1 ratio guard.
zip_file.writestr("test.parquet", b"\x00" * (1024 * 1024))
buffer.seek(0)
return buffer
def test_columnar_reader_unsafe_zip_rejected():
reader = ColumnarReader(
options=ColumnarReaderOptions(),
)
unsafe_zip = _make_high_ratio_zip()
with pytest.raises(DatabaseUploadFailed) as ex:
reader.file_to_dataframe(FileStorage(unsafe_zip, "test.zip"))
assert "compress ratio above allowed threshold" in str(ex.value)
def test_columnar_reader_unsafe_zip_rejected_in_metadata():
reader = ColumnarReader(
options=ColumnarReaderOptions(),
)
unsafe_zip = _make_high_ratio_zip()
with pytest.raises(DatabaseUploadFailed) as ex:
reader.file_metadata(FileStorage(unsafe_zip, "test.zip"))
assert "compress ratio above allowed threshold" in str(ex.value)
def test_columnar_reader_oversize_file_rejected():
reader = ColumnarReader(
options=ColumnarReaderOptions(),
)
file = create_columnar_file(COLUMNAR_DATA)
file.stream.seek(0, 2)
file_size = file.stream.tell()
file.stream.seek(0)
with patch.dict(
current_app.config,
{"UPLOAD_MAX_FILE_SIZE_BYTES": file_size - 1},
):
with pytest.raises(DatabaseUploadFailed) as ex:
reader.file_to_dataframe(file)
assert "exceeds the maximum allowed upload size" in str(ex.value)
def test_columnar_reader_oversize_file_rejected_in_metadata():
reader = ColumnarReader(
options=ColumnarReaderOptions(),
)
file = create_columnar_file(COLUMNAR_DATA)
file.stream.seek(0, 2)
file_size = file.stream.tell()
file.stream.seek(0)
with patch.dict(
current_app.config,
{"UPLOAD_MAX_FILE_SIZE_BYTES": file_size - 1},
):
with pytest.raises(DatabaseUploadFailed) as ex:
reader.file_metadata(file)
assert "exceeds the maximum allowed upload size" in str(ex.value)
def test_columnar_reader_under_limit_accepted():
reader = ColumnarReader(
options=ColumnarReaderOptions(),
)
file = create_columnar_file(COLUMNAR_DATA)
with patch.dict(
current_app.config,
{"UPLOAD_MAX_FILE_SIZE_BYTES": 100 * 1024 * 1024},
):
df = reader.file_to_dataframe(file)
assert len(df) == 3
def test_columnar_reader_metadata():
reader = ColumnarReader(
options=ColumnarReaderOptions(),

View File

@@ -258,3 +258,54 @@ def test_find_by_ids_none_id_column():
results = TestDAO.find_by_ids([1, 2, 3])
assert results == []
def _list_with_page_size(page_size: int) -> Mock:
"""
Run ``BaseDAO.list`` with a mocked query chain and return the mock query so
the ``.limit()`` call (the effective page size) can be inspected.
"""
mock_query = Mock()
# Every chainable call returns the same mock so the chain is easy to inspect
mock_query.options.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.order_by.return_value = mock_query
mock_query.offset.return_value = mock_query
mock_query.limit.return_value = mock_query
mock_query.count.return_value = 0
mock_query.all.return_value = []
mock_data_model = Mock()
mock_data_model.session.query.return_value = mock_query
with (
patch("superset.daos.base.SQLAInterface", return_value=mock_data_model),
patch.object(TestDAO, "_apply_base_filter", side_effect=lambda q, **_: q),
):
TestDAO.list(page=0, page_size=page_size)
return mock_query
def test_list_page_size_oversized_is_clamped():
"""An oversized page_size is clamped to the configured maximum."""
from flask import current_app
max_page_size = current_app.config.get("SQLALCHEMY_DAO_MAX_PAGE_SIZE", 1000)
mock_query = _list_with_page_size(max_page_size + 5000)
mock_query.limit.assert_called_once_with(max_page_size)
def test_list_page_size_normal_unaffected():
"""A page_size within the allowed range is passed through unchanged."""
mock_query = _list_with_page_size(50)
mock_query.limit.assert_called_once_with(50)
def test_list_page_size_below_one_is_floored():
"""A non-positive page_size is floored to 1 (existing semantics)."""
mock_query = _list_with_page_size(0)
mock_query.limit.assert_called_once_with(1)

View File

@@ -0,0 +1,46 @@
# 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.
"""Unit tests for the extensions REST API."""
from __future__ import annotations
from superset.extensions.api import _validate_segment
# ---------------------------------------------------------------------------
# _validate_segment helper — used by GET /api/v1/extensions/<publisher>/<name>
# and GET /api/v1/extensions/<publisher>/<name>/<file>
# ---------------------------------------------------------------------------
def test_validate_segment_accepts_alphanumeric() -> None:
assert _validate_segment("acme") is True
assert _validate_segment("my-ext") is True
assert _validate_segment("my_ext") is True
assert _validate_segment("Ext123") is True
def test_validate_segment_rejects_traversal() -> None:
assert _validate_segment("..") is False
assert _validate_segment("../etc") is False
assert _validate_segment("acme/bad") is False
assert _validate_segment("acme%2Fbad") is False
assert _validate_segment("") is False
def test_validate_segment_rejects_dots() -> None:
assert _validate_segment("acme.corp") is False

View File

@@ -44,7 +44,6 @@ def test_extension_config_minimal():
assert config.name == "my-extension"
assert config.displayName == "My Extension"
assert config.version == "0.0.0"
assert config.dependencies == []
assert config.permissions == []
assert config.backend is None
@@ -59,7 +58,6 @@ def test_extension_config_full():
"version": "1.0.0",
"license": "Apache-2.0",
"description": "A query insights extension",
"dependencies": ["other-extension"],
"permissions": ["can_read", "can_view"],
"backend": {
"files": ["backend/src/query_insights/**/*.py"],
@@ -72,7 +70,6 @@ def test_extension_config_full():
assert config.version == "1.0.0"
assert config.license == "Apache-2.0"
assert config.description == "A query insights extension"
assert config.dependencies == ["other-extension"]
assert config.permissions == ["can_read", "can_view"]
assert config.backend is not None
assert config.backend.files == ["backend/src/query_insights/**/*.py"]

View File

@@ -0,0 +1,16 @@
# 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.

View File

@@ -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.
"""Unit tests for the default async JWT secret startup check."""
from unittest.mock import MagicMock
import pytest
from pytest_mock import MockerFixture
from superset.constants import CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET
from superset.initialization import SupersetAppInitializer
def _make_initializer(
secret: str,
*,
debug: bool = False,
testing: bool = False,
) -> SupersetAppInitializer:
initializer = SupersetAppInitializer.__new__(SupersetAppInitializer)
app = MagicMock()
app.debug = debug
app.config = {
"GLOBAL_ASYNC_QUERIES_JWT_SECRET": secret,
"TESTING": testing,
}
initializer.superset_app = app
initializer.config = app.config
return initializer
def test_check_async_query_secret_rejects_default_in_production(
mocker: MockerFixture,
) -> None:
"""A default async secret with GAQ enabled refuses to start in production."""
mocker.patch(
"superset.initialization.feature_flag_manager.is_feature_enabled",
return_value=True,
)
mocker.patch("superset.initialization.is_test", return_value=False)
initializer = _make_initializer(CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET)
with pytest.raises(SystemExit):
initializer.check_async_query_secret()
def test_check_async_query_secret_allows_overridden_secret(
mocker: MockerFixture,
) -> None:
"""A non-default async secret does not block startup."""
mocker.patch(
"superset.initialization.feature_flag_manager.is_feature_enabled",
return_value=True,
)
mocker.patch("superset.initialization.is_test", return_value=False)
initializer = _make_initializer("a-strong-random-secret-value-1234567890")
# Should not raise.
initializer.check_async_query_secret()
def test_check_async_query_secret_skipped_when_gaq_disabled(
mocker: MockerFixture,
) -> None:
"""The check is a no-op when GLOBAL_ASYNC_QUERIES is disabled."""
mocker.patch(
"superset.initialization.feature_flag_manager.is_feature_enabled",
return_value=False,
)
mocker.patch("superset.initialization.is_test", return_value=False)
initializer = _make_initializer(CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET)
# Should not raise even with the default secret.
initializer.check_async_query_secret()
def test_check_async_query_secret_warns_only_in_debug(
mocker: MockerFixture,
) -> None:
"""In debug the default secret warns but does not exit (matches SECRET_KEY)."""
mocker.patch(
"superset.initialization.feature_flag_manager.is_feature_enabled",
return_value=True,
)
mocker.patch("superset.initialization.is_test", return_value=False)
initializer = _make_initializer(
CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET, debug=True
)
# Should not raise in debug mode.
initializer.check_async_query_secret()
def test_configure_async_queries_skips_init_with_default_secret(
mocker: MockerFixture,
) -> None:
"""In warn-only modes, async init is skipped so the short default secret cannot
crash startup via AsyncQueryManager.init_app()'s length check."""
mocker.patch(
"superset.initialization.feature_flag_manager.is_feature_enabled",
return_value=True,
)
factory = mocker.patch("superset.initialization.async_query_manager_factory")
initializer = _make_initializer(
CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET, debug=True
)
initializer.configure_async_queries()
factory.init_app.assert_not_called()
def test_configure_async_queries_inits_with_overridden_secret(
mocker: MockerFixture,
) -> None:
"""A non-default secret proceeds to initialize the async query manager."""
mocker.patch(
"superset.initialization.feature_flag_manager.is_feature_enabled",
return_value=True,
)
factory = mocker.patch("superset.initialization.async_query_manager_factory")
initializer = _make_initializer("a-strong-random-secret-value-1234567890")
initializer.configure_async_queries()
factory.init_app.assert_called_once_with(initializer.superset_app)

View File

@@ -57,6 +57,9 @@ def test_execute_query(mocker: MockerFixture, app: None) -> None:
cursor = mocker.MagicMock()
SupersetResultSet = mocker.patch("superset.sql_lab.SupersetResultSet") # noqa: N806
# Mock db.session.refresh to avoid AttributeError during session refresh
mocker.patch("superset.sql_lab.db.session.refresh", return_value=None)
execute_query(query, cursor=cursor, log_params={})
db_engine_spec.execute_with_cursor.assert_called_with(