Compare commits

...

9 Commits

Author SHA1 Message Date
Enzo Martellucci
2c92f1f5ae refactor(extensions): narrow navigation namespace to routing signal only
Remove PageContext, entityId, and getCurrentPage() from the navigation
declaration and host implementation. The namespace now exposes only
getPageType() and onDidChangePage (firing PageType, not a bundled object),
making its contract explicit: navigation is a routing signal, not a context
aggregator. Entity payloads (chart, dashboard, dataset) belong to their
respective surface namespaces.

Replace the extension's popstate fallback with subscribeToPageChanges(), a
single helper in pageContext.ts that tries navigation.onDidChangePage first
and falls back to popstate for older hosts. Panel.tsx now delegates to this
helper — the "Replaced in P3" TODO comment is resolved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:28:25 +02:00
Enzo Martellucci
5294d05467 fix(extensions): address P3 CodeAnt review — lifecycle, race conditions, and context accuracy
ExtensionsStartup: move unhandledrejection listener to its own one-time effect so it
remains registered for the app lifetime instead of being removed on every re-render.

ChatbotMount: add cancelled guard in settings-fetch effect so state setters are
not called after unmount.

settings.py: replace read-then-insert with dialect-aware upsert (ON CONFLICT DO UPDATE)
for both ExtensionSettings and ExtensionEnabled to eliminate concurrent-write race;
validate body["enabled"] is a dict before iterating.

navigation contract: clarify that entityId covers dashboard/dataset routes only —
Explore chart ids are query-string–based and exposed via the explore namespace instead.

dashboard: filter out native-filter entries with no applied value before emitting
DashboardContext, matching the documented contract.

explore: read sliceName from reduce state (updated by UPDATE_CHART_TITLE) instead of
the stale slice.slice_name field; add SET_DATASOURCE to the change predicate so
datasource switches fire onDidChangeChart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 14:22:48 +02:00
Enzo Martellucci
bf71eb6712 feat(extensions): P2 admin guards + P3 context namespaces (dashboard, explore, dataset, navigation) 2026-05-26 13:52:25 +02:00
Enzo Martellucci
6a07ad2369 feat(extensions): chatbot mount point, singleton resolver, and admin settings (SIP P1+P2) 2026-05-26 10:18:41 +02:00
Enzo Martellucci
793ffb3d80 feat(extensions): show toast on load failure and export ChatbotView type 2026-05-25 16:43:18 +02:00
Enzo Martellucci
f575fdae3a feat(extensions): complete SIP P1 — chatbot mount point & registration
- Add `icon` field to `View` descriptor (static, set at registerView time)
- Track per-extension Disposables in ExtensionsLoader; add deactivateExtension()
- Subscribe ChatbotMount to registry changes so it reacts to activate/deactivate
- Add host-level unhandledrejection isolation to ExtensionsStartup
- Make extension loading non-blocking (host renders immediately, chatbot appears reactively)
- Document superset.chatbot location and chatbot registerView example in public API
2026-05-25 16:20:02 +02:00
Enzo Martellucci
7b418becc7 feat(extensions): add superset.chatbot contribution point (SIP P1.1)
- Add `app` scope and `AppLocation` type to `ViewContributions` manifest schema
- Add host-internal `getViewProvider` and `getRegisteredViewIds` accessors to the views registry
- Add `getActiveChatbot` resolver with first-to-register fallback policy
- Mount `ChatbotMount` in the app shell (fixed bottom-right, persists across routes
2026-05-25 15:04:36 +02:00
Enzo Martellucci
ba7db15f02 Merge branch 'master' into chat-prototype 2026-05-22 16:31:56 +02:00
Michael S. Molina
c85661f4fd feat: Chat prototype 2026-05-15 15:15:42 -03:00
29 changed files with 1599 additions and 85 deletions

View File

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

@@ -17,23 +17,9 @@
* under the License.
*/
/**
* @fileoverview Manifest schema for Superset extension contributions.
*
* This module defines the aggregate interfaces used by the extension.json
* manifest and the `superset-extensions` build command. Individual metadata
* types are defined in their respective namespace modules (commands, views,
* menus, editors) and re-exported here for the manifest schema.
*/
import { Command } from '../commands';
import { View } from '../views';
import { Menu } from '../menus';
import { Editor } from '../editors';
/**
* Valid locations within SQL Lab.
*/
export type SqlLabLocation =
| 'leftSidebar'
| 'rightSidebar'
@@ -43,43 +29,14 @@ export type SqlLabLocation =
| 'results'
| 'queryHistory';
/**
* Nested structure for view contributions by scope and location.
* @example
* {
* sqllab: {
* panels: [{ id: "my-ext.panel", name: "My Panel" }],
* leftSidebar: [{ id: "my-ext.sidebar", name: "My Sidebar" }]
* }
* }
*/
/** Valid locations within the app shell (persist across all routes). */
export type AppLocation = 'chatbot';
export interface ViewContributions {
sqllab?: Partial<Record<SqlLabLocation, View[]>>;
app?: Partial<Record<AppLocation, View[]>>;
}
/**
* Nested structure for menu contributions by scope and location.
* @example
* {
* sqllab: {
* editor: { primary: [...], secondary: [...] }
* }
* }
*/
export interface MenuContributions {
sqllab?: Partial<Record<SqlLabLocation, Menu>>;
}
/**
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
*/
export interface Contributions {
/** List of commands. */
commands: Command[];
/** Nested mapping of menu contributions by scope and location. */
menus: MenuContributions;
/** Nested mapping of view contributions by scope and location. */
views: ViewContributions;
/** List of editors. */
editors?: Editor[];
}

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 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;
}
/**
* 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[];
}
/**
* 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,71 @@
/**
* 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.
* `'other'` covers any route not explicitly enumerated.
*/
export type PageType =
| 'dashboard'
| 'explore'
| 'sqllab'
| 'dataset'
| '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

