Compare commits

...

9 Commits

Author SHA1 Message Date
Enzo Martellucci
69bd1f21b8 fix(extensions): apply CodeAnt review fixes to settings and ExtensionsList
- settings.py: @transaction() replaces manual db.session.commit(); strict
  isinstance(enabled, bool) guard prevents "false" string coercion;
  active_chatbot_id validated as non-empty string before assignment
- ExtensionsList: Switch checked falls back to true (not stale API field);
  chatbotExtensions useMemo reacts to registry changes via subscribeToLocation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 15:24:48 +02:00
Enzo Martellucci
8e7c523353 feat(extensions): chatbot mount point, singleton resolver, and admin settings (SIP P1+P2) 2026-05-26 15:24:48 +02:00
Enzo Martellucci
6848de67e7 feat(extensions): show toast on load failure and export ChatbotView type 2026-05-26 15:24:48 +02:00
Enzo Martellucci
d0561e16e0 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-26 15:24:36 +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
18 changed files with 873 additions and 83 deletions

View File

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

@@ -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,76 @@
/**
* 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 [activeChatbot, setActiveChatbot] = useState(() =>
getActiveChatbot(null),
);
useEffect(() => {
SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
.then(({ json }) => {
const id = json.result?.active_chatbot_id ?? null;
setAdminSelectedId(id);
setActiveChatbot(getActiveChatbot(id));
})
.catch(() => {
// Settings fetch failure is non-fatal — fall back to first-to-register.
});
}, []);
useEffect(
() =>
subscribeToLocation(CHATBOT_LOCATION, () =>
setActiveChatbot(getActiveChatbot(adminSelectedId)),
),
[adminSelectedId],
);
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,76 @@
/**
* 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.
* - If `adminSelectedId` is provided and matches a registered chatbot, that one wins.
* - Otherwise the first-to-register chatbot is used as a fallback.
*
* @param adminSelectedId The id stored in the admin "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;
}
const selectedId =
adminSelectedId && registeredIds.includes(adminSelectedId)
? adminSelectedId
: registeredIds[0];
const provider = getViewProvider(CHATBOT_LOCATION, selectedId);
if (!provider) {
return undefined;
}
return { id: selectedId, provider };
};

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, subscribeToLocation } 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,54 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
addDangerToast,
);
const [settings, setSettings] = useState<ExtensionSettings>({
active_chatbot_id: null,
enabled: {},
});
const [chatbotRegistryVersion, setChatbotRegistryVersion] = useState(0);
useEffect(
() =>
subscribeToLocation(CHATBOT_LOCATION, () =>
setChatbotRegistryVersion(v => v + 1),
),
[],
);
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, chatbotRegistryVersion]);
const columns = useMemo(
() => [
{
@@ -58,15 +117,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 } },
}: any) => (
<Switch
data-test="toggle-enabled"
checked={settings.enabled[id] ?? true}
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 +154,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

@@ -19,6 +19,7 @@
import { useEffect, useState } from 'react';
// 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,
@@ -80,14 +81,29 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
views,
};
const setup = async () => {
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
await ExtensionsLoader.getInstance().initializeExtensions();
}
setInitialized(true);
// Isolate unhandled rejections that originate from extension code so they
// cannot crash the host application. Extensions load via Module Federation
// and their async failures (e.g. failed API calls, unhandled promise
// chains) would otherwise surface as uncaught rejections in the host.
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
// Always log so extension authors can diagnose failures.
logging.error('[extensions] Unhandled rejection from extension:', event.reason);
event.preventDefault();
};
window.addEventListener('unhandledrejection', handleUnhandledRejection);
setup();
// 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);
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
ExtensionsLoader.getInstance().initializeExtensions();
}
return () => {
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, [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,14 @@ 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.settings import (
get_extension_settings,
update_extension_settings,
)
from superset.extensions.utils import (
build_extension_data,
get_extensions,
@@ -167,6 +171,49 @@ 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
"""
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,59 @@
# 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 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},
}
@transaction()
def update_extension_settings(body: dict[str, Any]) -> dict[str, Any]:
row = db.session.get(ExtensionSettings, _SETTINGS_ROW_ID)
if row is None:
row = ExtensionSettings(id=_SETTINGS_ROW_ID)
db.session.add(row)
if "active_chatbot_id" in body:
value = body["active_chatbot_id"]
row.active_chatbot_id = str(value) if isinstance(value, str) and value else None
if "enabled" in body:
for extension_id, enabled in body["enabled"].items():
if not isinstance(enabled, bool):
continue
flag = db.session.get(ExtensionEnabled, extension_id)
if flag is None:
flag = ExtensionEnabled(extension_id=extension_id)
db.session.add(flag)
flag.enabled = 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"""