Compare commits

...

4 Commits

Author SHA1 Message Date
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
Enzo Martellucci
380e70060b feat(extensions): define the superset.chatbot contribution point (#40439) 2026-06-08 22:50:34 +02:00
60 changed files with 2741 additions and 312 deletions

View File

@@ -69,10 +69,6 @@ class BaseExtension(BaseModel):
default=None,
description="Extension description",
)
dependencies: list[str] = Field(
default_factory=list,
description="List of extension IDs this extension depends on",
)
permissions: list[str] = Field(
default_factory=list,
description="Permissions required by this extension",

View File

@@ -179,7 +179,6 @@ def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest:
displayName=extension.displayName,
version=extension.version,
permissions=extension.permissions,
dependencies=extension.dependencies,
frontend=frontend,
backend=backend,
)

View File

@@ -283,7 +283,6 @@ def test_build_manifest_creates_correct_manifest_structure(
"displayName": "Test Extension",
"version": "1.0.0",
"permissions": ["read_data"],
"dependencies": ["some_dep"],
}
extension_json = isolated_filesystem / "extension.json"
extension_json.write_text(json.dumps(extension_data))
@@ -297,7 +296,6 @@ def test_build_manifest_creates_correct_manifest_structure(
assert manifest.displayName == "Test Extension"
assert manifest.version == "1.0.0"
assert manifest.permissions == ["read_data"]
assert manifest.dependencies == ["some_dep"]
# Verify frontend section
assert manifest.frontend is not None
@@ -330,7 +328,6 @@ def test_build_manifest_handles_minimal_extension(isolated_filesystem):
assert manifest.displayName == "Minimal Extension"
assert manifest.version == "0.1.0"
assert manifest.permissions == []
assert manifest.dependencies == [] # Default empty list
assert manifest.frontend is None
assert manifest.backend is None

View File

@@ -18,6 +18,22 @@
"types": "./lib/authentication/index.d.ts",
"default": "./lib/authentication/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

@@ -213,18 +213,55 @@ 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
* by registering commands, views, menus, and editors as module-level side effects.
*/
export interface Extension {
/** List of other extensions that this extension depends on */
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

@@ -43,6 +43,9 @@ export type SqlLabLocation =
| 'results'
| 'queryHistory';
/** Valid locations within the app shell (persist across all routes). */
export type AppLocation = 'chatbot';
/**
* Nested structure for view contributions by scope and location.
* @example
@@ -55,6 +58,7 @@ export type SqlLabLocation =
*/
export interface ViewContributions {
sqllab?: Partial<Record<SqlLabLocation, View[]>>;
app?: Partial<Record<AppLocation, View[]>>;
}
/**

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

@@ -19,9 +19,13 @@
export * as common from './common';
export * as authentication from './authentication';
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

@@ -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' },
@@ -69,6 +75,15 @@ export interface View {
* () => <ResultStatsPanel />,
* );
* ```
*
* @example Chatbot bubble (`core.chatbot` — singleton, host renders one)
* ```typescript
* views.registerView(
* { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' },
* 'core.chatbot',
* () => <ChatbotApp />,
* );
* ```
*/
export declare function registerView(
view: View,

View File

@@ -519,7 +519,8 @@ const Select = forwardRef(
handleSelectAll();
}}
>
{t('Select all')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
{t('Select all')}{' '}
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
</Button>
<Button
type="link"
@@ -536,7 +537,8 @@ const Select = forwardRef(
handleDeselectAll();
}}
>
{t('Clear')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
{t('Clear')}{' '}
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
</Button>
</StyledBulkActionsContainer>
),

View File

@@ -182,10 +182,7 @@ testWithAssets(
// Now track POST /api/v1/chart/data requests around Clear All
const postsAfterClearAll: string[] = [];
const handler = (req: any) => {
if (
req.url().includes('/api/v1/chart/data') &&
req.method() === 'POST'
) {
if (req.url().includes('/api/v1/chart/data') && req.method() === 'POST') {
postsAfterClearAll.push(req.url());
}
};

View File

@@ -0,0 +1,125 @@
/**
* 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, screen } from 'spec/helpers/testing-library';
import { SupersetClient } from '@superset-ui/core';
import { views } from 'src/core';
import { loadExtensionSettings } from 'src/core/extensions';
import { CHATBOT_LOCATION } from 'src/views/contributions';
import ChatbotMount from '.';
const disposables: Array<{ dispose: () => void }> = [];
beforeEach(async () => {
// The settings store is a module singleton; reset it to the empty default
// (no admin pin) before each test by loading from a mocked API response.
jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: { active_chatbot_id: null } },
} as any);
await loadExtensionSettings();
});
afterEach(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
jest.restoreAllMocks();
});
test('renders nothing when no chatbot extension is registered', async () => {
render(<ChatbotMount />);
// Wait a tick for the settings load to resolve; the corner must stay empty
// even after the gate opens (no chatbot registered → nothing to render).
await Promise.resolve();
expect(screen.queryByTestId('chatbot-mount')).not.toBeInTheDocument();
});
test('renders the registered chatbot inside the fixed mount slot', async () => {
const provider = () => <div>My Chatbot Bubble</div>;
disposables.push(
views.registerView(
{ id: 'core.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
provider,
),
);
render(<ChatbotMount />);
// findBy* awaits the re-render after the initial settings load resolves.
expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument();
expect(screen.getByText('My Chatbot Bubble')).toBeInTheDocument();
});
test('renders only the first-to-register chatbot when several are installed', async () => {
const firstProvider = () => <div>First Bubble</div>;
const secondProvider = () => <div>Second Bubble</div>;
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First Chatbot' },
CHATBOT_LOCATION,
firstProvider,
),
views.registerView(
{ id: 'second.chatbot', name: 'Second Chatbot' },
CHATBOT_LOCATION,
secondProvider,
),
);
render(<ChatbotMount />);
expect(await screen.findByText('First Bubble')).toBeInTheDocument();
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
});
test('isolates a failing chatbot so it does not crash the host', async () => {
const FailingChatbot = () => {
throw new Error('chatbot blew up');
};
disposables.push(
views.registerView(
{ id: 'core.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
() => <FailingChatbot />,
),
);
// The host-owned error boundary catches the failure; render does not throw.
expect(() => render(<ChatbotMount />)).not.toThrow();
// The mount slot still renders post-gate (the boundary lives inside it);
// awaiting it confirms the provider was actually exercised and contained.
expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument();
});
test('isolates a chatbot whose provider function itself throws', async () => {
disposables.push(
views.registerView(
{ id: 'core.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
() => {
throw new Error('provider blew up');
},
),
);
// ChatbotRenderer wraps provider() in a component so ErrorBoundary catches
// synchronous throws from the provider function, not just from its output.
expect(() => render(<ChatbotMount />)).not.toThrow();
expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument();
});

View File

@@ -0,0 +1,124 @@
/**
* 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,
useEffect,
useMemo,
useRef,
useState,
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 { getActiveChatbot } from 'src/core/chatbot';
import { subscribeToRegistry, getRegistryVersion } from 'src/core/views';
import {
getExtensionSettingsSnapshot,
loadExtensionSettings,
subscribeToExtensionSettings,
} from 'src/core/extensions';
const CHATBOT_EDGE_MARGIN = 24;
/**
* Wraps the chatbot provider in a React component so that ErrorBoundary can
* catch synchronous throws from the provider function itself. Calling
* `provider()` inline (e.g. `{activeChatbot.provider()}`) would throw outside
* React's render boundary and crash the host.
*/
const ChatbotRenderer = ({ provider }: { provider: () => ReactElement }) =>
provider();
const ChatbotMount = () => {
const theme = useTheme();
// Notify once per mount; a crash can re-render and would otherwise re-toast.
const crashNotified = useRef(false);
// Defer chatbot resolution until the first settings load resolves. Otherwise
// the initial empty-default snapshot (no pin) would briefly resolve the
// first-registered chatbot even when the DB pins a different one, mounting
// the wrong provider until the async settings response arrives.
const [settingsLoaded, setSettingsLoaded] = useState(false);
// The active chatbot is a function of two host-owned stores: the admin
// settings (active chatbot id) and the view registry (which chatbots are
// registered). Both are read via useSyncExternalStore so this re-resolves
// when either changes — no local copy of the settings state.
const settings = useSyncExternalStore(
subscribeToExtensionSettings,
getExtensionSettingsSnapshot,
);
const registryVersion = useSyncExternalStore(
subscribeToRegistry,
getRegistryVersion,
);
useEffect(() => {
// Settings fetch failure is non-fatal: the store keeps its empty default,
// which getActiveChatbot treats as "no admin pin" (falls back to the
// first-registered chatbot). Either way, unblock rendering once the request
// settles so a failed fetch never permanently hides the chatbot.
loadExtensionSettings()
.catch(() => {})
.finally(() => setSettingsLoaded(true));
}, []);
const activeChatbot = useMemo(
() => getActiveChatbot(settings.active_chatbot_id),
[settings, registryVersion],
);
if (!settingsLoaded || !activeChatbot) {
return null;
}
return (
<div
data-test="chatbot-mount"
css={css`
position: fixed;
right: ${CHATBOT_EDGE_MARGIN}px;
bottom: ${CHATBOT_EDGE_MARGIN}px;
/* Above dashboard content and the toast layer, below modal dialogs. */
z-index: ${theme.zIndexPopupBase + 2};
`}
>
<ErrorBoundary
showMessage={false}
onError={(error: Error) => {
// Fault isolation (SIP §4.5): contain the crash, log it, surface a
// one-time notification, and leave the corner empty rather than
// parking a persistent error card.
logging.error('[chatbot] provider crashed', error);
if (!crashNotified.current) {
crashNotified.current = true;
store.dispatch(addDangerToast(t('The chatbot failed to load.')));
}
}}
>
<ChatbotRenderer provider={activeChatbot.provider} />
</ErrorBoundary>
</div>
);
};
export default ChatbotMount;

View File

@@ -0,0 +1,132 @@
/**
* 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 React from 'react';
import { views } from 'src/core/views';
import { CHATBOT_LOCATION } from 'src/views/contributions';
import { getActiveChatbot } from './index';
const disposables: Array<{ dispose: () => void }> = [];
afterEach(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
});
test('getActiveChatbot returns undefined when no chatbot is registered', () => {
expect(getActiveChatbot()).toBeUndefined();
});
test('getActiveChatbot resolves the single registered chatbot', () => {
const provider = () => React.createElement('div', null, 'Chatbot');
disposables.push(
views.registerView(
{ id: 'core.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
provider,
),
);
const active = getActiveChatbot();
expect(active).toEqual({ id: 'core.chatbot', provider });
});
test('getActiveChatbot picks the first-to-register when multiple are installed', () => {
const firstProvider = () => React.createElement('div', null, 'First');
const secondProvider = () => React.createElement('div', null, 'Second');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First Chatbot' },
CHATBOT_LOCATION,
firstProvider,
),
views.registerView(
{ id: 'second.chatbot', name: 'Second Chatbot' },
CHATBOT_LOCATION,
secondProvider,
),
);
const active = getActiveChatbot();
expect(active?.id).toBe('first.chatbot');
expect(active?.provider).toBe(firstProvider);
});
test('getActiveChatbot ignores views registered at other locations', () => {
const provider = () => React.createElement('div', null, 'Panel');
disposables.push(
views.registerView(
{ id: 'some.panel', name: 'Some Panel' },
'sqllab.panels',
provider,
),
);
expect(getActiveChatbot()).toBeUndefined();
});
test('getActiveChatbot stops resolving a chatbot once it is disposed', () => {
const provider = () => React.createElement('div', null, 'Chatbot');
const disposable = views.registerView(
{ id: 'core.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
provider,
);
expect(getActiveChatbot()?.id).toBe('core.chatbot');
disposable.dispose();
expect(getActiveChatbot()).toBeUndefined();
});
test('getActiveChatbot honours the admin-pinned selection', () => {
const firstProvider = () => React.createElement('div', null, 'First');
const secondProvider = () => React.createElement('div', null, 'Second');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First Chatbot' },
CHATBOT_LOCATION,
firstProvider,
),
views.registerView(
{ id: 'second.chatbot', name: 'Second Chatbot' },
CHATBOT_LOCATION,
secondProvider,
),
);
const active = getActiveChatbot('second.chatbot');
expect(active?.id).toBe('second.chatbot');
expect(active?.provider).toBe(secondProvider);
});
test('getActiveChatbot falls back to first-registered when pinned id is unknown', () => {
const provider = () => React.createElement('div', null, 'First');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First Chatbot' },
CHATBOT_LOCATION,
provider,
),
);
// 'stale.chatbot' was once the admin pin but is no longer registered.
const active = getActiveChatbot('stale.chatbot');
expect(active?.id).toBe('first.chatbot');
});

View File

@@ -0,0 +1,79 @@
/**
* 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-internal resolver for the exclusive `core.chatbot`
* contribution area.
*
* `core.chatbot` is a singleton contribution area: multiple chatbot
* extensions may register a view there, but the host renders exactly one.
* This module owns the host-side selection policy.
*
* This is host-internal infrastructure — it is NOT part of the public
* `@apache-superset/core` API. Extensions register via the public
* `views.registerView()`; only the host resolves which one is active.
*/
import { ReactElement } from 'react';
import { CHATBOT_LOCATION } from 'src/views/contributions';
import { getRegisteredViewIds, getViewProvider } from 'src/core/views';
/**
* The resolved active chatbot: a view id paired with its renderable provider.
*/
export interface ActiveChatbot {
/** The registered view id of the selected chatbot. */
id: string;
/** The provider that renders the chatbot's React element. */
provider: () => ReactElement;
}
/**
* Resolves which single chatbot extension is currently active.
*
* Selection policy:
* - If no chatbot is registered, returns `undefined` — the corner stays empty.
* - If `adminSelectedId` matches a registered chatbot, that one wins.
* - Otherwise the first-registered chatbot is used as a fallback.
* The active chatbot pin is set only via the backend DB; when no pin is set
* (active_chatbot_id is null), the fallback is the first-registered chatbot.
*
* @param adminSelectedId The id stored in the DB "Default chatbot" setting, if any.
* @returns The active chatbot's id and provider, or `undefined` if none.
*/
export const getActiveChatbot = (
adminSelectedId?: string | null,
): ActiveChatbot | undefined => {
const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION);
if (registeredIds.length === 0) {
return undefined;
}
// When the DB pin names a registered candidate, use it; otherwise fall back
// to the first registered chatbot in registration order.
// `getRegisteredViewIds` and `getViewProvider` read the same synchronous
// registry maps, so a candidate id always has a live provider; the final
// guard is cheap defensiveness, not a fallback path.
const selectedId =
adminSelectedId && registeredIds.includes(adminSelectedId)
? adminSelectedId
: registeredIds[0];
const provider = getViewProvider(CHATBOT_LOCATION, selectedId);
return provider ? { id: selectedId, provider } : undefined;
};

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 (SIP §10/§11); 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

@@ -17,6 +17,7 @@
* under the License.
*/
import { extensions as extensionsApi } from '@apache-superset/core';
import { SupersetClient } from '@superset-ui/core';
import ExtensionsLoader from 'src/extensions/ExtensionsLoader';
const getExtension: typeof extensionsApi.getExtension = id =>
@@ -29,3 +30,61 @@ export const extensions: typeof extensionsApi = {
getExtension,
getAllExtensions,
};
/**
* Deployment-wide extension admin settings. The keys are snake_case to match
* the `/api/v1/extensions/settings` wire shape this store loads from.
* Settings are read-only from the frontend; the admin write path has been
* removed in favour of direct backend configuration.
*/
export type ExtensionSettings = {
active_chatbot_id: string | null;
};
const SETTINGS_ENDPOINT = '/api/v1/extensions/settings';
const EMPTY_SETTINGS: ExtensionSettings = {
active_chatbot_id: null,
};
/**
* Single module-level store for extension admin settings. The chatbot mount
* reads this one source via `useSyncExternalStore` so it re-resolves when the
* store is updated — no bespoke second notification channel needed.
*/
let settings: ExtensionSettings = EMPTY_SETTINGS;
const settingsListeners = new Set<() => void>();
const emitSettingsChange = (): void => {
settingsListeners.forEach(fn => fn());
};
/** Subscribe to settings changes (for `useSyncExternalStore`). */
export const subscribeToExtensionSettings = (
listener: () => void,
): (() => void) => {
settingsListeners.add(listener);
return () => {
settingsListeners.delete(listener);
};
};
/** Current settings snapshot (for `useSyncExternalStore`). */
export const getExtensionSettingsSnapshot = (): ExtensionSettings => settings;
/** Replace the settings snapshot and notify subscribers. Module-private; only loadExtensionSettings should call this. */
const applyExtensionSettings = (next: ExtensionSettings): void => {
settings = next;
emitSettingsChange();
};
/**
* Fetch settings from the server into the store. Resolves to the loaded value;
* on failure the store is left untouched and the error is rethrown so callers
* can surface it.
*/
export const loadExtensionSettings = async (): Promise<ExtensionSettings> => {
const { json } = await SupersetClient.get({ endpoint: SETTINGS_ENDPOINT });
applyExtensionSettings(json.result ?? EMPTY_SETTINGS);
return settings;
};

View File

@@ -28,10 +28,14 @@ export const core: typeof coreType = {
export * from './authentication';
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

@@ -56,6 +56,7 @@ import {
QueryResultContext,
QueryErrorResultContext,
} from './models';
import { navigation } from '../navigation';
const { CTASMethod } = sqlLabApi;
@@ -301,8 +302,15 @@ function createQueryErrorContext(
);
}
const getCurrentTab: typeof sqlLabApi.getCurrentTab = () =>
getTab(activeEditorId());
const getCurrentTab: typeof sqlLabApi.getCurrentTab = () => {
// Guard on the page type so the tab does not leak onto non-editor surfaces.
// The SQL Lab Redux slice persists after navigating away, so without this
// guard `getCurrentTab()` would keep returning the last editor's tab on, e.g.,
// a dashboard or list page. Mirrors the page-type guards on
// `explore.getCurrentChart()` / `dashboard.getCurrentDashboard()`.
if (navigation.getPageType() !== 'sqllab') return undefined;
return getTab(activeEditorId());
};
const getActivePanel: typeof sqlLabApi.getActivePanel = () => {
const { activeSouthPaneTab } = getSqlLabState();
@@ -452,8 +460,14 @@ const onDidChangeActiveTab: typeof sqlLabApi.onDidChangeActiveTab = (
createActionListener(
globalPredicate(SET_ACTIVE_QUERY_EDITOR),
listener,
(action: { type: string; queryEditor: { id: string } }) =>
getTab(action.queryEditor.id),
// Resolve the now-active tab the same way `getCurrentTab()` does (via the
// active-editor / tabHistory state) rather than from the raw action payload.
// The action's `queryEditor` carries the base editor without `unsavedQueryEditor`
// merged, so its `dbId` can still be undefined at this point, which made
// `getTab(action.queryEditor.id)` return undefined and silently swallow the
// event. Reading the resolved active tab keeps this event consistent with the
// getter and fires on every tab switch.
() => getCurrentTab() ?? null,
thisArgs,
);

View File

@@ -119,6 +119,13 @@ jest.mock('src/views/store', () => ({
setupStore: jest.fn(),
}));
// The sqlLab namespace guards `getCurrentTab()` on the page type. These tests
// exercise the editor surface, so report 'sqllab'. Per-test overrides (e.g. to
// assert the off-surface guard) can change the return value.
jest.mock('../navigation', () => ({
navigation: { getPageType: jest.fn(() => 'sqllab') },
}));
// Module under test — imported after mocks
// eslint-disable-next-line import/first
import { sqlLab } from '.';
@@ -388,6 +395,31 @@ test('onDidChangeActiveTab fires with Tab on SET_ACTIVE_QUERY_EDITOR', () => {
disposable.dispose();
});
test('onDidChangeActiveTab carries the newly-activated tab when switching away', () => {
// Switching from the first editor to a second one must report the second tab,
// not the first. Regression guard: resolving the tab from the live active
// editor (via getCurrentTab) instead of the raw action payload.
mockStore.dispatch({
type: ADD_QUERY_EDITOR,
queryEditor: makeSecondEditor(),
});
const listener = jest.fn();
const disposable = sqlLab.onDidChangeActiveTab(listener);
mockStore.dispatch({
type: SET_ACTIVE_QUERY_EDITOR,
queryEditor: { id: 'editor-2' },
});
expect(listener).toHaveBeenCalledTimes(1);
const tab = listener.mock.calls[0][0];
expect(tab.id).toBe('editor-2');
expect(tab.databaseId).toBe(2);
disposable.dispose();
});
test('onDidCreateTab fires with Tab on ADD_QUERY_EDITOR', () => {
const listener = jest.fn();
const disposable = sqlLab.onDidCreateTab(listener);
@@ -535,6 +567,13 @@ test('getCurrentTab returns the active tab with correct properties', () => {
expect(tab!.schema).toBe('public');
});
test('getCurrentTab returns undefined when not on the SQL Lab editor surface', () => {
const { navigation } = jest.requireMock('../navigation');
(navigation.getPageType as jest.Mock).mockReturnValueOnce('dashboard');
expect(sqlLab.getCurrentTab()).toBeUndefined();
});
test('getActivePanel returns the active south pane tab', () => {
const panel = sqlLab.getActivePanel();
expect(panel.id).toBe('Results');

View File

@@ -20,6 +20,56 @@ import type { common as core } from '@apache-superset/core';
import { AnyAction } from 'redux';
import { listenerMiddleware, RootState, store } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
import { Disposable } from './models';
/**
* A typed event subscription matching the public `Event<T>` contract.
* Calling it with a listener (and optional `this` arg) subscribes and returns
* a {@link Disposable} that unsubscribes.
*/
export type EventSubscriber<T> = (
listener: (e: T) => void,
thisArgs?: unknown,
) => Disposable;
/**
* A minimal host-internal event emitter shared by the producer-backed
* namespaces (dataset, navigation, settings, view registry). Each of those
* needs the same "publish a value and fan it out to subscribers" primitive;
* this collapses the duplicated Set + bind + Disposable boilerplate into one
* place.
*
* `event` is exposed to extensions as the namespace's `onDidChange*`; `fire`
* and `getCurrent` stay host-internal.
*/
export interface Emitter<T> {
/** Subscribe to changes; conforms to the public `Event<T>` shape. */
event: EventSubscriber<T>;
/** Notify all current subscribers with `value`. */
fire: (value: T) => void;
/** The most recently fired value (or the initial value). */
getCurrent: () => T;
}
export function createEmitter<T>(initial: T): Emitter<T> {
const listeners = new Set<(e: T) => void>();
let current = initial;
return {
event: (listener, thisArgs) => {
const bound = thisArgs ? listener.bind(thisArgs) : listener;
listeners.add(bound);
return new Disposable(() => {
listeners.delete(bound);
});
},
fire: value => {
current = value;
listeners.forEach(fn => fn(value));
},
getCurrent: () => current,
};
}
export function createActionListener<V>(
predicate: AnyListenerPredicate<RootState>,

View File

@@ -17,7 +17,12 @@
* under the License.
*/
import React from 'react';
import { views, resolveView } from './index';
import {
views,
resolveView,
getViewProvider,
getRegisteredViewIds,
} from './index';
const disposables: Array<{ dispose: () => void }> = [];
@@ -110,3 +115,59 @@ test('dispose removes the view registration', () => {
expect(views.getViews('sqllab.panels')).toBeUndefined();
});
test('getViewProvider returns the registered provider for a matching location', () => {
const provider = () => React.createElement('div', null, 'Test');
disposables.push(
views.registerView(
{ id: 'test.provider', name: 'Test Provider' },
'core.chatbot',
provider,
),
);
expect(getViewProvider('core.chatbot', 'test.provider')).toBe(provider);
});
test('getViewProvider returns undefined when the location does not match', () => {
const provider = () => React.createElement('div', null, 'Test');
disposables.push(
views.registerView(
{ id: 'test.provider', name: 'Test Provider' },
'sqllab.panels',
provider,
),
);
// Registered, but at a different location.
expect(getViewProvider('core.chatbot', 'test.provider')).toBeUndefined();
});
test('getViewProvider returns undefined for an unknown id', () => {
expect(getViewProvider('core.chatbot', 'nonexistent')).toBeUndefined();
});
test('getRegisteredViewIds returns ids in registration order', () => {
const provider = () => React.createElement('div', null, 'Test');
disposables.push(
views.registerView(
{ id: 'first.chatbot', name: 'First' },
'core.chatbot',
provider,
),
views.registerView(
{ id: 'second.chatbot', name: 'Second' },
'core.chatbot',
provider,
),
);
expect(getRegisteredViewIds('core.chatbot')).toEqual([
'first.chatbot',
'second.chatbot',
]);
});
test('getRegisteredViewIds returns an empty array for an unused location', () => {
expect(getRegisteredViewIds('core.chatbot')).toEqual([]);
});

View File

@@ -39,6 +39,27 @@ const viewRegistry: Map<
const locationIndex: Map<string, Set<string>> = new Map();
/**
* Monotonic version of the view registry. Bumped on every registration or
* disposal so consumers can re-derive state via React's `useSyncExternalStore`.
*/
let registryVersion = 0;
const registrySubscribers = new Set<() => void>();
const notifyRegistry = () => {
registryVersion += 1;
registrySubscribers.forEach(fn => fn());
};
export const subscribeToRegistry = (listener: () => void): (() => void) => {
registrySubscribers.add(listener);
return () => {
registrySubscribers.delete(listener);
};
};
export const getRegistryVersion = () => registryVersion;
const registerView: typeof viewsApi.registerView = (
view: View,
location: string,
@@ -46,15 +67,24 @@ 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();
ids.add(id);
locationIndex.set(location, ids);
notifyRegistry();
return new Disposable(() => {
const registeredLocation = viewRegistry.get(id)?.location ?? location;
viewRegistry.delete(id);
locationIndex.get(location)?.delete(id);
locationIndex.get(registeredLocation)?.delete(id);
notifyRegistry();
});
};
@@ -77,6 +107,28 @@ const getViews: typeof viewsApi.getViews = (
.filter((c): c is View => !!c);
};
/**
* Host-internal: returns the provider for a registered view id at a location.
* Not part of the public `@apache-superset/core` API — `getViews` stays
* descriptor-only so extensions cannot render each other's views directly.
*/
export const getViewProvider = (
location: string,
id: string,
): (() => ReactElement) | undefined => {
const entry = viewRegistry.get(id);
if (entry?.location !== location) {
return undefined;
}
return entry.provider;
};
/** Host-internal: view ids at a location in registration order. */
export const getRegisteredViewIds = (location: string): string[] => {
const ids = locationIndex.get(location);
return ids ? Array.from(ids) : [];
};
export const views: typeof viewsApi = {
registerView,
getViews,

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,
});
@@ -109,6 +111,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 +130,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -144,6 +148,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 +174,7 @@ test('only initializes once even with multiple renders', async () => {
const { rerender } = render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -205,6 +211,7 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -234,6 +241,7 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -268,6 +276,7 @@ test('continues rendering children even when ExtensionsLoader initialization fai
</ExtensionsStartup>,
{
useRedux: true,
useRouter: true,
initialState: mockInitialState,
},
);

View File

@@ -16,48 +16,66 @@
* 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,
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 +85,33 @@ 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,
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.
// ChatbotMount re-resolves reactively once the chatbot extension registers
// (via subscribeToRegistry / getRegistryVersion), 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,64 @@
/**
* 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,
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;
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

@@ -252,9 +252,7 @@ describe('RoleListEditModal', () => {
const mockGet = SupersetClient.get as jest.Mock;
mockGet.mockImplementation(({ endpoint }) => {
if (
endpoint?.includes(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
)
endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`)
) {
// Only return permission id=10, not id=20
return Promise.resolve({
@@ -298,9 +296,7 @@ describe('RoleListEditModal', () => {
const mockGet = SupersetClient.get as jest.Mock;
mockGet.mockImplementation(({ endpoint }) => {
if (
endpoint?.includes(
`/api/v1/security/roles/${mockRole.id}/permissions/`,
)
endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`)
) {
return Promise.reject(new Error('network error'));
}
@@ -371,7 +367,9 @@ describe('RoleListEditModal', () => {
};
mockGet.mockImplementation(({ endpoint }) => {
if (endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)) {
if (
endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)
) {
return Promise.resolve({
json: {
result: roleA.permission_ids.map(pid => ({
@@ -382,7 +380,9 @@ describe('RoleListEditModal', () => {
},
});
}
if (endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)) {
if (
endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)
) {
return Promise.resolve({
json: {
result: roleB.permission_ids.map(pid => ({

View File

@@ -33,7 +33,12 @@ import { ensureAppRoot } from '../utils/pathUtils';
import type { DashboardInfo, DashboardLayoutState } from '../dashboard/types';
import type { QueryEditor } from '../SqlLab/types';
type LogEventSource = 'dashboard' | 'embedded_dashboard' | 'explore' | 'sqlLab' | 'slice';
type LogEventSource =
| 'dashboard'
| 'embedded_dashboard'
| 'explore'
| 'sqlLab'
| 'slice';
interface LogEventData {
source?: LogEventSource;

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 ChatbotMount from 'src/components/ChatbotMount';
import { RootContextProviders } from './RootContextProviders';
import { ScrollToTop } from './ScrollToTop';
@@ -112,6 +114,13 @@ const App = () => (
</Route>
))}
</Switch>
{/*
The singleton chatbot bubble. Rendered as a sibling of the route
Switch — inside ExtensionsStartup so chatbot extensions have been
loaded and registered, but outside the Switch so the bubble persists
across route changes (SIP §3.2).
*/}
{isFeatureEnabled(FeatureFlag.EnableExtensions) && <ChatbotMount />}
</ExtensionsStartup>
<ToastContainer />
</RootContextProviders>

View File

@@ -0,0 +1,31 @@
/**
* 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 locations for app-shell extension integration.
*
* These define locations that persist across all routes, mirroring the `app`
* scope of the `ViewContributions` manifest schema.
*/
export const AppViewLocations = {
app: {
chatbot: 'core.chatbot',
},
} as const;
export const CHATBOT_LOCATION = AppViewLocations.app.chatbot;

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

@@ -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,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,29 @@
# 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.
from typing import Any
from superset.commands.base import BaseCommand
from superset.daos.extension import get_extension_settings
class GetExtensionSettingsCommand(BaseCommand):
def run(self) -> dict[str, Any]:
self.validate()
return get_extension_settings()
def validate(self) -> None:
return None

View File

@@ -0,0 +1,44 @@
# 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.
from typing import Any
from superset import db
from superset.daos.base import BaseDAO
from superset.extensions.models import ExtensionSettings
# The global extension settings live in a single row; id is fixed so the row
# can be fetched without a secondary lookup.
SETTINGS_ROW_ID = 1
class ExtensionSettingsDAO(BaseDAO[ExtensionSettings]):
"""Persistence for the singleton global extension settings row.
The row (id=1) holds global admin state such as the active chatbot id.
"""
@staticmethod
def get_settings_row() -> ExtensionSettings | None:
return db.session.get(ExtensionSettings, SETTINGS_ROW_ID)
def get_extension_settings() -> dict[str, Any]:
"""Read-only view of the extension settings."""
row = ExtensionSettingsDAO.get_settings_row()
return {
"active_chatbot_id": row.active_chatbot_id if row else None,
}

View File

@@ -15,34 +15,43 @@
# 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.commands.extension.settings.get import GetExtensionSettingsCommand
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",
"can_get_settings",
]
@expose("/_info", methods=("GET",))
@protect()
@@ -72,13 +81,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 +167,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)
@@ -167,6 +177,23 @@ class ExtensionsRestApi(BaseApi):
extension_data = build_extension_data(extension)
return self.response(200, result=extension_data)
@protect()
@safe
@expose("/settings", methods=("GET",))
def get_settings(self, **kwargs: Any) -> Response:
"""Get global extension admin settings.
No admin gate here by design: authenticated non-admin users need these
settings so the ChatbotMount can read active_chatbot_id on every page.
---
get:
summary: Get extension admin settings (active chatbot id).
responses:
200:
description: Extension settings
"""
return self.response(200, result=GetExtensionSettingsCommand().run())
@protect()
@safe
@expose("/<publisher>/<name>/<file>", methods=("GET",))
@@ -210,7 +237,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

@@ -14,21 +14,19 @@
# 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
"""SQLAlchemy models for extension settings persistence."""
from flask_appbuilder import Model
from sqlalchemy import Column, Integer, String
# Column length for extension/chatbot identifiers.
EXTENSION_ID_MAX_LENGTH = 250
class ExtensionsView(BaseSupersetView):
route_base = "/extensions"
class_permission_name = "Extensions"
method_permission_name = MODEL_VIEW_RW_METHOD_PERMISSION_MAP
class ExtensionSettings(Model): # pylint: disable=too-few-public-methods
"""Global admin settings for extensions (singleton row, id=1)."""
@expose("/list/")
@has_access
@permission_name("read")
def list(self) -> FlaskResponse:
return super().render_app_template()
__tablename__ = "extension_settings"
id = Column(Integer, primary_key=True)
active_chatbot_id = Column(String(EXTENSION_ID_MAX_LENGTH), nullable=True)

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

@@ -177,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
@@ -418,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",

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

@@ -14,4 +14,6 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from superset.extensions import models as extensions_models # noqa: F401
from . import core, dynamic_plugins, sql_lab, user_attributes # noqa: F401

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

@@ -0,0 +1,77 @@
# 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 extension settings persistence and the settings API endpoint.
Persistence is exercised through the public Command + DAO layer:
``GetExtensionSettingsCommand`` and ``ExtensionSettingsDAO``.
"""
from __future__ import annotations
from typing import Any
import pytest
# ---------------------------------------------------------------------------
# Settings persistence (Command + DAO) — sqlite-backed round-trip tests
# ---------------------------------------------------------------------------
class TestGetExtensionSettings:
def test_returns_defaults_when_no_rows(self, app_context: Any) -> None:
from superset.commands.extension.settings.get import (
GetExtensionSettingsCommand,
)
result = GetExtensionSettingsCommand().run()
assert result["active_chatbot_id"] is None
# ---------------------------------------------------------------------------
# GET /api/v1/extensions/settings
# ---------------------------------------------------------------------------
# The settings routes are only registered when ENABLE_EXTENSIONS is on at
# app-init time, so the endpoint tests parametrize the app fixture to enable it
# (otherwise the route is absent and requests 404).
_ENABLE_EXTENSIONS = [{"FEATURE_FLAGS": {"ENABLE_EXTENSIONS": True}}]
@pytest.mark.parametrize("app", _ENABLE_EXTENSIONS, indirect=True)
class TestGetSettingsEndpoint:
def test_authenticated_user_can_read(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.GetExtensionSettingsCommand.run",
return_value={"active_chatbot_id": None},
)
resp = client.get("/api/v1/extensions/settings")
assert resp.status_code == 200
assert resp.json["result"]["active_chatbot_id"] is None
def test_returns_active_chatbot_id(
self, client: Any, full_api_access: None, mocker: Any
) -> None:
mocker.patch(
"superset.extensions.api.GetExtensionSettingsCommand.run",
return_value={"active_chatbot_id": "acme.chatbot"},
)
resp = client.get("/api/v1/extensions/settings")
assert resp.status_code == 200
assert resp.json["result"]["active_chatbot_id"] == "acme.chatbot"

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"]