@@ -20,19 +20,12 @@
/**
* @fileoverview Views registration API for Superset extensions.
*
* This module provides functions for registering custom React views
* at specific locations in the Superset UI. Views are registered as
* module-level side effects at import time.
* Extensions register React views at named locations using `registerView`.
* Registrations happen as module-level side effects at import time.
*
* @example
* ```typescript
* import { views } from '@apache-superset/core';
*
* views.registerView(
* { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' },
* () => <ResultStatsPanel />,
* );
* ```
* Built-in locations:
* - `sqllab.panels` / `sqllab.rightSidebar` / … — SQL Lab surface
* - `superset.chatbot` — app-shell chatbot bubble (singleton; host renders one)
*/
import { ReactElement } from 'react';
@@ -48,20 +41,23 @@ 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;
}
/**
* Registers a custom view at a specific UI location.
*
* 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 location The location where this view should appear (e.g. "sqllab.panels").
* @param view The view descriptor (id, name, and optional icon/description).
* @param location The location where this view should appear.
* @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 +65,15 @@ export interface View {
* () => <ResultStatsPanel />,
* );
* ```
*
* @example Chatbot bubble (`superset.chatbot` — singleton, host renders one)
* ```typescript
* views.registerView(
* { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' },
* 'superset.chatbot',
* () => <ChatbotApp />,
* );
* ```
*/
export declare function registerView(
view: View,
@@ -76,6 +81,21 @@ export declare function registerView(
provider: () => ReactElement,
): Disposable;
/**
* Narrowed descriptor for chatbot contributions (`superset.chatbot` location).
*
* Extension authors should use this type when calling `registerView` for the
* chatbot area. It is identical to {@link View} but makes the registration
* intent explicit and allows future narrowing (e.g. required `icon`).
*
* @example
* ```typescript
* const chatbot: ChatbotView = { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' };
* views.registerView(chatbot, 'superset.chatbot', () => <ChatbotApp />);
* ```
*/
export type ChatbotView = View;
/**
* Retrieves all views registered at a specific location.
*

View File

@@ -0,0 +1,91 @@
/**
* 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 { render, screen } from 'spec/helpers/testing-library';
import { views } from 'src/core';
import { CHATBOT_LOCATION } from 'src/views/contributions';
import ChatbotMount from '.';
const disposables: Array<{ dispose: () => void }> = [];
afterEach(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
});
test('renders nothing when no chatbot extension is registered', () => {
render(<ChatbotMount />);
expect(screen.queryByTestId('chatbot-mount')).not.toBeInTheDocument();
});
test('renders the registered chatbot inside the fixed mount slot', () => {
const provider = () => React.createElement('div', null, 'My Chatbot Bubble');
disposables.push(
views.registerView(
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
provider,
),
);
render(<ChatbotMount />);
expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument();
expect(screen.getByText('My Chatbot Bubble')).toBeInTheDocument();
});
test('renders only the first-to-register chatbot when several are installed', () => {
const firstProvider = () => React.createElement('div', null, 'First Bubble');
const secondProvider = () =>
React.createElement('div', null, 'Second Bubble');
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(screen.getByText('First Bubble')).toBeInTheDocument();
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
});
test('isolates a failing chatbot so it does not crash the host', () => {
const FailingChatbot = () => {
throw new Error('chatbot blew up');
};
disposables.push(
views.registerView(
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
() => React.createElement(FailingChatbot),
),
);
// The host-owned error boundary catches the failure; render does not throw.
expect(() => render(<ChatbotMount />)).not.toThrow();
});

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.
*/
import { useState, useEffect } from 'react';
import { SupersetClient } from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/theme';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import { getActiveChatbot } from 'src/core/chatbot';
import { subscribeToLocation } from 'src/core/views';
import { CHATBOT_LOCATION } from 'src/views/contributions';
const CHATBOT_EDGE_MARGIN = 24;
const ChatbotMount = () => {
const theme = useTheme();
const [adminSelectedId, setAdminSelectedId] = useState<string | null>(null);
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
const [activeChatbot, setActiveChatbot] = useState(() =>
getActiveChatbot(null, {}),
);
useEffect(() => {
let cancelled = false;
SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
.then(({ json }) => {
if (cancelled) return;
const id = json.result?.active_chatbot_id ?? null;
const enabled: Record<string, boolean> = json.result?.enabled ?? {};
setAdminSelectedId(id);
setEnabledMap(enabled);
setActiveChatbot(getActiveChatbot(id, enabled));
})
.catch(() => {
// Settings fetch failure is non-fatal — fall back to first-to-register.
});
return () => {
cancelled = true;
};
}, []);
useEffect(
() =>
subscribeToLocation(CHATBOT_LOCATION, () =>
setActiveChatbot(getActiveChatbot(adminSelectedId, enabledMap)),
),
[adminSelectedId, enabledMap],
);
if (!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>{activeChatbot.provider()}</ErrorBoundary>
</div>
);
};
export default ChatbotMount;

View File

@@ -0,0 +1,96 @@
/**
* 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: 'superset.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
provider,
),
);
const active = getActiveChatbot();
expect(active).toEqual({ id: 'superset.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: 'superset.chatbot', name: 'Superset Chatbot' },
CHATBOT_LOCATION,
provider,
);
expect(getActiveChatbot()?.id).toBe('superset.chatbot');
disposable.dispose();
expect(getActiveChatbot()).toBeUndefined();
});

View File

@@ -0,0 +1,87 @@
/**
* 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 `superset.chatbot`
* contribution area.
*
* `superset.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.
* - Disabled chatbots (per `enabledMap`) are excluded before selection.
* - If `adminSelectedId` matches an enabled registered chatbot, that one wins.
* - Otherwise the first enabled chatbot in registration order is used as a fallback.
*
* @param adminSelectedId The id stored in the admin "Default chatbot" setting, if any.
* @param enabledMap Per-extension enabled flags from the admin settings API.
* @returns The active chatbot's id and provider, or `undefined` if none.
*/
export const getActiveChatbot = (
adminSelectedId?: string | null,
enabledMap?: Record<string, boolean>,
): ActiveChatbot | undefined => {
const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION);
if (registeredIds.length === 0) {
return undefined;
}
const candidates = enabledMap
? registeredIds.filter(id => enabledMap[id] !== false)
: registeredIds;
if (candidates.length === 0) {
return undefined;
}
const selectedId =
adminSelectedId && candidates.includes(adminSelectedId)
? adminSelectedId
: candidates[0];
const provider = getViewProvider(CHATBOT_LOCATION, selectedId);
if (!provider) {
return undefined;
}
return { id: selectedId, provider };
};

View File

@@ -0,0 +1,90 @@
/**
* 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 { 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 { createActionListener } from '../utils';
type DashboardContext = dashboardApi.DashboardContext;
type FilterValue = dashboardApi.FilterValue;
function buildDashboardContext(): DashboardContext | undefined {
const state = store.getState();
const info = (state as any).dashboardInfo;
if (!info?.id) return undefined;
const nativeFilters = (state as any).nativeFilters?.filters ?? {};
const dataMask = (state as any).dataMask ?? {};
const filters: FilterValue[] = Object.entries(dataMask)
.filter(([id, mask]: [string, any]) => {
if (!(id in nativeFilters)) return false;
const value = mask?.filterState?.value;
return value !== null && value !== undefined;
})
.map(([id, mask]: [string, any]) => ({
filterId: id,
label: nativeFilters[id]?.name ?? id,
value: mask.filterState.value,
}));
return {
dashboardId: info.id as number,
title: info.dashboard_title ?? info.slug ?? String(info.id),
filters,
};
}
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,62 @@
/**
* 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 { Disposable } from '../models';
type DatasetContext = datasetApi.DatasetContext;
let currentDataset: DatasetContext | undefined;
const listeners = new Set<(ctx: DatasetContext) => void>();
/**
* 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 => {
currentDataset = ctx;
if (ctx) {
listeners.forEach(fn => fn(ctx));
}
};
const getCurrentDataset: typeof datasetApi.getCurrentDataset = () =>
currentDataset ? { ...currentDataset } : undefined;
const onDidChangeDataset: typeof datasetApi.onDidChangeDataset = (
listener: (ctx: DatasetContext) => void,
thisArgs?: any,
): Disposable => {
const bound = thisArgs ? listener.bind(thisArgs) : listener;
listeners.add(bound);
return new Disposable(() => listeners.delete(bound));
};
export const dataset: typeof datasetApi = {
getCurrentDataset,
onDidChangeDataset,
};

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.
*/
/**
* 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 {
SET_FORM_DATA,
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';
type ChartContext = exploreApi.ChartContext;
function buildChartContext(): ChartContext | undefined {
const state = store.getState();
const exploreState = (state as any).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;
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

@@ -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,69 @@
/**
* 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('/explore/')) return 'explore';
if (pathname.startsWith('/superset/explore/')) return 'explore';
if (pathname.startsWith('/chart/add')) return 'explore';
if (pathname.startsWith('/sqllab/')) return 'sqllab';
if (pathname.startsWith('/dataset/')) return 'dataset';
if (pathname.startsWith('/superset/welcome/')) return 'home';
return 'other';
}
let currentPageType: PageType = derivePageType(window.location.pathname);
/** Called by ExtensionsStartup whenever the React Router location changes. */
export const notifyPageChange = (pathname: string): void => {
const next = derivePageType(pathname);
if (next === currentPageType) return;
currentPageType = next;
listeners.forEach(fn => fn(next));
};
const getPageType: typeof navigationApi.getPageType = () => currentPageType;
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

@@ -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' },
'superset.chatbot',
provider,
),
);
expect(getViewProvider('superset.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('superset.chatbot', 'test.provider')).toBeUndefined();
});
test('getViewProvider returns undefined for an unknown id', () => {
expect(getViewProvider('superset.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' },
'superset.chatbot',
provider,
),
views.registerView(
{ id: 'second.chatbot', name: 'Second' },
'superset.chatbot',
provider,
),
);
expect(getRegisteredViewIds('superset.chatbot')).toEqual([
'first.chatbot',
'second.chatbot',
]);
});
test('getRegisteredViewIds returns an empty array for an unused location', () => {
expect(getRegisteredViewIds('superset.chatbot')).toEqual([]);
});

View File

@@ -39,6 +39,27 @@ const viewRegistry: Map<
const locationIndex: Map<string, Set<string>> = new Map();
/** Listeners notified whenever a view is registered or unregistered at a location. */
const locationListeners: Map<string, Set<() => void>> = new Map();
const notifyListeners = (location: string) => {
locationListeners.get(location)?.forEach(fn => fn());
};
/**
* Subscribe to registration changes at a specific location.
* Returns an unsubscribe function.
*/
export const subscribeToLocation = (
location: string,
listener: () => void,
): (() => void) => {
const listeners = locationListeners.get(location) ?? new Set();
listeners.add(listener);
locationListeners.set(location, listeners);
return () => listeners.delete(listener);
};
const registerView: typeof viewsApi.registerView = (
view: View,
location: string,
@@ -52,9 +73,12 @@ const registerView: typeof viewsApi.registerView = (
ids.add(id);
locationIndex.set(location, ids);
notifyListeners(location);
return new Disposable(() => {
viewRegistry.delete(id);
locationIndex.get(location)?.delete(id);
notifyListeners(location);
});
};
@@ -77,6 +101,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

@@ -17,20 +17,31 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
import { FunctionComponent, useMemo } from 'react';
import { css } from '@apache-superset/core/theme';
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react';
import { SupersetClient } from '@superset-ui/core';
import { Select } from '@superset-ui/core/components';
import { Switch } from '@superset-ui/core/components/Switch';
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';
import { CHATBOT_LOCATION } from 'src/views/contributions';
import { getRegisteredViewIds } from 'src/core/views';
const PAGE_SIZE = 25;
type Extension = {
id: number;
id: string;
name: string;
enabled: boolean;
};
type ExtensionSettings = {
active_chatbot_id: string | null;
enabled: Record<string, boolean>;
};
interface ExtensionsListProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
@@ -50,6 +61,45 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
addDangerToast,
);
const [settings, setSettings] = useState<ExtensionSettings>({
active_chatbot_id: null,
enabled: {},
});
useEffect(() => {
SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
.then(({ json }) => setSettings(json.result))
.catch(() => addDangerToast(t('Failed to load extension settings.')));
}, [addDangerToast]);
const saveSettings = useCallback(
(patch: Partial<ExtensionSettings>) => {
const next = { ...settings, ...patch };
SupersetClient.put({
endpoint: '/api/v1/extensions/settings',
jsonPayload: next,
})
.then(({ json }) => {
setSettings(json.result);
addSuccessToast(t('Settings saved.'));
})
.catch(() => addDangerToast(t('Failed to save extension settings.')));
},
[settings, addDangerToast, addSuccessToast],
);
const toggleEnabled = useCallback(
(extensionId: string, enabled: boolean) => {
saveSettings({ enabled: { ...settings.enabled, [extensionId]: enabled } });
},
[settings, saveSettings],
);
const chatbotExtensions = useMemo(() => {
const chatbotIds = new Set(getRegisteredViewIds(CHATBOT_LOCATION));
return resourceCollection.filter(ext => chatbotIds.has(ext.id));
}, [resourceCollection]);
const columns = useMemo(
() => [
{
@@ -58,15 +108,34 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
size: 'lg',
id: 'name',
Cell: ({
row: {
original: { name },
},
row: { original: { name } },
}: any) => name,
},
{
Header: t('Enabled'),
accessor: 'enabled',
size: 'sm',
id: 'enabled',
Cell: ({
row: { original: { id, enabled } },
}: any) => (
<Switch
data-test="toggle-enabled"
checked={settings.enabled[id] ?? enabled}
onClick={(checked: boolean) => toggleEnabled(id, checked)}
size="small"
/>
),
},
],
[loading], // We need to monitor loading to avoid stale state in actions
[loading, settings, toggleEnabled],
);
const chatbotOptions = chatbotExtensions.map(ext => ({
label: ext.name,
value: ext.id,
}));
const menuData: SubMenuProps = {
activeChild: 'Extensions',
name: t('Extensions'),
@@ -76,6 +145,23 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
return (
<>
<SubMenu {...menuData} />
{chatbotOptions.length > 1 && (
<div style={{ padding: '16px 24px' }}>
<label htmlFor="chatbot-select" style={{ marginRight: 8 }}>
{t('Default chatbot')}
</label>
<Select
allowClear
options={chatbotOptions}
value={settings.active_chatbot_id ?? undefined}
onChange={value =>
saveSettings({ active_chatbot_id: (value as string) ?? null })
}
placeholder={t('First registered (automatic)')}
css={css`width: 280px;`}
/>
</div>
)}
<ListView<Extension>
columns={columns}
count={resourceCount}

View File

@@ -17,8 +17,11 @@
* 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';
type Extension = core.Extension;
@@ -36,6 +39,9 @@ class ExtensionsLoader {
private initializationPromise: Promise<void> | null = null;
/** Disposables returned by contribution registrations, keyed by extension id. */
private extensionDisposables: Map<string, (() => void)[]> = new Map();
// eslint-disable-next-line no-useless-constructor
private constructor() {
// Private constructor for singleton pattern
@@ -88,7 +94,8 @@ class ExtensionsLoader {
public async initializeExtension(extension: Extension) {
try {
if (extension.remoteEntry) {
await this.loadModule(extension);
const disposables = await this.loadModule(extension);
this.extensionDisposables.set(extension.id, disposables);
}
this.extensionIndex.set(extension.id, extension);
} catch (error) {
@@ -96,15 +103,31 @@ class ExtensionsLoader {
`Failed to initialize extension ${extension.name}\n`,
error,
);
store.dispatch(
addDangerToast(t('Extension "%s" failed to load.', extension.name)),
);
}
}
/**
* Deactivates an extension by disposing all of its registered contributions
* and removing it from the index.
*/
public deactivateExtension(id: string): void {
const disposables = this.extensionDisposables.get(id);
if (disposables) {
disposables.forEach(dispose => dispose());
this.extensionDisposables.delete(id);
}
this.extensionIndex.delete(id);
}
/**
* Loads a single extension module via webpack module federation.
* The module's top-level side effects fire contribution registrations.
* @param extension The extension to load.
*/
private async loadModule(extension: Extension): Promise<void> {
private async loadModule(extension: Extension): Promise<(() => void)[]> {
const { remoteEntry, id } = extension;
// Load the remote entry script
@@ -149,8 +172,33 @@ class ExtensionsLoader {
await container.init(__webpack_share_scopes__.default);
const factory = await container.get('./index');
// Execute the module factory - side effects fire registrations
factory();
// Intercept contribution registrations during module activation so we can
// collect the Disposables and drive cleanup on deactivation.
const collected: (() => void)[] = [];
const originalSuperset = window.superset;
window.superset = {
...originalSuperset,
views: {
...originalSuperset.views,
registerView: (
...args: Parameters<typeof originalSuperset.views.registerView>
) => {
const disposable = originalSuperset.views.registerView(...args);
collected.push(() => disposable.dispose());
return disposable;
},
},
};
try {
// Execute the module factory — side effects fire contribution registrations
factory();
} finally {
window.superset = originalSuperset;
}
return collected;
}
/**

View File

@@ -16,20 +16,27 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
// eslint-disable-next-line no-restricted-syntax
import * as supersetCore from '@apache-superset/core';
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';
@@ -40,9 +47,13 @@ declare global {
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;
};
@@ -53,11 +64,40 @@ 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]);
// Isolate unhandled rejections from extension code for the lifetime of the
// app — registered once, never removed.
useEffect(() => {
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
logging.error(
'[extensions] Unhandled rejection from extension:',
event.reason,
);
event.preventDefault();
};
window.addEventListener('unhandledrejection', handleUnhandledRejection);
return () => {
window.removeEventListener(
'unhandledrejection',
handleUnhandledRejection,
);
};
}, []);
useEffect(() => {
if (initialized) return;
@@ -73,21 +113,25 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
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 subscribeToLocation), so the bubble appears without blocking the UI.
setInitialized(true);
setup();
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
ExtensionsLoader.getInstance().initializeExtensions();
}
}, [initialized, userId]);
if (!initialized) {

View File

@@ -39,6 +39,7 @@ import setupCodeOverrides from 'src/setup/setupCodeOverrides';
import { logEvent } from 'src/logger/actions';
import { store } from 'src/views/store';
import ExtensionsStartup from 'src/extensions/ExtensionsStartup';
import ChatbotMount from 'src/components/ChatbotMount';
import { RootContextProviders } from './RootContextProviders';
import { ScrollToTop } from './ScrollToTop';
@@ -112,6 +113,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).
*/}
<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: 'superset.chatbot',
},
} as const;
export const CHATBOT_LOCATION = AppViewLocations.app.chatbot;

View File

@@ -18,10 +18,15 @@ import mimetypes
from io import BytesIO
from typing import Any
from flask import send_file
from flask import request, send_file
from flask.wrappers import Response
from flask_appbuilder.api import BaseApi, expose, protect, safe
from superset.extensions import security_manager
from superset.extensions.settings import (
get_extension_settings,
update_extension_settings,
)
from superset.extensions.utils import (
build_extension_data,
get_extensions,
@@ -167,6 +172,53 @@ 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.
---
get:
summary: Get extension admin settings (active chatbot, enabled flags).
responses:
200:
description: Extension settings
"""
return self.response(200, result=get_extension_settings())
@protect()
@safe
@expose("/settings", methods=("PUT",))
def put_settings(self, **kwargs: Any) -> Response:
"""Update global extension admin settings.
---
put:
summary: Update extension admin settings.
requestBody:
content:
application/json:
schema:
type: object
properties:
active_chatbot_id:
type: string
nullable: true
enabled:
type: object
additionalProperties:
type: boolean
responses:
200:
description: Updated settings
403:
$ref: '#/components/responses/403'
"""
if not security_manager.is_admin():
return self.response(403, message="Admin access required.")
body = request.get_json(silent=True) or {}
result = update_extension_settings(body)
return self.response(200, result=result)
@protect()
@safe
@expose("/<publisher>/<name>/<file>", methods=("GET",))

View File

@@ -0,0 +1,108 @@
# 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.
"""Admin settings persistence for extensions (active chatbot, enable/disable)."""
from typing import Any
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from superset import db
from superset.models.core import ExtensionEnabled, ExtensionSettings
from superset.utils.decorators import transaction
_SETTINGS_ROW_ID = 1
def get_extension_settings() -> dict[str, Any]:
row = db.session.get(ExtensionSettings, _SETTINGS_ROW_ID)
enabled_rows = db.session.query(ExtensionEnabled).all()
return {
"active_chatbot_id": row.active_chatbot_id if row else None,
"enabled": {r.extension_id: r.enabled for r in enabled_rows},
}
def _upsert_settings_row(
active_chatbot_id: str | None,
) -> None:
"""Upsert the singleton settings row without a read-then-insert race."""
bind = db.session.get_bind()
dialect = bind.dialect.name
if dialect == "postgresql":
stmt = (
pg_insert(ExtensionSettings)
.values(id=_SETTINGS_ROW_ID, active_chatbot_id=active_chatbot_id)
.on_conflict_do_update(
index_elements=["id"],
set_={"active_chatbot_id": active_chatbot_id},
)
)
db.session.execute(stmt)
else:
stmt = (
sqlite_insert(ExtensionSettings)
.values(id=_SETTINGS_ROW_ID, active_chatbot_id=active_chatbot_id)
.on_conflict_do_update(
index_elements=["id"],
set_={"active_chatbot_id": active_chatbot_id},
)
)
db.session.execute(stmt)
def _upsert_enabled_flag(extension_id: str, enabled: bool) -> None:
"""Upsert a per-extension enabled flag without a read-then-insert race."""
bind = db.session.get_bind()
dialect = bind.dialect.name
if dialect == "postgresql":
stmt = (
pg_insert(ExtensionEnabled)
.values(extension_id=extension_id, enabled=enabled)
.on_conflict_do_update(
index_elements=["extension_id"],
set_={"enabled": enabled},
)
)
db.session.execute(stmt)
else:
stmt = (
sqlite_insert(ExtensionEnabled)
.values(extension_id=extension_id, enabled=enabled)
.on_conflict_do_update(
index_elements=["extension_id"],
set_={"enabled": enabled},
)
)
db.session.execute(stmt)
@transaction()
def update_extension_settings(body: dict[str, Any]) -> dict[str, Any]:
if "active_chatbot_id" in body:
value = body["active_chatbot_id"]
active_chatbot_id = str(value) if isinstance(value, str) and value else None
_upsert_settings_row(active_chatbot_id)
if "enabled" in body and isinstance(body["enabled"], dict):
for extension_id, enabled in body["enabled"].items():
if not isinstance(enabled, bool):
continue
_upsert_enabled_flag(extension_id, enabled)
return get_extension_settings()

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: a1b2c3d4e5f6
Create Date: 2026-05-25 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "b2c3d4e5f6a7"
down_revision = "a1b2c3d4e5f6"
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

@@ -108,6 +108,22 @@ class KeyValue(Model): # pylint: disable=too-few-public-methods
value = Column(utils.MediumText(), nullable=False)
class ExtensionSettings(Model): # pylint: disable=too-few-public-methods
"""Global admin settings for extensions (singleton row, id=1)."""
__tablename__ = "extension_settings"
id = Column(Integer, primary_key=True)
active_chatbot_id = Column(String(250), nullable=True)
class ExtensionEnabled(Model): # pylint: disable=too-few-public-methods
"""Per-extension enable/disable flag."""
__tablename__ = "extension_enabled"
extension_id = Column(String(250), primary_key=True)
enabled = Column(Boolean, nullable=False, default=True)
class CssTemplate(AuditMixinNullable, UUIDMixin, Model):
"""CSS templates for dashboards"""