You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by en...@apache.org on 2026/05/28 08:06:15 UTC
(superset) branch test/chatbot-local created (now df6e0095dcf)
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a change to branch test/chatbot-local
in repository https://gitbox.apache.org/repos/asf/superset.git
at df6e0095dcf fix(extensions): sync PR review fixes to test/chatbot-local
This branch includes the following new commits:
new d7592913ea1 feat(extensions): reference chatbot extension — docs and example
new 97ba65b0cd1 feat(extensions): port import/delete UI and backend endpoints to test branch
new e3efa0ae711 feat(extensions): consolidate actions column and live settings sync
new 96f2fb36592 fix(extensions): defer chatbot render until settings load; isolate provider errors
new a74684b0625 fix(extensions): port CodeAnt fixes — page-type guards, deactivation cleanup, settings race, feature flag gate
new 4e8145f14be fix(extensions): match /sqllab path without trailing slash in derivePageType
new 504826bb24d refactor(extensions): replace per-location pub/sub with registry-wide version counter + useSyncExternalStore
new 22d9332794b fix(extensions): push settings payload through pub/sub to eliminate re-fetch delay on toggle
new 1645a9652f6 fix(extensions): address PR review — rollback, import cleanup, tests, guard
new da4d09a0061 fix(extensions): complete model move — remove from core.py, fix import, ensure ORM registration
new 23d4574caf4 fix(extensions): address context-sharing PR review — blockers, quality, and tests
new c4b9f7b6e54 fix(extensions): fully remove dataset namespace — SDK contract and orphaned host impl
new df6e0095dcf fix(extensions): sync PR review fixes to test/chatbot-local
The 13 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 09/13: fix(extensions): address PR review — rollback, import cleanup, tests, guard
Posted by en...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch test/chatbot-local
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 1645a9652f6dea9246605c88c83b1c96579fc123
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Wed May 27 23:36:05 2026 +0200
fix(extensions): address PR review — rollback, import cleanup, tests, guard
- ExtensionsList: snapshot previous settings before optimistic write;
rollback setSettings + notifyExtensionSettingsChanged in .catch() so a
failed PUT leaves the UI consistent with server state. Drop
setSettings(json.result) from .then() — optimistic write is source of
truth. Switch onClick → onChange. Consolidate Switch/Select/etc into
single @superset-ui/core/components import.
- ChatbotMount: revert undefined loading gate (immediate render, fall
back on fetch error); guard json.result with ?? fallback; merge React
imports; promote ChatbotRenderer comment to JSDoc.
- Tests: add getActiveChatbot coverage for admin-pin, stale-pin fallback,
enabled-filter exclusion, all-disabled. Add ChatbotMount test for
provider function throwing synchronously.
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
.../components/ChatbotMount/ChatbotMount.test.tsx | 16 +++++
.../src/components/ChatbotMount/index.tsx | 35 +++++++----
superset-frontend/src/core/chatbot/index.test.ts | 71 ++++++++++++++++++++++
.../src/extensions/ExtensionsList.tsx | 24 +++++---
4 files changed, 125 insertions(+), 21 deletions(-)
diff --git a/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx
index ba546005398..a039c30a157 100644
--- a/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx
+++ b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx
@@ -89,3 +89,19 @@ test('isolates a failing chatbot so it does not crash the host', () => {
// The host-owned error boundary catches the failure; render does not throw.
expect(() => render(<ChatbotMount />)).not.toThrow();
});
+
+test('isolates a chatbot whose provider function itself throws', () => {
+ disposables.push(
+ views.registerView(
+ { id: 'superset.chatbot', name: 'Superset Chatbot' },
+ CHATBOT_LOCATION,
+ () => {
+ throw new Error('provider blew up');
+ },
+ ),
+ );
+
+ // ChatbotRenderer wraps provider() in a component so ErrorBoundary catches
+ // synchronous throws from the provider function, not just from its output.
+ expect(() => render(<ChatbotMount />)).not.toThrow();
+});
diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx b/superset-frontend/src/components/ChatbotMount/index.tsx
index 3eaec221741..c0ff50ad9ec 100644
--- a/superset-frontend/src/components/ChatbotMount/index.tsx
+++ b/superset-frontend/src/components/ChatbotMount/index.tsx
@@ -16,8 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
-import { ReactElement } from 'react';
+import {
+ type ReactElement,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ useSyncExternalStore,
+} from 'react';
import { SupersetClient } from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/theme';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
@@ -27,20 +33,25 @@ import { subscribeToExtensionSettings } from 'src/core/extensions';
const CHATBOT_EDGE_MARGIN = 24;
-type ActiveChatbot = { provider: () => ReactElement };
-
-// Renders the provider as a component so ErrorBoundary catches provider-level throws.
+/**
+ * Wraps the chatbot provider in a React component so that ErrorBoundary can
+ * catch synchronous throws from the provider function itself. Calling
+ * `provider()` inline (e.g. `{activeChatbot.provider()}`) would throw outside
+ * React's render boundary and crash the host.
+ */
const ChatbotRenderer = ({ provider }: { provider: () => ReactElement }) =>
provider();
const ChatbotMount = () => {
const theme = useTheme();
- // null = settings not yet loaded; don't render anything until settings arrive.
- const [adminSelectedId, setAdminSelectedId] = useState<string | null | undefined>(undefined);
+ const [adminSelectedId, setAdminSelectedId] = useState<string | null>(null);
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
const applySettings = useCallback(
- (settings: { active_chatbot_id: string | null; enabled: Record<string, boolean> }) => {
+ (settings: {
+ active_chatbot_id: string | null;
+ enabled: Record<string, boolean>;
+ }) => {
setAdminSelectedId(settings.active_chatbot_id ?? null);
setEnabledMap(settings.enabled ?? {});
},
@@ -52,10 +63,11 @@ const ChatbotMount = () => {
SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
.then(({ json }) => {
if (cancelled) return;
- applySettings(json.result);
+ applySettings(json.result ?? { active_chatbot_id: null, enabled: {} });
})
.catch(() => {
// Settings fetch failure is non-fatal — fall back to first-to-register.
+ // enabledMap stays {} which getActiveChatbot treats as all-enabled.
setAdminSelectedId(null);
});
return () => {
@@ -71,10 +83,7 @@ const ChatbotMount = () => {
);
const activeChatbot = useMemo(
- () =>
- adminSelectedId === undefined
- ? null
- : getActiveChatbot(adminSelectedId, enabledMap),
+ () => getActiveChatbot(adminSelectedId, enabledMap),
[adminSelectedId, enabledMap, registryVersion],
);
diff --git a/superset-frontend/src/core/chatbot/index.test.ts b/superset-frontend/src/core/chatbot/index.test.ts
index 0b8d6f2f6ec..80cd1d51e95 100644
--- a/superset-frontend/src/core/chatbot/index.test.ts
+++ b/superset-frontend/src/core/chatbot/index.test.ts
@@ -94,3 +94,74 @@ test('getActiveChatbot stops resolving a chatbot once it is disposed', () => {
expect(getActiveChatbot()).toBeUndefined();
});
+
+test('getActiveChatbot honours the admin-pinned selection', () => {
+ const firstProvider = () => React.createElement('div', null, 'First');
+ const secondProvider = () => React.createElement('div', null, 'Second');
+ disposables.push(
+ views.registerView(
+ { id: 'first.chatbot', name: 'First Chatbot' },
+ CHATBOT_LOCATION,
+ firstProvider,
+ ),
+ views.registerView(
+ { id: 'second.chatbot', name: 'Second Chatbot' },
+ CHATBOT_LOCATION,
+ secondProvider,
+ ),
+ );
+
+ const active = getActiveChatbot('second.chatbot');
+ expect(active?.id).toBe('second.chatbot');
+ expect(active?.provider).toBe(secondProvider);
+});
+
+test('getActiveChatbot falls back to first-registered when pinned id is unknown', () => {
+ const provider = () => React.createElement('div', null, 'First');
+ disposables.push(
+ views.registerView(
+ { id: 'first.chatbot', name: 'First Chatbot' },
+ CHATBOT_LOCATION,
+ provider,
+ ),
+ );
+
+ // 'stale.chatbot' was once the admin pin but is no longer registered.
+ const active = getActiveChatbot('stale.chatbot');
+ expect(active?.id).toBe('first.chatbot');
+});
+
+test('getActiveChatbot excludes disabled extensions before applying admin pin', () => {
+ 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,
+ ),
+ );
+
+ // Admin pinned second, but second is disabled — should fall back to first.
+ const active = getActiveChatbot('second.chatbot', {
+ 'second.chatbot': false,
+ });
+ expect(active?.id).toBe('first.chatbot');
+});
+
+test('getActiveChatbot returns undefined when all candidates are disabled', () => {
+ disposables.push(
+ views.registerView(
+ { id: 'superset.chatbot', name: 'Superset Chatbot' },
+ CHATBOT_LOCATION,
+ () => React.createElement('div', null, 'Chatbot'),
+ ),
+ );
+
+ expect(getActiveChatbot(null, { 'superset.chatbot': false })).toBeUndefined();
+});
diff --git a/superset-frontend/src/extensions/ExtensionsList.tsx b/superset-frontend/src/extensions/ExtensionsList.tsx
index 9660ab5b867..1b8083c2e55 100644
--- a/superset-frontend/src/extensions/ExtensionsList.tsx
+++ b/superset-frontend/src/extensions/ExtensionsList.tsx
@@ -27,8 +27,11 @@ import {
useSyncExternalStore,
} from 'react';
import { SupersetClient } from '@superset-ui/core';
-import { ConfirmStatusChange, Tooltip } from '@superset-ui/core/components';
-import { Switch } from '@superset-ui/core/components/Switch';
+import {
+ ConfirmStatusChange,
+ Switch,
+ Tooltip,
+} from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { useListViewResource } from 'src/views/CRUD/hooks';
import { createErrorHandler } from 'src/views/CRUD/utils';
@@ -102,18 +105,23 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
const saveSettings = useCallback(
(patch: Partial<ExtensionSettings>) => {
- const next = { ...settingsRef.current, ...patch };
+ const previous = settingsRef.current;
+ const next = { ...previous, ...patch };
setSettings(next);
+ notifyExtensionSettingsChanged(next);
SupersetClient.put({
endpoint: '/api/v1/extensions/settings',
jsonPayload: next,
})
- .then(({ json }) => {
- setSettings(json.result);
- notifyExtensionSettingsChanged(json.result);
+ .then(() => {
addSuccessToast(t('Settings saved.'));
})
- .catch(() => addDangerToast(t('Failed to save extension settings.')));
+ .catch(() => {
+ // Rollback optimistic update so UI stays consistent with server state.
+ setSettings(previous);
+ notifyExtensionSettingsChanged(previous);
+ addDangerToast(t('Failed to save extension settings.'));
+ });
},
[addDangerToast, addSuccessToast],
);
@@ -232,7 +240,7 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
<Switch
data-test="toggle-enabled"
checked={settings.enabled[id] ?? true}
- onClick={(checked: boolean) => toggleEnabled(id, checked)}
+ onChange={(checked: boolean) => toggleEnabled(id, checked)}
size="small"
/>
</Tooltip>
(superset) 12/13: fix(extensions): fully remove dataset namespace — SDK contract and orphaned host impl
Posted by en...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch test/chatbot-local
in repository https://gitbox.apache.org/repos/asf/superset.git
commit c4b9f7b6e549d67133b295224e9302860ab1aff7
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Thu May 28 09:29:33 2026 +0200
fix(extensions): fully remove dataset namespace — SDK contract and orphaned host impl
The host removed dataset from window.superset but the SDK still exported
the typed contract. An extension importing dataset from @apache-superset/core
would get a fully typed namespace whose runtime calls throw at access time,
which is worse than not shipping it.
Removes:
- packages/superset-core/src/dataset/index.ts (SDK type declarations)
- export * as dataset from './dataset' in superset-core/src/index.ts
- "./dataset" subpath from superset-core/package.json exports
- src/core/dataset/index.ts (orphaned host implementation)
The namespace will be re-introduced once a producer (DatasetCreation or
equivalent) calls setCurrentDataset to back the contract at runtime.
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
.../packages/superset-core/package.json | 4 --
.../packages/superset-core/src/dataset/index.ts | 73 ----------------------
.../packages/superset-core/src/index.ts | 1 -
superset-frontend/src/core/dataset/index.ts | 62 ------------------
4 files changed, 140 deletions(-)
diff --git a/superset-frontend/packages/superset-core/package.json b/superset-frontend/packages/superset-core/package.json
index 0fd06f79a62..42112829336 100644
--- a/superset-frontend/packages/superset-core/package.json
+++ b/superset-frontend/packages/superset-core/package.json
@@ -22,10 +22,6 @@
"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"
diff --git a/superset-frontend/packages/superset-core/src/dataset/index.ts b/superset-frontend/packages/superset-core/src/dataset/index.ts
deleted file mode 100644
index ea3fafa4fdb..00000000000
--- a/superset-frontend/packages/superset-core/src/dataset/index.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-/**
- * @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>;
diff --git a/superset-frontend/packages/superset-core/src/index.ts b/superset-frontend/packages/superset-core/src/index.ts
index 79c699caff4..1ac169a9ad5 100644
--- a/superset-frontend/packages/superset-core/src/index.ts
+++ b/superset-frontend/packages/superset-core/src/index.ts
@@ -20,7 +20,6 @@ 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';
diff --git a/superset-frontend/src/core/dataset/index.ts b/superset-frontend/src/core/dataset/index.ts
deleted file mode 100644
index 3c98d79454b..00000000000
--- a/superset-frontend/src/core/dataset/index.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-/**
- * 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,
-};
(superset) 03/13: feat(extensions): consolidate actions column and live settings sync
Posted by en...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch test/chatbot-local
in repository https://gitbox.apache.org/repos/asf/superset.git
commit e3efa0ae711979e03fe4998ab4a3d0e602b2109f
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Tue May 26 22:51:05 2026 +0200
feat(extensions): consolidate actions column and live settings sync
- Single Actions column: switch (enable/disable), star (default chatbot,
chatbot-type only, toggleable), trash (hidden for LOCAL_EXTENSIONS)
- Add `deletable` field to build_extension_data so the frontend knows
which extensions can be removed via the UI
- Add `publisher` field to Extension type for correct delete URL
- Add notifyExtensionSettingsChanged / subscribeToExtensionSettings
pub/sub in core/extensions so ChatbotMount re-fetches on any settings
change without a page reload
- Wire ChatbotMount to subscribe to settings changes via fetchSettings
callback
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
.../src/components/ChatbotMount/index.tsx | 9 +-
superset-frontend/src/core/extensions/index.ts | 13 ++
.../src/extensions/ExtensionsList.tsx | 173 ++++++++++++---------
superset/extensions/utils.py | 5 +
4 files changed, 123 insertions(+), 77 deletions(-)
diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx b/superset-frontend/src/components/ChatbotMount/index.tsx
index 68e692c57a7..e85a0d3bf7c 100644
--- a/superset-frontend/src/components/ChatbotMount/index.tsx
+++ b/superset-frontend/src/components/ChatbotMount/index.tsx
@@ -16,12 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useCallback } 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 { subscribeToExtensionSettings } from 'src/core/extensions';
import { CHATBOT_LOCATION } from 'src/views/contributions';
const CHATBOT_EDGE_MARGIN = 24;
@@ -34,7 +35,7 @@ const ChatbotMount = () => {
getActiveChatbot(null, {}),
);
- useEffect(() => {
+ const fetchSettings = useCallback(() => {
let cancelled = false;
SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
.then(({ json }) => {
@@ -53,6 +54,10 @@ const ChatbotMount = () => {
};
}, []);
+ useEffect(() => fetchSettings(), [fetchSettings]);
+
+ useEffect(() => subscribeToExtensionSettings(fetchSettings), [fetchSettings]);
+
useEffect(
() =>
subscribeToLocation(CHATBOT_LOCATION, () =>
diff --git a/superset-frontend/src/core/extensions/index.ts b/superset-frontend/src/core/extensions/index.ts
index ae49a135aed..72cfd450fce 100644
--- a/superset-frontend/src/core/extensions/index.ts
+++ b/superset-frontend/src/core/extensions/index.ts
@@ -29,3 +29,16 @@ export const extensions: typeof extensionsApi = {
getExtension,
getAllExtensions,
};
+
+const settingsListeners = new Set<() => void>();
+
+export const notifyExtensionSettingsChanged = (): void => {
+ settingsListeners.forEach(fn => fn());
+};
+
+export const subscribeToExtensionSettings = (
+ listener: () => void,
+): (() => void) => {
+ settingsListeners.add(listener);
+ return () => settingsListeners.delete(listener);
+};
diff --git a/superset-frontend/src/extensions/ExtensionsList.tsx b/superset-frontend/src/extensions/ExtensionsList.tsx
index e31b14a87f2..5daceb60dc2 100644
--- a/superset-frontend/src/extensions/ExtensionsList.tsx
+++ b/superset-frontend/src/extensions/ExtensionsList.tsx
@@ -17,7 +17,6 @@
* under the License.
*/
import { t } from '@apache-superset/core/translation';
-import { css } from '@apache-superset/core/theme';
import {
FunctionComponent,
useCallback,
@@ -27,7 +26,7 @@ import {
useState,
} from 'react';
import { SupersetClient } from '@superset-ui/core';
-import { ConfirmStatusChange, Select, Tooltip } from '@superset-ui/core/components';
+import { ConfirmStatusChange, Tooltip } from '@superset-ui/core/components';
import { Switch } from '@superset-ui/core/components/Switch';
import { Icons } from '@superset-ui/core/components/Icons';
import { useListViewResource } from 'src/views/CRUD/hooks';
@@ -37,6 +36,7 @@ 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';
+import { notifyExtensionSettingsChanged } from 'src/core/extensions';
const PAGE_SIZE = 25;
@@ -45,6 +45,7 @@ type Extension = {
name: string;
publisher: string;
enabled: boolean;
+ deletable: boolean;
};
type ExtensionSettings = {
@@ -103,6 +104,7 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
})
.then(({ json }) => {
setSettings(json.result);
+ notifyExtensionSettingsChanged();
addSuccessToast(t('Settings saved.'));
})
.catch(() => addDangerToast(t('Failed to save extension settings.')));
@@ -112,17 +114,28 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
const toggleEnabled = useCallback(
(extensionId: string, enabled: boolean) => {
- saveSettings({ enabled: { ...settings.enabled, [extensionId]: enabled } });
+ saveSettings({
+ enabled: { ...settings.enabled, [extensionId]: enabled },
+ });
+ },
+ [settings, saveSettings],
+ );
+
+ const setDefaultChatbot = useCallback(
+ (extensionId: string) => {
+ const next =
+ settings.active_chatbot_id === extensionId ? null : extensionId;
+ saveSettings({ active_chatbot_id: next });
},
[settings, saveSettings],
);
- const chatbotExtensions = useMemo(() => {
- const chatbotIds = new Set(getRegisteredViewIds(CHATBOT_LOCATION));
- return resourceCollection.filter(ext => chatbotIds.has(ext.id));
+ const chatbotIds = useMemo(
+ () => new Set(getRegisteredViewIds(CHATBOT_LOCATION)),
// chatbotRegistryVersion is intentionally in deps to re-evaluate when views register
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [resourceCollection, chatbotRegistryVersion]);
+ [chatbotRegistryVersion],
+ );
const handleUploadClick = () => {
fileInputRef.current?.click();
@@ -186,7 +199,6 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
{
Header: t('Name'),
accessor: 'name',
- size: 'lg',
id: 'name',
Cell: ({
row: {
@@ -194,66 +206,96 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
},
}: 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"
- />
- ),
- },
{
Header: t('Actions'),
id: 'actions',
disableSortBy: true,
- Cell: ({ row: { original } }: any) => (
- <ConfirmStatusChange
- title={t('Please confirm')}
- description={
- <>
- {t('Are you sure you want to delete')} <b>{original.name}</b>?
- </>
- }
- onConfirm={() => handleDelete(original)}
- >
- {(confirmDelete: () => void) => (
+ Cell: ({ row: { original } }: any) => {
+ const { id, deletable } = original;
+ const isChatbot = chatbotIds.has(id);
+ const isDefault = settings.active_chatbot_id === id;
+ return (
+ <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Tooltip
- id="delete-extension-tooltip"
- title={t('Delete')}
+ id="toggle-enabled-tooltip"
+ title={t('Enable / Disable')}
placement="bottom"
>
- <span
- role="button"
- tabIndex={0}
- className="action-button"
- onClick={confirmDelete}
- >
- <Icons.DeleteOutlined iconSize="l" />
- </span>
+ <Switch
+ data-test="toggle-enabled"
+ checked={settings.enabled[id] ?? true}
+ onClick={(checked: boolean) => toggleEnabled(id, checked)}
+ size="small"
+ />
</Tooltip>
- )}
- </ConfirmStatusChange>
- ),
+ {isChatbot && (
+ <Tooltip
+ id="set-default-chatbot-tooltip"
+ title={
+ isDefault
+ ? t('Remove default')
+ : t('Set as default chatbot')
+ }
+ placement="bottom"
+ >
+ <span
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={() => setDefaultChatbot(id)}
+ >
+ {isDefault ? (
+ <Icons.StarFilled iconSize="l" />
+ ) : (
+ <Icons.StarOutlined iconSize="l" />
+ )}
+ </span>
+ </Tooltip>
+ )}
+ {deletable && (
+ <ConfirmStatusChange
+ title={t('Please confirm')}
+ description={
+ <>
+ {t('Are you sure you want to delete')}{' '}
+ <b>{original.name}</b>?
+ </>
+ }
+ onConfirm={() => handleDelete(original)}
+ >
+ {(confirmDelete: () => void) => (
+ <Tooltip
+ id="delete-extension-tooltip"
+ title={t('Delete')}
+ placement="bottom"
+ >
+ <span
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={confirmDelete}
+ >
+ <Icons.DeleteOutlined iconSize="l" />
+ </span>
+ </Tooltip>
+ )}
+ </ConfirmStatusChange>
+ )}
+ </span>
+ );
+ },
},
],
- [loading, settings, toggleEnabled],
+ [
+ loading,
+ settings,
+ chatbotIds,
+ toggleEnabled,
+ setDefaultChatbot,
+ handleDelete,
+ ],
);
- const chatbotOptions = chatbotExtensions.map(ext => ({
- label: ext.name,
- value: ext.id,
- }));
-
const menuData: SubMenuProps = {
activeChild: 'Extensions',
name: t('Extensions'),
@@ -285,25 +327,6 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
onChange={handleFileChange}
/>
<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}
diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py
index 878fbf0bb75..4a51ed1d288 100644
--- a/superset/extensions/utils.py
+++ b/superset/extensions/utils.py
@@ -232,6 +232,10 @@ def get_loaded_extension(
def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
manifest = extension.manifest
+ local_paths = {
+ str((Path(p) / "dist").resolve())
+ for p in current_app.config.get("LOCAL_EXTENSIONS", [])
+ }
extension_data: dict[str, Any] = {
"id": manifest.id,
"publisher": manifest.publisher,
@@ -239,6 +243,7 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
"version": extension.version,
"description": manifest.description or "",
"dependencies": manifest.dependencies,
+ "deletable": extension.source_base_path not in local_paths,
}
if manifest.frontend:
frontend = manifest.frontend
(superset) 10/13: fix(extensions): complete model move — remove from core.py, fix import, ensure ORM registration
Posted by en...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch test/chatbot-local
in repository https://gitbox.apache.org/repos/asf/superset.git
commit da4d09a00614eacc9460983351485c1c524a76cb
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Wed May 27 23:51:38 2026 +0200
fix(extensions): complete model move — remove from core.py, fix import, ensure ORM registration
- Create superset/extensions/models.py with ExtensionSettings and ExtensionEnabled.
- Remove both classes from superset/models/core.py.
- Update superset/extensions/settings.py import to the new path.
- ORM registration is guaranteed by the existing import chain:
api.py → settings.py → extensions/models.py — no additional import needed.
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
superset/extensions/models.py | 37 +++++++++++++++++++++++++++++++++++++
superset/extensions/settings.py | 2 +-
superset/models/core.py | 16 ----------------
3 files changed, 38 insertions(+), 17 deletions(-)
diff --git a/superset/extensions/models.py b/superset/extensions/models.py
new file mode 100644
index 00000000000..623b839134a
--- /dev/null
+++ b/superset/extensions/models.py
@@ -0,0 +1,37 @@
+# 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.
+
+"""SQLAlchemy models for extension settings persistence."""
+
+from flask_appbuilder import Model
+from sqlalchemy import Boolean, Column, Integer, String
+
+
+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)
diff --git a/superset/extensions/settings.py b/superset/extensions/settings.py
index 367ab4aef44..ecce0accf19 100644
--- a/superset/extensions/settings.py
+++ b/superset/extensions/settings.py
@@ -23,7 +23,7 @@ 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.extensions.models import ExtensionEnabled, ExtensionSettings
from superset.utils.decorators import transaction
_SETTINGS_ROW_ID = 1
diff --git a/superset/models/core.py b/superset/models/core.py
index 35a36f588d5..1f99630ab3d 100755
--- a/superset/models/core.py
+++ b/superset/models/core.py
@@ -108,22 +108,6 @@ 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"""
(superset) 01/13: feat(extensions): reference chatbot extension — docs and example
Posted by en...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch test/chatbot-local
in repository https://gitbox.apache.org/repos/asf/superset.git
commit d7592913ea113e2f29c8c104f445578b28492078
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Tue May 26 16:12:17 2026 +0200
feat(extensions): reference chatbot extension — docs and example
Ships the full extensions/chat reference implementation that exercises
the chatbot extension platform end-to-end: activation lifecycle and
master disposable (teardown contract), React error boundary (fault
isolation), mock streaming with AbortController cancellation, commands
registration, and the pageContext helper that composes host namespaces.
Local branch only — not intended for upstream merge.
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
extensions/chat/README.md | 138 ++++++++++++++
extensions/chat/extension.json | 40 ++++
extensions/chat/jest.config.js | 53 ++++++
extensions/chat/package.json | 26 +++
extensions/chat/src/ReferenceChatbot.tsx | 45 +++++
extensions/chat/src/__tests__/activate.test.tsx | 144 ++++++++++++++
extensions/chat/src/__tests__/sdkMock.ts | 119 ++++++++++++
extensions/chat/src/activate.ts | 88 +++++++++
extensions/chat/src/commands.ts | 47 +++++
extensions/chat/src/components/Bubble.tsx | 46 +++++
extensions/chat/src/components/ErrorBoundary.tsx | 66 +++++++
extensions/chat/src/components/Panel.tsx | 228 +++++++++++++++++++++++
extensions/chat/src/context/pageContext.ts | 114 ++++++++++++
extensions/chat/src/index.tsx | 30 +++
extensions/chat/src/state.ts | 57 ++++++
extensions/chat/src/streaming/mockStream.ts | 73 ++++++++
extensions/chat/src/streaming/registry.ts | 46 +++++
extensions/chat/tsconfig.json | 15 ++
extensions/chat/tsconfig.test.json | 15 ++
extensions/chat/webpack.config.js | 70 +++++++
20 files changed, 1460 insertions(+)
diff --git a/extensions/chat/README.md b/extensions/chat/README.md
new file mode 100644
index 00000000000..dece34ba327
--- /dev/null
+++ b/extensions/chat/README.md
@@ -0,0 +1,138 @@
+<!--
+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.
+-->
+
+# Reference Chatbot Extension
+
+Canonical environment-validation extension for the `superset.chatbot`
+contribution area. **Not** a product chatbot — there is no LLM, no backend,
+no persistence. Its purpose is to exercise the extension platform end-to-end:
+
+- `views.registerView` at `superset.chatbot` (singleton resolution)
+- Lifecycle activation + a master disposable that tears down everything
+- `commands.registerCommand` for `core.chatbot__open|close|toggle`
+- Mock streaming with `AbortController` cancellation on dispose
+- Defense-in-depth React error boundary inside the panel
+- A single P3 page-context seam that lights up automatically as the
+ `dashboard` / `explore` / `dataset` / `navigation` namespaces become
+ available at runtime on the host
+
+It is intended as the reference implementation third-party chatbot extension
+authors copy. Anything that ships as host-internal (the mount point, the
+admin picker, the `getActiveChatbot` resolver) is **not** here — see the
+host side at `superset-frontend/src/components/ChatbotMount/` and
+`superset-frontend/src/core/chatbot/`.
+
+## Layout
+
+```
+extensions/chat/
+├── extension.json Manifest (app.chatbot view + commands)
+├── package.json
+├── tsconfig.json
+├── webpack.config.js ModuleFederation → window.superset
+├── jest.config.js Self-contained unit tests
+└── src/
+ ├── index.tsx MF entry — calls activate() once
+ ├── activate.ts Returns master disposable
+ ├── commands.ts core.chatbot__open|close|toggle
+ ├── state.ts Module-scoped open/closed + emitter
+ ├── ReferenceChatbot.tsx Root component (bubble ↔ panel)
+ ├── components/
+ │ ├── Bubble.tsx
+ │ ├── Panel.tsx
+ │ └── ErrorBoundary.tsx
+ ├── streaming/
+ │ ├── mockStream.ts AsyncIterable<string> + AbortSignal
+ │ └── registry.ts Cross-component abort tracking
+ ├── context/
+ │ └── pageContext.ts P3 namespace seam (defensive)
+ └── __tests__/
+ ├── sdkMock.ts In-memory @apache-superset/core mock
+ └── activate.test.tsx
+```
+
+## Run the unit tests
+
+```bash
+cd extensions/chat
+npm install # first time only
+npx jest
+```
+
+The tests mock `@apache-superset/core` via `src/__tests__/sdkMock.ts` so they
+do not depend on host runtime wiring.
+
+## Build / bundle for deployment
+
+```bash
+# from the extension folder
+npm install
+npm run build
+
+# packaging into a .supx is handled by the Superset extensions CLI
+pip install apache-superset-extensions-cli
+superset-extensions bundle # produces apache-superset.reference-chatbot-0.1.0.supx
+```
+
+Drop the `.supx` into the `EXTENSIONS_PATH` of a Superset instance that has
+`FEATURE_FLAGS = { "ENABLE_EXTENSIONS": True }`.
+
+## Selecting it as the active chatbot
+
+The host's singleton picker reads `active_chatbot_id` from the admin
+settings endpoint (`/api/v1/extensions/settings`). Set it to:
+
+```
+apache-superset.reference-chatbot
+```
+
+If no admin selection exists, the host falls back to the first-to-register
+chatbot — installing this extension alone is enough for the bubble to appear.
+
+## P3 integration seams
+
+All page-context derivation lives in [`src/context/pageContext.ts`](src/context/pageContext.ts).
+Each namespace branch (`dashboard`, `explore`, `dataset`, `navigation`) is
+called defensively — when the host implementation lands, the returned value
+becomes non-undefined automatically with no other change in the extension.
+
+The panel re-reads context on `popstate`. Once `navigation.onDidChangePage`
+is live on the host, the panel's `useEffect` should subscribe to it instead;
+that is the only file in the extension that needs to change for full P3
+context sync.
+
+## Known intentional non-features
+
+- No conversation persistence — by design (extension scope per SIP §2).
+- No real network. The mock stream is a `setTimeout` token emitter so the
+ cancellation contract is exercised without external dependencies.
+- No keyboard shortcut binding (Cmd+K). Extensions own that, but it adds
+ surface area not needed for platform validation.
+- No notification badge / icon mutation. SIP §3.2 recommends static icons;
+ the bubble re-renders freely already.
+
+## TODOs
+
+- **P1**: if/when the host gains `deactivate(): Promise<void>`, wrap the
+ master disposer in `activate.ts` to flush async work before returning.
+- **P3**: replace the `popstate` listener in `Panel.tsx` with
+ `navigation.onDidChangePage` once that event is wired up host-side.
+- **P4**: if the host pre-registers `core.chatbot__*` as host-owned intents,
+ swap `commands.registerCommand` for the implementation hook in
+ `commands.ts`. Command IDs do not change.
diff --git a/extensions/chat/extension.json b/extensions/chat/extension.json
new file mode 100644
index 00000000000..58fc98c7e3c
--- /dev/null
+++ b/extensions/chat/extension.json
@@ -0,0 +1,40 @@
+{
+ "publisher": "apache-superset",
+ "name": "reference-chatbot",
+ "displayName": "Reference Chatbot",
+ "description": "Canonical environment-validation chatbot extension for the superset.chatbot contribution area. Exercises registration, lifecycle, singleton resolution, commands, fault isolation, and streaming teardown. Not a product chatbot.",
+ "version": "0.1.0",
+ "license": "Apache-2.0",
+ "permissions": [],
+ "contributes": {
+ "views": {
+ "app": {
+ "chatbot": [
+ {
+ "id": "apache-superset.reference-chatbot",
+ "name": "Reference Chatbot",
+ "description": "Validates the chatbot extension environment end-to-end.",
+ "icon": "Bubble"
+ }
+ ]
+ }
+ },
+ "commands": [
+ {
+ "id": "core.chatbot__open",
+ "title": "Open chatbot",
+ "description": "Opens the reference chatbot panel."
+ },
+ {
+ "id": "core.chatbot__close",
+ "title": "Close chatbot",
+ "description": "Closes the reference chatbot panel."
+ },
+ {
+ "id": "core.chatbot__toggle",
+ "title": "Toggle chatbot",
+ "description": "Toggles the reference chatbot panel."
+ }
+ ]
+ }
+}
diff --git a/extensions/chat/jest.config.js b/extensions/chat/jest.config.js
new file mode 100644
index 00000000000..7151d801622
--- /dev/null
+++ b/extensions/chat/jest.config.js
@@ -0,0 +1,53 @@
+/**
+ * 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.
+ */
+const path = require('path');
+
+// When run as a standalone package (`cd extensions/chat && npm test`), modules
+// resolve from this folder's own node_modules. When run from the superset-frontend
+// workspace (CI, dev convenience), resolve ts-jest there too.
+const tsJest = (() => {
+ try {
+ require.resolve('ts-jest');
+ return 'ts-jest';
+ } catch {
+ return path.resolve(
+ __dirname,
+ '..',
+ '..',
+ 'superset-frontend',
+ 'node_modules',
+ 'ts-jest',
+ );
+ }
+})();
+
+module.exports = {
+ testEnvironment: 'jsdom',
+ rootDir: __dirname,
+ testMatch: ['<rootDir>/src/**/*.test.{ts,tsx}'],
+ // When running from the extension folder without node_modules installed,
+ // resolve react / react-dom from the superset-frontend workspace.
+ modulePaths: [path.resolve(__dirname, '..', '..', 'superset-frontend', 'node_modules')],
+ moduleNameMapper: {
+ '^@apache-superset/core$': '<rootDir>/src/__tests__/sdkMock.ts',
+ },
+ transform: {
+ '^.+\\.tsx?$': [tsJest, { tsconfig: '<rootDir>/tsconfig.test.json' }],
+ },
+};
diff --git a/extensions/chat/package.json b/extensions/chat/package.json
new file mode 100644
index 00000000000..ace4b103324
--- /dev/null
+++ b/extensions/chat/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@apache-superset/reference-chatbot",
+ "version": "0.1.0",
+ "private": true,
+ "license": "Apache-2.0",
+ "description": "Reference chatbot extension that validates the Superset chatbot extension platform.",
+ "scripts": {
+ "start": "webpack serve --mode development",
+ "build": "webpack --stats-error-details --mode production"
+ },
+ "peerDependencies": {
+ "@apache-superset/core": "^0.1.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ },
+ "devDependencies": {
+ "@apache-superset/core": "^0.1.0",
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "ts-loader": "^9.5.0",
+ "typescript": "^5.0.0",
+ "webpack": "^5.0.0",
+ "webpack-cli": "^5.0.0",
+ "webpack-dev-server": "^5.0.0"
+ }
+}
diff --git a/extensions/chat/src/ReferenceChatbot.tsx b/extensions/chat/src/ReferenceChatbot.tsx
new file mode 100644
index 00000000000..971950bc8c4
--- /dev/null
+++ b/extensions/chat/src/ReferenceChatbot.tsx
@@ -0,0 +1,45 @@
+/**
+ * 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, { useEffect, useState } from 'react';
+import { commands } from '@apache-superset/core';
+import { Bubble } from './components/Bubble';
+import { Panel } from './components/Panel';
+import { ExtensionErrorBoundary } from './components/ErrorBoundary';
+import { isOpen, subscribe } from './state';
+
+/**
+ * Root extension component. Mirrors module-state into React via `subscribe`
+ * so the bubble↔panel transition is driven by the same command handlers
+ * that external callers use (`core.chatbot__open`, `__close`, `__toggle`).
+ */
+export const ReferenceChatbot: React.FC = () => {
+ const [open, setOpenState] = useState<boolean>(isOpen());
+
+ useEffect(() => subscribe(setOpenState), []);
+
+ return (
+ <ExtensionErrorBoundary>
+ {open ? (
+ <Panel onClose={() => commands.executeCommand('core.chatbot__close')} />
+ ) : (
+ <Bubble onClick={() => commands.executeCommand('core.chatbot__open')} />
+ )}
+ </ExtensionErrorBoundary>
+ );
+};
diff --git a/extensions/chat/src/__tests__/activate.test.tsx b/extensions/chat/src/__tests__/activate.test.tsx
new file mode 100644
index 00000000000..99926c9706b
--- /dev/null
+++ b/extensions/chat/src/__tests__/activate.test.tsx
@@ -0,0 +1,144 @@
+/**
+ * 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 { commands } from '@apache-superset/core';
+import { registry, reset } from './sdkMock';
+import { activate, VIEW_ID, CHATBOT_LOCATION } from '../activate';
+import { isOpen } from '../state';
+import { streamReply } from '../streaming/mockStream';
+import {
+ registerActiveController,
+ unregisterActiveController,
+ abortAllActiveControllers,
+} from '../streaming/registry';
+
+beforeEach(() => {
+ reset();
+});
+
+test('registers one view at superset.chatbot and three chatbot commands', () => {
+ const disposable = activate();
+
+ try {
+ expect(registry.views.size).toBe(1);
+ const entry = registry.views.get(VIEW_ID);
+ expect(entry?.location).toBe(CHATBOT_LOCATION);
+ expect(entry?.view.icon).toBe('Bubble');
+
+ expect(Array.from(registry.commands.keys()).sort()).toEqual([
+ 'core.chatbot__close',
+ 'core.chatbot__open',
+ 'core.chatbot__toggle',
+ ]);
+ } finally {
+ disposable.dispose();
+ }
+});
+
+test('executeCommand drives open/close/toggle through module state', async () => {
+ const disposable = activate();
+ try {
+ expect(isOpen()).toBe(false);
+
+ await commands.executeCommand('core.chatbot__open');
+ expect(isOpen()).toBe(true);
+
+ await commands.executeCommand('core.chatbot__toggle');
+ expect(isOpen()).toBe(false);
+
+ await commands.executeCommand('core.chatbot__toggle');
+ expect(isOpen()).toBe(true);
+
+ await commands.executeCommand('core.chatbot__close');
+ expect(isOpen()).toBe(false);
+ } finally {
+ disposable.dispose();
+ }
+});
+
+test('disposing the master disposable unregisters view + commands', () => {
+ const disposable = activate();
+ expect(registry.views.size).toBe(1);
+ expect(registry.commands.size).toBe(3);
+
+ disposable.dispose();
+
+ expect(registry.views.size).toBe(0);
+ expect(registry.commands.size).toBe(0);
+});
+
+test('disposal is idempotent', () => {
+ const disposable = activate();
+ disposable.dispose();
+ expect(() => disposable.dispose()).not.toThrow();
+ expect(registry.views.size).toBe(0);
+});
+
+test('re-activate after dispose works (validates replace semantics)', () => {
+ const first = activate();
+ first.dispose();
+
+ const second = activate();
+ try {
+ expect(registry.views.size).toBe(1);
+ expect(registry.commands.size).toBe(3);
+ expect(isOpen()).toBe(false); // resetState() cleared open flag
+ } finally {
+ second.dispose();
+ }
+});
+
+test('aborting an active controller stops the stream cleanly', async () => {
+ const controller = new AbortController();
+ registerActiveController(controller);
+
+ const iter = streamReply('hello world', controller.signal);
+ const received: string[] = [];
+
+ const consume = (async () => {
+ for await (const tok of iter) received.push(tok);
+ })();
+
+ // Abort after a single tick — the iterator must return without throwing.
+ await new Promise(r => setTimeout(r, 50));
+ abortAllActiveControllers();
+ await expect(consume).resolves.toBeUndefined();
+
+ unregisterActiveController(controller);
+ expect(received.length).toBeLessThan(20); // would be ~20+ tokens if uncancelled
+});
+
+test('disposing the extension aborts any in-flight controller', async () => {
+ const disposable = activate();
+ const controller = new AbortController();
+ registerActiveController(controller);
+
+ const iter = streamReply('a longer prompt to ensure many tokens', controller.signal);
+ const consume = (async () => {
+ // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
+ for await (const _tok of iter) {
+ // drain
+ }
+ })();
+
+ await new Promise(r => setTimeout(r, 30));
+ disposable.dispose();
+
+ await expect(consume).resolves.toBeUndefined();
+ expect(controller.signal.aborted).toBe(true);
+});
diff --git a/extensions/chat/src/__tests__/sdkMock.ts b/extensions/chat/src/__tests__/sdkMock.ts
new file mode 100644
index 00000000000..60b089807b9
--- /dev/null
+++ b/extensions/chat/src/__tests__/sdkMock.ts
@@ -0,0 +1,119 @@
+/**
+ * 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.
+ */
+
+/**
+ * In-memory mock of `@apache-superset/core` for unit-testing the extension.
+ *
+ * Mirrors only the surfaces the reference chatbot consumes:
+ * - views.registerView returns a disposable that removes the view
+ * - commands.registerCommand / executeCommand round-trip handlers
+ * - sqlLab.getCurrentTab returns undefined (no SQL Lab in tests)
+ *
+ * The mock is intentionally observable: tests can read `registry.views` and
+ * `registry.commands` to assert contract compliance.
+ */
+
+import type { ReactElement } from 'react';
+
+type Provider = () => ReactElement;
+
+interface ViewDescriptor {
+ id: string;
+ name: string;
+ icon?: string;
+ description?: string;
+}
+
+interface DisposableLike {
+ dispose(): void;
+}
+
+interface RegisteredView {
+ view: ViewDescriptor;
+ location: string;
+ provider: Provider;
+}
+
+interface RegisteredCommand {
+ id: string;
+ title: string;
+ handler: (...args: any[]) => any;
+}
+
+export const registry = {
+ views: new Map<string, RegisteredView>(),
+ commands: new Map<string, RegisteredCommand>(),
+};
+
+export const reset = (): void => {
+ registry.views.clear();
+ registry.commands.clear();
+};
+
+export const views = {
+ registerView(
+ view: ViewDescriptor,
+ location: string,
+ provider: Provider,
+ ): DisposableLike {
+ registry.views.set(view.id, { view, location, provider });
+ return {
+ dispose: () => {
+ registry.views.delete(view.id);
+ },
+ };
+ },
+ getViews(location: string) {
+ return Array.from(registry.views.values())
+ .filter(v => v.location === location)
+ .map(v => v.view);
+ },
+};
+
+export const commands = {
+ registerCommand(
+ command: { id: string; title: string },
+ handler: (...args: any[]) => any,
+ ): DisposableLike {
+ registry.commands.set(command.id, {
+ id: command.id,
+ title: command.title,
+ handler,
+ });
+ return {
+ dispose: () => {
+ registry.commands.delete(command.id);
+ },
+ };
+ },
+ async executeCommand(id: string, ...rest: any[]): Promise<unknown> {
+ const cmd = registry.commands.get(id);
+ return cmd?.handler(...rest);
+ },
+ getCommands() {
+ return Array.from(registry.commands.values()).map(c => ({
+ id: c.id,
+ title: c.title,
+ }));
+ },
+};
+
+export const sqlLab = {
+ getCurrentTab: () => undefined,
+};
diff --git a/extensions/chat/src/activate.ts b/extensions/chat/src/activate.ts
new file mode 100644
index 00000000000..1c48ba7249f
--- /dev/null
+++ b/extensions/chat/src/activate.ts
@@ -0,0 +1,88 @@
+/**
+ * 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 '@apache-superset/core';
+import { ReferenceChatbot } from './ReferenceChatbot';
+import { registerChatbotCommands } from './commands';
+import { abortAllActiveControllers } from './streaming/registry';
+import { resetState } from './state';
+
+export const VIEW_ID = 'apache-superset.reference-chatbot';
+export const CHATBOT_LOCATION = 'superset.chatbot';
+
+interface DisposableLike {
+ dispose(): void;
+}
+
+/**
+ * Registers the reference chatbot and returns a single disposable that
+ * tears down everything it created. Idempotent across activate/dispose cycles.
+ *
+ * Cleanup order matters: stop in-flight streams first so listeners do not
+ * receive late tokens, then unregister commands (so user clicks during teardown
+ * become no-ops), then unregister the view (so the host's ChatbotMount unmounts
+ * the React tree), and finally reset module state.
+ *
+ * Returns a plain `{ dispose }` object rather than constructing a Disposable
+ * from the SDK — the SDK class is host-injected and only reliably available
+ * via window.superset at runtime, while plain disposable-likes work in both
+ * runtime and unit-test contexts.
+ *
+ * TODO(P1): when the host gains an async `deactivate(): Promise<void>` hook,
+ * wrap the master disposer to flush in-flight async work before returning.
+ */
+export const activate = (): DisposableLike => {
+ const commandDisposables = registerChatbotCommands();
+ const viewDisposable = views.registerView(
+ {
+ id: VIEW_ID,
+ name: 'Reference Chatbot',
+ icon: 'Bubble',
+ description: 'Validates the chatbot extension environment end-to-end.',
+ },
+ CHATBOT_LOCATION,
+ () => React.createElement(ReferenceChatbot),
+ );
+
+ let disposed = false;
+ return {
+ dispose() {
+ if (disposed) return;
+ disposed = true;
+ try {
+ abortAllActiveControllers();
+ } catch {
+ // streams are best-effort during teardown
+ }
+ commandDisposables.forEach(d => {
+ try {
+ d.dispose();
+ } catch {
+ // a single command failing to unregister must not block the rest
+ }
+ });
+ try {
+ viewDisposable.dispose();
+ } catch {
+ // ignore
+ }
+ resetState();
+ },
+ };
+};
diff --git a/extensions/chat/src/commands.ts b/extensions/chat/src/commands.ts
new file mode 100644
index 00000000000..4f19a8610b2
--- /dev/null
+++ b/extensions/chat/src/commands.ts
@@ -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.
+ */
+import { commands } from '@apache-superset/core';
+import { isOpen, setOpen } from './state';
+
+interface DisposableLike {
+ dispose(): void;
+}
+
+/**
+ * Registers the three chatbot intent commands and returns their disposables.
+ *
+ * TODO(P4): if/when the host pre-registers `core.chatbot__*` as host-owned
+ * intents that extensions implement instead of own, swap registerCommand for
+ * the implementation hook. The command ids stay the same so call sites do not
+ * change.
+ */
+export const registerChatbotCommands = (): DisposableLike[] => [
+ commands.registerCommand(
+ { id: 'core.chatbot__open', title: 'Open chatbot' },
+ () => setOpen(true),
+ ),
+ commands.registerCommand(
+ { id: 'core.chatbot__close', title: 'Close chatbot' },
+ () => setOpen(false),
+ ),
+ commands.registerCommand(
+ { id: 'core.chatbot__toggle', title: 'Toggle chatbot' },
+ () => setOpen(!isOpen()),
+ ),
+];
diff --git a/extensions/chat/src/components/Bubble.tsx b/extensions/chat/src/components/Bubble.tsx
new file mode 100644
index 00000000000..488ccc0cb4e
--- /dev/null
+++ b/extensions/chat/src/components/Bubble.tsx
@@ -0,0 +1,46 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React from 'react';
+
+interface Props {
+ onClick: () => void;
+}
+
+export const Bubble: React.FC<Props> = ({ onClick }) => (
+ <button
+ type="button"
+ onClick={onClick}
+ data-test="reference-chatbot-bubble"
+ aria-label="Open reference chatbot"
+ style={{
+ width: 56,
+ height: 56,
+ borderRadius: '50%',
+ border: 'none',
+ background: '#1f6feb',
+ color: '#fff',
+ fontSize: 24,
+ fontWeight: 600,
+ cursor: 'pointer',
+ boxShadow: '0 4px 14px rgba(0,0,0,0.18)',
+ }}
+ >
+ ?
+ </button>
+);
diff --git a/extensions/chat/src/components/ErrorBoundary.tsx b/extensions/chat/src/components/ErrorBoundary.tsx
new file mode 100644
index 00000000000..0d173a9068a
--- /dev/null
+++ b/extensions/chat/src/components/ErrorBoundary.tsx
@@ -0,0 +1,66 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React from 'react';
+
+interface State {
+ error: Error | null;
+}
+
+/**
+ * Defense-in-depth boundary. The host already wraps the mount in its own
+ * ErrorBoundary; this one keeps a panel crash from also bringing down the
+ * bubble next to it.
+ */
+export class ExtensionErrorBoundary extends React.Component<
+ React.PropsWithChildren<{}>,
+ State
+> {
+ state: State = { error: null };
+
+ static getDerivedStateFromError(error: Error): State {
+ return { error };
+ }
+
+ componentDidCatch(error: Error): void {
+ // eslint-disable-next-line no-console
+ console.error('[reference-chatbot] render error', error);
+ }
+
+ render() {
+ if (this.state.error) {
+ return (
+ <div
+ data-test="reference-chatbot-error"
+ style={{
+ padding: 12,
+ border: '1px solid #f5222d',
+ borderRadius: 6,
+ background: '#fff1f0',
+ color: '#a8071a',
+ fontSize: 12,
+ maxWidth: 320,
+ }}
+ >
+ Reference chatbot crashed: {this.state.error.message}
+ </div>
+ );
+ }
+ return <>{this.props.children}</>;
+ }
+}
diff --git a/extensions/chat/src/components/Panel.tsx b/extensions/chat/src/components/Panel.tsx
new file mode 100644
index 00000000000..895eb4ada47
--- /dev/null
+++ b/extensions/chat/src/components/Panel.tsx
@@ -0,0 +1,228 @@
+/**
+ * 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, { useCallback, useEffect, useRef, useState } from 'react';
+import { streamReply } from '../streaming/mockStream';
+import { getPageContext, PageContext, subscribeToPageChanges } from '../context/pageContext';
+import { registerActiveController, unregisterActiveController } from '../streaming/registry';
+
+interface Props {
+ onClose: () => void;
+}
+
+interface Message {
+ id: number;
+ from: 'user' | 'bot';
+ text: string;
+}
+
+let messageSeq = 0;
+
+export const Panel: React.FC<Props> = ({ onClose }) => {
+ const [input, setInput] = useState('');
+ const [messages, setMessages] = useState<Message[]>([]);
+ const [streaming, setStreaming] = useState(false);
+ const [pageContext, setPageContext] = useState<PageContext>(() => getPageContext());
+ const controllerRef = useRef<AbortController | null>(null);
+
+ useEffect(
+ () => subscribeToPageChanges(() => setPageContext(getPageContext())),
+ [],
+ );
+
+ useEffect(
+ () => () => {
+ // Component unmount cancels any in-flight stream.
+ controllerRef.current?.abort();
+ },
+ [],
+ );
+
+ const send = useCallback(async () => {
+ const prompt = input.trim();
+ if (!prompt || streaming) return;
+ setInput('');
+ const userMsg: Message = { id: ++messageSeq, from: 'user', text: prompt };
+ const botMsg: Message = { id: ++messageSeq, from: 'bot', text: '' };
+ setMessages(prev => [...prev, userMsg, botMsg]);
+ setStreaming(true);
+
+ const controller = new AbortController();
+ controllerRef.current = controller;
+ registerActiveController(controller);
+
+ try {
+ for await (const token of streamReply(prompt, controller.signal)) {
+ setMessages(prev =>
+ prev.map(m => (m.id === botMsg.id ? { ...m, text: m.text + token } : m)),
+ );
+ }
+ } finally {
+ unregisterActiveController(controller);
+ controllerRef.current = null;
+ setStreaming(false);
+ }
+ }, [input, streaming]);
+
+ const cancel = useCallback(() => {
+ controllerRef.current?.abort();
+ }, []);
+
+ return (
+ <div
+ data-test="reference-chatbot-panel"
+ style={{
+ width: 360,
+ maxHeight: 480,
+ display: 'flex',
+ flexDirection: 'column',
+ background: '#fff',
+ border: '1px solid #d9d9d9',
+ borderRadius: 8,
+ boxShadow: '0 8px 24px rgba(0,0,0,0.18)',
+ overflow: 'hidden',
+ fontSize: 13,
+ }}
+ >
+ <header
+ style={{
+ padding: '8px 12px',
+ background: '#1f6feb',
+ color: '#fff',
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ }}
+ >
+ <span>Reference Chatbot</span>
+ <button
+ type="button"
+ onClick={onClose}
+ aria-label="Close chatbot"
+ data-test="reference-chatbot-close"
+ style={{
+ background: 'transparent',
+ border: 'none',
+ color: '#fff',
+ fontSize: 16,
+ cursor: 'pointer',
+ }}
+ >
+ ×
+ </button>
+ </header>
+
+ <div
+ data-test="reference-chatbot-context"
+ style={{
+ padding: '6px 12px',
+ background: '#f6f8fa',
+ borderBottom: '1px solid #eaecef',
+ fontFamily: 'monospace',
+ fontSize: 11,
+ color: '#57606a',
+ wordBreak: 'break-all',
+ }}
+ >
+ page: {pageContext.pageType}
+ {pageContext.sqlLab ? ` · tab: ${pageContext.sqlLab.title}` : ''}
+ </div>
+
+ <div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
+ {messages.length === 0 && (
+ <p style={{ color: '#8c8c8c' }}>
+ Ask anything — replies are canned tokens streamed by the reference extension.
+ </p>
+ )}
+ {messages.map(m => (
+ <div
+ key={m.id}
+ data-test={`reference-chatbot-msg-${m.from}`}
+ style={{
+ margin: '6px 0',
+ textAlign: m.from === 'user' ? 'right' : 'left',
+ }}
+ >
+ <span
+ style={{
+ display: 'inline-block',
+ padding: '4px 8px',
+ borderRadius: 6,
+ background: m.from === 'user' ? '#1f6feb' : '#eef0f3',
+ color: m.from === 'user' ? '#fff' : '#1f2328',
+ maxWidth: '85%',
+ whiteSpace: 'pre-wrap',
+ }}
+ >
+ {m.text || '…'}
+ </span>
+ </div>
+ ))}
+ </div>
+
+ <footer
+ style={{
+ padding: 8,
+ borderTop: '1px solid #eaecef',
+ display: 'flex',
+ gap: 6,
+ }}
+ >
+ <input
+ aria-label="Chat input"
+ data-test="reference-chatbot-input"
+ value={input}
+ onChange={e => setInput(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ send();
+ }
+ }}
+ placeholder="Type a message"
+ style={{
+ flex: 1,
+ padding: '4px 8px',
+ border: '1px solid #d9d9d9',
+ borderRadius: 4,
+ }}
+ />
+ {streaming ? (
+ <button
+ type="button"
+ onClick={cancel}
+ data-test="reference-chatbot-cancel"
+ style={{ padding: '4px 10px' }}
+ >
+ Stop
+ </button>
+ ) : (
+ <button
+ type="button"
+ onClick={send}
+ data-test="reference-chatbot-send"
+ disabled={!input.trim()}
+ style={{ padding: '4px 10px' }}
+ >
+ Send
+ </button>
+ )}
+ </footer>
+ </div>
+ );
+};
diff --git a/extensions/chat/src/context/pageContext.ts b/extensions/chat/src/context/pageContext.ts
new file mode 100644
index 00000000000..bc6bc92c060
--- /dev/null
+++ b/extensions/chat/src/context/pageContext.ts
@@ -0,0 +1,114 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Single integration seam for the P3 namespaces.
+ *
+ * Each surface namespace is consumed via a try/catch — the host may ship a
+ * version where a namespace function is declared but not yet implemented at
+ * runtime, and the reference extension must keep working in that case. As
+ * each namespace lights up on the host, that branch starts returning real
+ * data without any change here.
+ *
+ * Route inference is the fallback when navigation.getPageType() is absent.
+ */
+
+import * as core from '@apache-superset/core';
+
+export type PageType =
+ | 'home'
+ | 'dashboard'
+ | 'chart'
+ | 'sqllab'
+ | 'dataset'
+ | 'unknown';
+
+export interface PageContext {
+ pageType: PageType;
+ dashboard?: unknown;
+ chart?: unknown;
+ dataset?: unknown;
+ sqlLab?: { tabId: string; title: string };
+ href: string;
+}
+
+const tryCall = <T>(fn: () => T | undefined): T | undefined => {
+ try {
+ return fn();
+ } catch {
+ return undefined;
+ }
+};
+
+const inferPageType = (pathname: string): PageType => {
+ if (pathname.startsWith('/sqllab')) return 'sqllab';
+ if (
+ pathname.startsWith('/superset/dashboard') ||
+ pathname.startsWith('/dashboard')
+ )
+ return 'dashboard';
+ if (pathname.startsWith('/explore') || pathname.startsWith('/chart'))
+ return 'chart';
+ if (pathname.startsWith('/tablemodelview') || pathname.startsWith('/dataset'))
+ return 'dataset';
+ if (pathname === '/' || pathname.startsWith('/superset/welcome'))
+ return 'home';
+ return 'unknown';
+};
+
+const readSqlLabTab = (): PageContext['sqlLab'] => {
+ const tab = tryCall(() => (core as any).sqlLab?.getCurrentTab?.());
+ return tab ? { tabId: tab.id, title: tab.title } : undefined;
+};
+
+const readPageType = (pathname: string): PageType => {
+ const fromNav = tryCall(() => (core as any).navigation?.getPageType?.());
+ return (fromNav as PageType | undefined) ?? inferPageType(pathname);
+};
+
+/**
+ * Subscribe to page changes. Uses navigation.onDidChangePage when available,
+ * falls back to popstate for hosts without the navigation namespace.
+ * Returns a cleanup function.
+ */
+export const subscribeToPageChanges = (onChange: () => void): (() => void) => {
+ const nav = tryCall(() => (core as any).navigation);
+ if (nav?.onDidChangePage) {
+ const sub = nav.onDidChangePage(onChange);
+ return () => sub.dispose();
+ }
+ window.addEventListener('popstate', onChange);
+ return () => window.removeEventListener('popstate', onChange);
+};
+
+export const getPageContext = (): PageContext => {
+ const { pathname, href } =
+ typeof window !== 'undefined'
+ ? window.location
+ : { pathname: '', href: '' };
+
+ return {
+ pageType: readPageType(pathname),
+ dashboard: tryCall(() => (core as any).dashboard?.getCurrentDashboard?.()),
+ chart: tryCall(() => (core as any).explore?.getCurrentChart?.()),
+ dataset: tryCall(() => (core as any).dataset?.getCurrentDataset?.()),
+ sqlLab: readSqlLabTab(),
+ href,
+ };
+};
diff --git a/extensions/chat/src/index.tsx b/extensions/chat/src/index.tsx
new file mode 100644
index 00000000000..688a5bf283a
--- /dev/null
+++ b/extensions/chat/src/index.tsx
@@ -0,0 +1,30 @@
+/**
+ * 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.
+ */
+
+/**
+ * Module Federation entry. The host loads `./index` and invokes the factory;
+ * the side effect below registers the view + commands. The host's loader
+ * intercepts registerView calls to collect disposables for deactivation, so
+ * returning the master Disposable here is also captured by the test harness
+ * for direct assertion.
+ */
+
+import { activate } from './activate';
+
+export const disposable = activate();
diff --git a/extensions/chat/src/state.ts b/extensions/chat/src/state.ts
new file mode 100644
index 00000000000..5b4a00780d7
--- /dev/null
+++ b/extensions/chat/src/state.ts
@@ -0,0 +1,57 @@
+/**
+ * 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.
+ */
+
+/**
+ * Module-scoped open/closed state plus a tiny emitter the UI subscribes to.
+ *
+ * Lives entirely inside the extension — never reaches into the host store.
+ * Reset on dispose so re-activation starts cleanly.
+ */
+
+export type OpenStateListener = (open: boolean) => void;
+
+let open = false;
+const listeners = new Set<OpenStateListener>();
+
+export const isOpen = (): boolean => open;
+
+export const setOpen = (next: boolean): void => {
+ if (next === open) return;
+ open = next;
+ listeners.forEach(fn => {
+ try {
+ fn(open);
+ } catch {
+ // A listener throwing must not block other listeners or flip our state back.
+ }
+ });
+};
+
+export const subscribe = (fn: OpenStateListener): (() => void) => {
+ listeners.add(fn);
+ return () => {
+ listeners.delete(fn);
+ };
+};
+
+/** Drains listeners and resets state. Called from the master Disposable. */
+export const resetState = (): void => {
+ open = false;
+ listeners.clear();
+};
diff --git a/extensions/chat/src/streaming/mockStream.ts b/extensions/chat/src/streaming/mockStream.ts
new file mode 100644
index 00000000000..a72cf0d6435
--- /dev/null
+++ b/extensions/chat/src/streaming/mockStream.ts
@@ -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.
+ */
+
+/**
+ * Mock streaming reply used to validate stream teardown semantics.
+ *
+ * The reference chatbot is environment-validation only — there is no LLM.
+ * This iterator yields canned tokens on a timer and exits cleanly when its
+ * AbortSignal is fired. Disposal of the extension aborts any in-flight
+ * controller, which is the contract that proves async cancellation works.
+ */
+
+const TICK_MS = 40;
+
+const buildReply = (prompt: string): string => {
+ const trimmed = prompt.trim();
+ if (!trimmed) {
+ return 'Reference chatbot online. Send a message to validate streaming.';
+ }
+ return (
+ `[reference-chatbot] received "${trimmed}". ` +
+ 'Streaming token-by-token to validate cancellation and teardown.'
+ );
+};
+
+const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
+ new Promise((resolve, reject) => {
+ if (signal.aborted) {
+ reject(new DOMException('aborted', 'AbortError'));
+ return;
+ }
+ const timer = setTimeout(() => {
+ signal.removeEventListener('abort', onAbort);
+ resolve();
+ }, ms);
+ const onAbort = () => {
+ clearTimeout(timer);
+ reject(new DOMException('aborted', 'AbortError'));
+ };
+ signal.addEventListener('abort', onAbort, { once: true });
+ });
+
+export async function* streamReply(
+ prompt: string,
+ signal: AbortSignal,
+): AsyncIterableIterator<string> {
+ const tokens = buildReply(prompt).split(/(\s+)/);
+ for (const token of tokens) {
+ if (signal.aborted) return;
+ try {
+ await sleep(TICK_MS, signal);
+ } catch {
+ return;
+ }
+ yield token;
+ }
+}
diff --git a/extensions/chat/src/streaming/registry.ts b/extensions/chat/src/streaming/registry.ts
new file mode 100644
index 00000000000..fca0f2fdbc8
--- /dev/null
+++ b/extensions/chat/src/streaming/registry.ts
@@ -0,0 +1,46 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Module-scoped registry of in-flight stream AbortControllers.
+ *
+ * Lets the master Disposable abort any running stream even when the panel
+ * is unmounted by a route change or by re-activation of the extension.
+ */
+
+const active = new Set<AbortController>();
+
+export const registerActiveController = (c: AbortController): void => {
+ active.add(c);
+};
+
+export const unregisterActiveController = (c: AbortController): void => {
+ active.delete(c);
+};
+
+export const abortAllActiveControllers = (): void => {
+ active.forEach(c => {
+ try {
+ c.abort();
+ } catch {
+ // ignore — abort() should not throw, but stay defensive.
+ }
+ });
+ active.clear();
+};
diff --git a/extensions/chat/tsconfig.json b/extensions/chat/tsconfig.json
new file mode 100644
index 00000000000..0edb6a5ac3f
--- /dev/null
+++ b/extensions/chat/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "es2019",
+ "module": "esnext",
+ "moduleResolution": "node",
+ "jsx": "react",
+ "strict": true,
+ "noImplicitAny": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "lib": ["dom", "es2019"]
+ },
+ "include": ["src"]
+}
diff --git a/extensions/chat/tsconfig.test.json b/extensions/chat/tsconfig.test.json
new file mode 100644
index 00000000000..3c04af57683
--- /dev/null
+++ b/extensions/chat/tsconfig.test.json
@@ -0,0 +1,15 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@apache-superset/core": ["src/__tests__/sdkMock.ts"]
+ },
+ "typeRoots": [
+ "./node_modules/@types",
+ "../../superset-frontend/node_modules/@types"
+ ],
+ "types": ["jest", "node"]
+ },
+ "include": ["src"]
+}
diff --git a/extensions/chat/webpack.config.js b/extensions/chat/webpack.config.js
new file mode 100644
index 00000000000..13a615d6596
--- /dev/null
+++ b/extensions/chat/webpack.config.js
@@ -0,0 +1,70 @@
+/**
+ * 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.
+ */
+const path = require('path');
+const { ModuleFederationPlugin } = require('webpack').container;
+const packageConfig = require('./package.json');
+const extensionConfig = require('./extension.json');
+
+module.exports = (env, argv) => {
+ const isProd = argv.mode === 'production';
+
+ return {
+ entry: isProd ? {} : './src/index.tsx',
+ mode: isProd ? 'production' : 'development',
+ devtool: isProd ? false : 'eval-cheap-module-source-map',
+ devServer: {
+ port: 3030,
+ headers: { 'Access-Control-Allow-Origin': '*' },
+ },
+ output: {
+ clean: true,
+ filename: isProd ? undefined : '[name].[contenthash].js',
+ chunkFilename: '[name].[contenthash].js',
+ path: path.resolve(__dirname, 'dist'),
+ publicPath: `/api/v1/extensions/${extensionConfig.publisher}/${extensionConfig.name}/`,
+ },
+ resolve: { extensions: ['.ts', '.tsx', '.js', '.jsx'] },
+ externalsType: 'window',
+ externals: { '@apache-superset/core': 'superset' },
+ module: {
+ rules: [
+ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ },
+ ],
+ },
+ plugins: [
+ new ModuleFederationPlugin({
+ name: 'apacheSuperset_referenceChatbot',
+ filename: 'remoteEntry.[contenthash].js',
+ exposes: { './index': './src/index.tsx' },
+ shared: {
+ react: {
+ singleton: true,
+ requiredVersion: packageConfig.peerDependencies.react,
+ import: false,
+ },
+ 'react-dom': {
+ singleton: true,
+ requiredVersion: packageConfig.peerDependencies['react-dom'],
+ import: false,
+ },
+ },
+ }),
+ ],
+ };
+};
(superset) 07/13: refactor(extensions): replace per-location pub/sub with registry-wide version counter + useSyncExternalStore
Posted by en...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch test/chatbot-local
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 504826bb24d9eeb8bce34d5a9db14af44fa58b84
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Wed May 27 22:52:57 2026 +0200
refactor(extensions): replace per-location pub/sub with registry-wide version counter + useSyncExternalStore
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
.../src/components/ChatbotMount/index.tsx | 30 +++++++++---------
superset-frontend/src/core/views/index.ts | 37 +++++++++++-----------
.../src/extensions/ExtensionsList.tsx | 21 ++++++------
3 files changed, 44 insertions(+), 44 deletions(-)
diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx b/superset-frontend/src/components/ChatbotMount/index.tsx
index 87d4894a918..116e6743bfd 100644
--- a/superset-frontend/src/components/ChatbotMount/index.tsx
+++ b/superset-frontend/src/components/ChatbotMount/index.tsx
@@ -16,15 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useState, useEffect, useCallback } from 'react';
+import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { ReactElement } 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 { subscribeToRegistry, getRegistryVersion } from 'src/core/views';
import { subscribeToExtensionSettings } from 'src/core/extensions';
-import { CHATBOT_LOCATION } from 'src/views/contributions';
const CHATBOT_EDGE_MARGIN = 24;
@@ -36,12 +35,9 @@ const ChatbotRenderer = ({ provider }: { provider: () => ReactElement }) =>
const ChatbotMount = () => {
const theme = useTheme();
- const [adminSelectedId, setAdminSelectedId] = useState<string | null>(null);
- const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
// null = settings not yet loaded; don't render anything until settings arrive.
- const [activeChatbot, setActiveChatbot] = useState<ActiveChatbot | null>(
- null,
- );
+ const [adminSelectedId, setAdminSelectedId] = useState<string | null | undefined>(undefined);
+ const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
const fetchSettings = useCallback(() => {
let cancelled = false;
@@ -52,11 +48,10 @@ const ChatbotMount = () => {
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.
- setActiveChatbot(getActiveChatbot(null, {}));
+ setAdminSelectedId(null);
});
return () => {
cancelled = true;
@@ -67,12 +62,17 @@ const ChatbotMount = () => {
useEffect(() => subscribeToExtensionSettings(fetchSettings), [fetchSettings]);
- useEffect(
+ const registryVersion = useSyncExternalStore(
+ subscribeToRegistry,
+ getRegistryVersion,
+ );
+
+ const activeChatbot = useMemo(
() =>
- subscribeToLocation(CHATBOT_LOCATION, () =>
- setActiveChatbot(getActiveChatbot(adminSelectedId, enabledMap)),
- ),
- [adminSelectedId, enabledMap],
+ adminSelectedId === undefined
+ ? null
+ : getActiveChatbot(adminSelectedId, enabledMap),
+ [adminSelectedId, enabledMap, registryVersion],
);
if (!activeChatbot) {
diff --git a/superset-frontend/src/core/views/index.ts b/superset-frontend/src/core/views/index.ts
index 3806bb19979..bc3bea3588f 100644
--- a/superset-frontend/src/core/views/index.ts
+++ b/superset-frontend/src/core/views/index.ts
@@ -39,27 +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();
+/**
+ * Monotonic version of the view registry. Bumped on every registration or
+ * disposal so consumers can re-derive state via React's `useSyncExternalStore`.
+ */
+let registryVersion = 0;
+const registrySubscribers = new Set<() => void>();
-const notifyListeners = (location: string) => {
- locationListeners.get(location)?.forEach(fn => fn());
+const notifyRegistry = () => {
+ registryVersion += 1;
+ registrySubscribers.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);
+export const subscribeToRegistry = (listener: () => void): (() => void) => {
+ registrySubscribers.add(listener);
+ return () => {
+ registrySubscribers.delete(listener);
+ };
};
+export const getRegistryVersion = () => registryVersion;
+
const registerView: typeof viewsApi.registerView = (
view: View,
location: string,
@@ -70,7 +70,6 @@ const registerView: typeof viewsApi.registerView = (
const previousLocation = viewRegistry.get(id)?.location;
if (previousLocation && previousLocation !== location) {
locationIndex.get(previousLocation)?.delete(id);
- notifyListeners(previousLocation);
}
viewRegistry.set(id, { view, location, provider });
@@ -79,13 +78,13 @@ const registerView: typeof viewsApi.registerView = (
ids.add(id);
locationIndex.set(location, ids);
- notifyListeners(location);
+ notifyRegistry();
return new Disposable(() => {
const registeredLocation = viewRegistry.get(id)?.location ?? location;
viewRegistry.delete(id);
locationIndex.get(registeredLocation)?.delete(id);
- notifyListeners(registeredLocation);
+ notifyRegistry();
});
};
diff --git a/superset-frontend/src/extensions/ExtensionsList.tsx b/superset-frontend/src/extensions/ExtensionsList.tsx
index f57cefba970..9e8df4c43f8 100644
--- a/superset-frontend/src/extensions/ExtensionsList.tsx
+++ b/superset-frontend/src/extensions/ExtensionsList.tsx
@@ -24,6 +24,7 @@ import {
useMemo,
useRef,
useState,
+ useSyncExternalStore,
} from 'react';
import { SupersetClient } from '@superset-ui/core';
import { ConfirmStatusChange, Tooltip } from '@superset-ui/core/components';
@@ -35,7 +36,11 @@ 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';
+import {
+ getRegisteredViewIds,
+ subscribeToRegistry,
+ getRegistryVersion,
+} from 'src/core/views';
import { notifyExtensionSettingsChanged } from 'src/core/extensions';
const PAGE_SIZE = 25;
@@ -84,13 +89,9 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
const settingsRef = useRef(settings);
settingsRef.current = settings;
- const [chatbotRegistryVersion, setChatbotRegistryVersion] = useState(0);
- useEffect(
- () =>
- subscribeToLocation(CHATBOT_LOCATION, () =>
- setChatbotRegistryVersion(v => v + 1),
- ),
- [],
+ const registryVersion = useSyncExternalStore(
+ subscribeToRegistry,
+ getRegistryVersion,
);
useEffect(() => {
@@ -139,9 +140,9 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
const chatbotIds = useMemo(
() => new Set(getRegisteredViewIds(CHATBOT_LOCATION)),
- // chatbotRegistryVersion is intentionally in deps to re-evaluate when views register
+ // registryVersion is intentionally in deps to re-evaluate when views register
// eslint-disable-next-line react-hooks/exhaustive-deps
- [chatbotRegistryVersion],
+ [registryVersion],
);
const handleUploadClick = () => {
(superset) 13/13: fix(extensions): sync PR review fixes to test/chatbot-local
Posted by en...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch test/chatbot-local
in repository https://gitbox.apache.org/repos/asf/superset.git
commit df6e0095dcf1f0f016bf0c0b2cc0c2e2e86e759a
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Thu May 28 10:05:43 2026 +0200
fix(extensions): sync PR review fixes to test/chatbot-local
- ExtensionsList.tsx: unique Tooltip ids per row; onKeyDown Enter/Space for
star and delete role=button spans; data-test attrs; remove stale loading dep
- ExtensionsList.test.tsx: 10 unit tests (import validation, delete confirm,
star toggle, keyboard a11y, file upload)
- api.py: path-traversal validation, upload size limit, LOCAL_EXTENSIONS 409
- test_api.py: 19 Python unit tests for POST and DELETE endpoints
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
.../src/extensions/ExtensionsList.test.tsx | 299 +++++++++++++---
.../src/extensions/ExtensionsList.tsx | 25 +-
superset/extensions/api.py | 52 ++-
tests/unit_tests/extensions/test_api.py | 381 +++++++++++++++++++++
4 files changed, 700 insertions(+), 57 deletions(-)
diff --git a/superset-frontend/src/extensions/ExtensionsList.test.tsx b/superset-frontend/src/extensions/ExtensionsList.test.tsx
index ee2be4691d1..c48bfc174f0 100644
--- a/superset-frontend/src/extensions/ExtensionsList.test.tsx
+++ b/superset-frontend/src/extensions/ExtensionsList.test.tsx
@@ -16,73 +16,284 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { render, waitFor } from 'spec/helpers/testing-library';
+import userEvent from '@testing-library/user-event';
+import { fireEvent, render, screen, waitFor, within } from 'spec/helpers/testing-library';
+import { SupersetClient } from '@superset-ui/core';
import ExtensionsList from './ExtensionsList';
-import fetchMock from 'fetch-mock';
-
-beforeAll(() => fetchMock.unmockGlobal());
-
-// Mock initial state for the store
-const mockInitialState = {
- extensions: {
- loading: false,
- resourceCount: 2,
- resourceCollection: [
- {
- id: 1,
- name: 'Test Extension 1',
- enabled: true,
- },
- {
- id: 2,
- name: 'Test Extension 2',
- enabled: false,
- },
- ],
- bulkSelectEnabled: false,
+
+// ---------------------------------------------------------------------------
+// Module-level mocks
+// ---------------------------------------------------------------------------
+
+jest.mock('src/views/CRUD/hooks', () => ({
+ useListViewResource: jest.fn(),
+}));
+
+jest.mock('src/components', () => ({
+ ListView: ({ columns, data }: any) => (
+ <table>
+ <tbody>
+ {(data ?? []).map((row: any) =>
+ columns.map((col: any) => (
+ <td key={`${row.id}-${col.id}`}>
+ {col.Cell ? col.Cell({ row: { original: row } }) : row[col.accessor]}
+ </td>
+ )),
+ )}
+ </tbody>
+ </table>
+ ),
+}));
+
+// Stub SubMenu so tests aren't coupled to the navigation menu rendering chain.
+jest.mock('src/features/home/SubMenu', () => ({
+ __esModule: true,
+ default: ({ buttons }: any) => (
+ <div data-test="submenu">
+ {(buttons ?? []).map((btn: any, i: number) => (
+ // eslint-disable-next-line react/no-array-index-key
+ <button key={i} type="button" onClick={btn.onClick}>
+ {btn.name}
+ </button>
+ ))}
+ </div>
+ ),
+}));
+
+// withToasts is the outermost HOC — pass through so callers can inject toast fns.
+jest.mock('src/components/MessageToasts/withToasts', () => (C: any) => C);
+
+jest.mock('src/views/contributions', () => ({
+ CHATBOT_LOCATION: 'superset.chatbot',
+}));
+
+jest.mock('src/core/views', () => ({
+ getRegisteredViewIds: jest.fn(() => []),
+ subscribeToRegistry: jest.fn(() => () => undefined),
+ getRegistryVersion: jest.fn(() => 0),
+}));
+
+jest.mock('src/core/extensions', () => ({
+ notifyExtensionSettingsChanged: jest.fn(),
+}));
+
+jest.mock('@superset-ui/core', () => {
+ const actual = jest.requireActual('@superset-ui/core');
+ return {
+ ...actual,
+ SupersetClient: {
+ get: jest.fn(),
+ post: jest.fn(),
+ put: jest.fn(),
+ delete: jest.fn(),
+ },
+ };
+});
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const { useListViewResource } = jest.requireMock('src/views/CRUD/hooks');
+const mockGet = SupersetClient.get as jest.Mock;
+const mockPost = SupersetClient.post as jest.Mock;
+const mockPut = SupersetClient.put as jest.Mock;
+const mockDelete = SupersetClient.delete as jest.Mock;
+
+const EXTENSIONS = [
+ {
+ id: 'acme.chatbot',
+ name: 'chatbot',
+ publisher: 'acme',
+ enabled: true,
+ deletable: true,
},
-};
+ {
+ id: 'acme.widget',
+ name: 'widget',
+ publisher: 'acme',
+ enabled: true,
+ deletable: false,
+ },
+];
+
+const mockFetchData = jest.fn();
+const mockRefreshData = jest.fn();
+
+function setupHook(extensions = EXTENSIONS) {
+ useListViewResource.mockReturnValue({
+ state: {
+ loading: false,
+ resourceCount: extensions.length,
+ resourceCollection: extensions,
+ },
+ fetchData: mockFetchData,
+ refreshData: mockRefreshData,
+ });
+}
const defaultProps = {
addDangerToast: jest.fn(),
addSuccessToast: jest.fn(),
};
-const renderWithStore = (props = {}) =>
- render(<ExtensionsList {...defaultProps} {...props} />, {
+function renderList(props = {}) {
+ return render(<ExtensionsList {...defaultProps} {...props} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
- initialState: mockInitialState,
});
+}
+
+function uploadFile(input: HTMLInputElement, file: File) {
+ Object.defineProperty(input, 'files', { value: [file], configurable: true });
+ fireEvent.change(input);
+}
-test('renders extensions list with basic structure', async () => {
- renderWithStore();
+// ---------------------------------------------------------------------------
+// Setup / teardown
+// ---------------------------------------------------------------------------
- // Check that the component renders
- expect(document.body).toBeInTheDocument();
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockGet.mockResolvedValue({
+ json: { result: { active_chatbot_id: null, enabled: {} } },
+ });
+ setupHook();
});
-test('displays extension names in the list', async () => {
- renderWithStore();
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+test('renders the import button in the submenu', () => {
+ renderList();
+ expect(screen.getByTestId('submenu')).toBeInTheDocument();
+});
+test('renders extension names in the table', async () => {
+ renderList();
await waitFor(() => {
- // These texts should appear somewhere in the rendered component
- expect(document.body).toHaveTextContent(/Extensions/);
+ expect(screen.getByText('chatbot')).toBeInTheDocument();
+ expect(screen.getByText('widget')).toBeInTheDocument();
});
});
-test('calls toast functions when provided', () => {
- const addDangerToast = jest.fn();
- const addSuccessToast = jest.fn();
+test('renders delete button only for deletable extensions', async () => {
+ renderList();
+ await waitFor(() => {
+ // Only acme.chatbot has deletable: true
+ expect(screen.getAllByTestId('delete-extension')).toHaveLength(1);
+ });
+});
+
+test('clicking delete opens confirmation dialog', async () => {
+ renderList();
+ await waitFor(() => screen.getByText('chatbot'));
- renderWithStore({
- addDangerToast,
- addSuccessToast,
+ await userEvent.click(screen.getByTestId('delete-extension'));
+
+ await waitFor(() => {
+ expect(screen.getByText(/are you sure you want to delete/i)).toBeInTheDocument();
});
+});
+
+test('typing DELETE in confirmation modal triggers delete API call', async () => {
+ mockDelete.mockResolvedValue({});
+ renderList();
+ await waitFor(() => screen.getByText('chatbot'));
+
+ await userEvent.click(screen.getByTestId('delete-extension'));
+
+ const confirmInput = await screen.findByTestId('delete-modal-input');
+ fireEvent.change(confirmInput, { target: { value: 'DELETE' } });
- // The component should accept these props without error
- expect(addDangerToast).toBeDefined();
- expect(addSuccessToast).toBeDefined();
+ const modal = screen.getByRole('dialog');
+ const confirmBtn = within(modal)
+ .getAllByRole('button', { name: /^delete$/i })
+ .pop()!;
+ await userEvent.click(confirmBtn);
+
+ await waitFor(() => {
+ expect(mockDelete).toHaveBeenCalledWith(
+ expect.objectContaining({ endpoint: '/api/v1/extensions/acme/chatbot' }),
+ );
+ expect(mockRefreshData).toHaveBeenCalled();
+ });
+});
+
+test('star button shown only for extensions registered as chatbot views', async () => {
+ const { getRegisteredViewIds } = jest.requireMock('src/core/views');
+ (getRegisteredViewIds as jest.Mock).mockReturnValue(['acme.chatbot']);
+
+ renderList();
+ await waitFor(() => screen.getByText('chatbot'));
+
+ expect(screen.getAllByTestId('set-default-chatbot')).toHaveLength(1);
+});
+
+test('clicking star calls PUT settings with the extension id', async () => {
+ const { getRegisteredViewIds } = jest.requireMock('src/core/views');
+ (getRegisteredViewIds as jest.Mock).mockReturnValue(['acme.chatbot']);
+ mockPut.mockResolvedValue({ json: {} });
+
+ renderList();
+ await waitFor(() => screen.getByText('chatbot'));
+
+ await userEvent.click(screen.getByTestId('set-default-chatbot'));
+
+ await waitFor(() => {
+ expect(mockPut).toHaveBeenCalledWith(
+ expect.objectContaining({
+ endpoint: '/api/v1/extensions/settings',
+ jsonPayload: expect.objectContaining({
+ active_chatbot_id: 'acme.chatbot',
+ }),
+ }),
+ );
+ });
+});
+
+test('pressing Enter on star span triggers set-default action', async () => {
+ const { getRegisteredViewIds } = jest.requireMock('src/core/views');
+ (getRegisteredViewIds as jest.Mock).mockReturnValue(['acme.chatbot']);
+ mockPut.mockResolvedValue({ json: {} });
+
+ renderList();
+ await waitFor(() => screen.getByText('chatbot'));
+
+ fireEvent.keyDown(screen.getByTestId('set-default-chatbot'), { key: 'Enter' });
+
+ await waitFor(() => {
+ expect(mockPut).toHaveBeenCalled();
+ });
+});
+
+test('uploading a non-.supx file shows danger toast without calling API', async () => {
+ const addDangerToast = jest.fn();
+ renderList({ addDangerToast });
+
+ const input = document.querySelector<HTMLInputElement>('input[type="file"]')!;
+ uploadFile(input, new File(['x'], 'evil.zip', { type: 'application/zip' }));
+
+ expect(addDangerToast).toHaveBeenCalledWith(
+ expect.stringMatching(/\.supx/i),
+ );
+ expect(mockPost).not.toHaveBeenCalled();
+});
+
+test('uploading a .supx file calls POST endpoint and refreshes list', async () => {
+ mockPost.mockResolvedValue({});
+ renderList();
+
+ const input = document.querySelector<HTMLInputElement>('input[type="file"]')!;
+ uploadFile(input, new File(['PK'], 'my.supx', { type: 'application/octet-stream' }));
+
+ await waitFor(() => {
+ expect(mockPost).toHaveBeenCalledWith(
+ expect.objectContaining({ endpoint: '/api/v1/extensions/' }),
+ );
+ expect(mockRefreshData).toHaveBeenCalled();
+ });
});
diff --git a/superset-frontend/src/extensions/ExtensionsList.tsx b/superset-frontend/src/extensions/ExtensionsList.tsx
index 1b8083c2e55..3902fd1ee19 100644
--- a/superset-frontend/src/extensions/ExtensionsList.tsx
+++ b/superset-frontend/src/extensions/ExtensionsList.tsx
@@ -246,7 +246,7 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
</Tooltip>
{isChatbot && (
<Tooltip
- id="set-default-chatbot-tooltip"
+ id={`set-chatbot-tooltip-${id}`}
title={
isDefault
? t('Remove default')
@@ -257,8 +257,14 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
<span
role="button"
tabIndex={0}
+ data-test="set-default-chatbot"
className="action-button"
onClick={() => setDefaultChatbot(id)}
+ onKeyDown={(e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ setDefaultChatbot(id);
+ }
+ }}
>
{isDefault ? (
<Icons.StarFilled iconSize="l" />
@@ -281,15 +287,21 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
>
{(confirmDelete: () => void) => (
<Tooltip
- id="delete-extension-tooltip"
+ id={`delete-extension-tooltip-${id}`}
title={t('Delete')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
+ data-test="delete-extension"
className="action-button"
onClick={confirmDelete}
+ onKeyDown={(e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ confirmDelete();
+ }
+ }}
>
<Icons.DeleteOutlined iconSize="l" />
</span>
@@ -302,14 +314,7 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
},
},
],
- [
- loading,
- settings,
- chatbotIds,
- toggleEnabled,
- setDefaultChatbot,
- handleDelete,
- ],
+ [settings, chatbotIds, toggleEnabled, setDefaultChatbot, handleDelete],
);
const menuData: SubMenuProps = {
diff --git a/superset/extensions/api.py b/superset/extensions/api.py
index 380e2c94165..cf841e86aaf 100644
--- a/superset/extensions/api.py
+++ b/superset/extensions/api.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
import mimetypes
+import re
from io import BytesIO
from pathlib import Path
from typing import Any
@@ -37,6 +38,19 @@ from superset.extensions.utils import (
)
from superset.utils.core import check_is_safe_zip
+# Allowlist for publisher and name path parameters — alphanumeric, hyphens,
+# underscores only. Rejects path-traversal attempts (../), URL-encoded slashes,
+# and any other characters that could escape EXTENSIONS_PATH.
+_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
+
+# Default 10 MB server-side upload limit; can be overridden via config.
+_DEFAULT_MAX_UPLOAD_BYTES = 10 * 1024 * 1024
+
+
+def _validate_segment(value: str) -> bool:
+ """Return True if *value* is a safe publisher or name segment."""
+ return bool(_SEGMENT_RE.match(value))
+
class ExtensionsRestApi(BaseApi):
allow_browser_login = True
@@ -180,7 +194,8 @@ class ExtensionsRestApi(BaseApi):
500:
$ref: '#/components/responses/500'
"""
- # Reconstruct composite ID from publisher and name
+ if not _validate_segment(publisher) or not _validate_segment(name):
+ return self.response(400, message="Invalid publisher or name.")
composite_id = f"{publisher}.{name}"
extensions = get_extensions()
extension = extensions.get(composite_id)
@@ -249,7 +264,19 @@ class ExtensionsRestApi(BaseApi):
if not upload.filename or not upload.filename.endswith(".supx"):
return self.response(400, message="File must have a .supx extension.")
- stream = BytesIO(upload.read())
+ max_bytes: int = current_app.config.get(
+ "EXTENSIONS_MAX_UPLOAD_SIZE", _DEFAULT_MAX_UPLOAD_BYTES
+ )
+ raw = upload.read(max_bytes + 1)
+ if len(raw) > max_bytes:
+ return self.response(
+ 400,
+ message=(
+ f"File exceeds the maximum allowed size of {max_bytes} bytes."
+ ),
+ )
+
+ stream = BytesIO(raw)
if not is_zipfile(stream):
return self.response(400, message="File is not a valid ZIP archive.")
@@ -262,7 +289,22 @@ class ExtensionsRestApi(BaseApi):
except Exception as ex: # pylint: disable=broad-except
return self.response(400, message=f"Invalid extension bundle: {ex}")
+ # Reject bundles whose manifest id collides with a LOCAL_EXTENSIONS entry.
+ local_ids = {
+ Path(p).name for p in current_app.config.get("LOCAL_EXTENSIONS", [])
+ }
+ if extension.manifest.id in local_ids:
+ return self.response(
+ 409,
+ message=(
+ f"Extension '{extension.manifest.id}' is already installed as a "
+ "local extension. Remove it from LOCAL_EXTENSIONS before uploading."
+ ),
+ )
+
# Persist to EXTENSIONS_PATH so the extension survives restarts.
+ # Destination path is derived from the validated manifest id, not the
+ # uploaded filename, so the upload filename cannot escape EXTENSIONS_PATH.
dest_dir = Path(extensions_path)
dest_dir.mkdir(parents=True, exist_ok=True)
dest_file = dest_dir / f"{extension.manifest.id}.supx"
@@ -304,6 +346,9 @@ class ExtensionsRestApi(BaseApi):
if not security_manager.is_admin():
return self.response(403, message="Admin access required.")
+ if not _validate_segment(publisher) or not _validate_segment(name):
+ return self.response(400, message="Invalid publisher or name.")
+
composite_id = f"{publisher}.{name}"
extensions = get_extensions()
extension = extensions.get(composite_id)
@@ -429,7 +474,8 @@ class ExtensionsRestApi(BaseApi):
500:
$ref: '#/components/responses/500'
"""
- # Reconstruct composite ID from publisher and name
+ if not _validate_segment(publisher) or not _validate_segment(name):
+ return self.response(400, message="Invalid publisher or name.")
composite_id = f"{publisher}.{name}"
extensions = get_extensions()
extension = extensions.get(composite_id)
diff --git a/tests/unit_tests/extensions/test_api.py b/tests/unit_tests/extensions/test_api.py
new file mode 100644
index 00000000000..c78f3953e95
--- /dev/null
+++ b/tests/unit_tests/extensions/test_api.py
@@ -0,0 +1,381 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Unit tests for the extensions REST API (POST and DELETE endpoints)."""
+
+from __future__ import annotations
+
+import io
+import zipfile
+from pathlib import Path
+from typing import Any
+from unittest.mock import MagicMock
+
+from superset.extensions.api import _validate_segment
+
+# ---------------------------------------------------------------------------
+# _validate_segment helper
+# ---------------------------------------------------------------------------
+
+
+def test_validate_segment_accepts_alphanumeric() -> None:
+ assert _validate_segment("acme") is True
+ assert _validate_segment("my-ext") is True
+ assert _validate_segment("my_ext") is True
+ assert _validate_segment("Ext123") is True
+
+
+def test_validate_segment_rejects_traversal() -> None:
+ assert _validate_segment("..") is False
+ assert _validate_segment("../etc") is False
+ assert _validate_segment("acme/bad") is False
+ assert _validate_segment("acme%2Fbad") is False
+ assert _validate_segment("") is False
+
+
+def test_validate_segment_rejects_dots() -> None:
+ assert _validate_segment("acme.corp") is False
+
+
+# ---------------------------------------------------------------------------
+# Helpers for building fake .supx payloads
+# ---------------------------------------------------------------------------
+
+
+def _make_supx(manifest_id: str = "acme.chatbot") -> bytes:
+ """Return minimal valid .supx (zip) bytes with a manifest."""
+ buf = io.BytesIO()
+ manifest_json = (
+ f'{{"id": "{manifest_id}", "name": "Chatbot", "version": "1.0.0",'
+ f'"publisher": "acme", "description": "test"}}'
+ )
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr("manifest.json", manifest_json)
+ return buf.getvalue()
+
+
+def _make_fake_extension(manifest_id: str = "acme.chatbot") -> MagicMock:
+ ext = MagicMock()
+ ext.manifest.id = manifest_id
+ ext.source_base_path = "upload://"
+ ext.frontend = {}
+ ext.backend = {}
+ ext.version = "1.0.0"
+ ext.name = "Chatbot"
+ return ext
+
+
+# ---------------------------------------------------------------------------
+# POST /api/v1/extensions/ — upload and install
+# ---------------------------------------------------------------------------
+
+
+class TestPostEndpoint:
+ def _post(self, client: Any, data: dict[str, Any], full_api_access: None) -> Any:
+ return client.post(
+ "/api/v1/extensions/",
+ data=data,
+ content_type="multipart/form-data",
+ )
+
+ def test_non_admin_rejected(
+ self, client: Any, full_api_access: None, mocker: Any
+ ) -> None:
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=False
+ )
+ resp = client.post("/api/v1/extensions/", data={})
+ assert resp.status_code == 403
+
+ def test_missing_extensions_path_returns_400(
+ self, client: Any, full_api_access: None, mocker: Any
+ ) -> None:
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=True
+ )
+ mocker.patch.dict("flask.current_app.config", {"EXTENSIONS_PATH": None})
+ resp = client.post("/api/v1/extensions/", data={})
+ assert resp.status_code == 400
+ assert "EXTENSIONS_PATH" in resp.json["message"]
+
+ def test_missing_bundle_field_returns_400(
+ self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
+ ) -> None:
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=True
+ )
+ mocker.patch.dict(
+ "flask.current_app.config", {"EXTENSIONS_PATH": str(tmp_path)}
+ )
+ resp = client.post(
+ "/api/v1/extensions/",
+ data={},
+ content_type="multipart/form-data",
+ )
+ assert resp.status_code == 400
+ assert "bundle" in resp.json["message"]
+
+ def test_wrong_extension_rejected(
+ self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
+ ) -> None:
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=True
+ )
+ mocker.patch.dict(
+ "flask.current_app.config", {"EXTENSIONS_PATH": str(tmp_path)}
+ )
+ resp = client.post(
+ "/api/v1/extensions/",
+ data={"bundle": (io.BytesIO(b"data"), "evil.zip")},
+ content_type="multipart/form-data",
+ )
+ assert resp.status_code == 400
+ assert ".supx" in resp.json["message"]
+
+ def test_oversize_upload_rejected(
+ self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
+ ) -> None:
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=True
+ )
+ mocker.patch.dict(
+ "flask.current_app.config",
+ {"EXTENSIONS_PATH": str(tmp_path), "EXTENSIONS_MAX_UPLOAD_SIZE": 10},
+ )
+ big = io.BytesIO(b"x" * 20)
+ resp = client.post(
+ "/api/v1/extensions/",
+ data={"bundle": (big, "big.supx")},
+ content_type="multipart/form-data",
+ )
+ assert resp.status_code == 400
+ assert "maximum" in resp.json["message"]
+
+ def test_not_a_zip_returns_400(
+ self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
+ ) -> None:
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=True
+ )
+ mocker.patch.dict(
+ "flask.current_app.config", {"EXTENSIONS_PATH": str(tmp_path)}
+ )
+ resp = client.post(
+ "/api/v1/extensions/",
+ data={"bundle": (io.BytesIO(b"not a zip"), "ext.supx")},
+ content_type="multipart/form-data",
+ )
+ assert resp.status_code == 400
+ assert "ZIP" in resp.json["message"]
+
+ def test_zip_slip_rejected(
+ self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
+ ) -> None:
+ """check_is_safe_zip raises on path-traversal entries inside the zip."""
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=True
+ )
+ mocker.patch.dict(
+ "flask.current_app.config", {"EXTENSIONS_PATH": str(tmp_path)}
+ )
+ mocker.patch(
+ "superset.extensions.api.check_is_safe_zip",
+ side_effect=Exception("zip-slip detected"),
+ )
+ supx = _make_supx()
+ resp = client.post(
+ "/api/v1/extensions/",
+ data={"bundle": (io.BytesIO(supx), "ext.supx")},
+ content_type="multipart/form-data",
+ )
+ assert resp.status_code == 400
+ assert "zip-slip" in resp.json["message"]
+
+ def test_local_extensions_collision_returns_409(
+ self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
+ ) -> None:
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=True
+ )
+ mocker.patch.dict(
+ "flask.current_app.config",
+ {
+ "EXTENSIONS_PATH": str(tmp_path),
+ "LOCAL_EXTENSIONS": ["/opt/superset/ext/acme.chatbot"],
+ },
+ )
+ fake_ext = _make_fake_extension("acme.chatbot")
+ mocker.patch(
+ "superset.extensions.api.get_bundle_files_from_zip", return_value=[]
+ )
+ mocker.patch(
+ "superset.extensions.api.get_loaded_extension", return_value=fake_ext
+ )
+ supx = _make_supx("acme.chatbot")
+ resp = client.post(
+ "/api/v1/extensions/",
+ data={"bundle": (io.BytesIO(supx), "ext.supx")},
+ content_type="multipart/form-data",
+ )
+ assert resp.status_code == 409
+ assert "local extension" in resp.json["message"]
+
+ def test_happy_path_returns_201(
+ self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
+ ) -> None:
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=True
+ )
+ mocker.patch.dict(
+ "flask.current_app.config",
+ {"EXTENSIONS_PATH": str(tmp_path), "LOCAL_EXTENSIONS": []},
+ )
+ fake_ext = _make_fake_extension("acme.chatbot")
+ mocker.patch(
+ "superset.extensions.api.get_bundle_files_from_zip", return_value=[]
+ )
+ mocker.patch(
+ "superset.extensions.api.get_loaded_extension", return_value=fake_ext
+ )
+ mocker.patch(
+ "superset.extensions.api.build_extension_data",
+ return_value={"id": "acme.chatbot"},
+ )
+ supx = _make_supx("acme.chatbot")
+ resp = client.post(
+ "/api/v1/extensions/",
+ data={"bundle": (io.BytesIO(supx), "ext.supx")},
+ content_type="multipart/form-data",
+ )
+ assert resp.status_code == 201
+ assert resp.json["result"]["id"] == "acme.chatbot"
+ assert (tmp_path / "acme.chatbot.supx").exists()
+
+
+# ---------------------------------------------------------------------------
+# DELETE /api/v1/extensions/<publisher>/<name>
+# ---------------------------------------------------------------------------
+
+
+class TestDeleteEndpoint:
+ def test_non_admin_rejected(
+ self, client: Any, full_api_access: None, mocker: Any
+ ) -> None:
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=False
+ )
+ resp = client.delete("/api/v1/extensions/acme/chatbot")
+ assert resp.status_code == 403
+
+ def test_path_traversal_publisher_rejected(
+ self, client: Any, full_api_access: None, mocker: Any
+ ) -> None:
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=True
+ )
+ # Use percent-encoded dots so Flask routing passes the segment to the
+ # handler as the string ".." — literal slashes in the path would be
+ # intercepted by the router before reaching the view.
+ resp = client.delete("/api/v1/extensions/%2E%2E/passwd")
+ assert resp.status_code == 400
+ assert "Invalid" in resp.json["message"]
+
+ def test_invalid_name_returns_400(
+ self, client: Any, full_api_access: None, mocker: Any
+ ) -> None:
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=True
+ )
+ resp = client.delete("/api/v1/extensions/acme/bad.name")
+ assert resp.status_code == 400
+ assert "Invalid" in resp.json["message"]
+
+ def test_unknown_extension_returns_404(
+ self, client: Any, full_api_access: None, mocker: Any
+ ) -> None:
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=True
+ )
+ mocker.patch("superset.extensions.api.get_extensions", return_value={})
+ resp = client.delete("/api/v1/extensions/acme/chatbot")
+ assert resp.status_code == 404
+
+ def test_local_extension_cannot_be_deleted(
+ self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
+ ) -> None:
+ local_base = str(tmp_path / "local-ext" / "dist")
+ fake_ext = _make_fake_extension("acme.chatbot")
+ fake_ext.source_base_path = local_base
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=True
+ )
+ mocker.patch(
+ "superset.extensions.api.get_extensions",
+ return_value={"acme.chatbot": fake_ext},
+ )
+ mocker.patch.dict(
+ "flask.current_app.config",
+ {"LOCAL_EXTENSIONS": [str(tmp_path / "local-ext")]},
+ )
+ resp = client.delete("/api/v1/extensions/acme/chatbot")
+ assert resp.status_code == 400
+ assert "LOCAL_EXTENSIONS" in resp.json["message"]
+
+ def test_happy_path_deletes_file(
+ self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
+ ) -> None:
+ supx_file = tmp_path / "acme.chatbot.supx"
+ supx_file.write_bytes(b"fake")
+
+ fake_ext = _make_fake_extension("acme.chatbot")
+ fake_ext.source_base_path = "upload://"
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=True
+ )
+ mocker.patch(
+ "superset.extensions.api.get_extensions",
+ return_value={"acme.chatbot": fake_ext},
+ )
+ mocker.patch.dict(
+ "flask.current_app.config",
+ {
+ "LOCAL_EXTENSIONS": [],
+ "EXTENSIONS_PATH": str(tmp_path),
+ },
+ )
+ resp = client.delete("/api/v1/extensions/acme/chatbot")
+ assert resp.status_code == 200
+ assert not supx_file.exists()
+
+ def test_supx_file_missing_returns_404(
+ self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path
+ ) -> None:
+ fake_ext = _make_fake_extension("acme.chatbot")
+ fake_ext.source_base_path = "upload://"
+ mocker.patch(
+ "superset.extensions.api.security_manager.is_admin", return_value=True
+ )
+ mocker.patch(
+ "superset.extensions.api.get_extensions",
+ return_value={"acme.chatbot": fake_ext},
+ )
+ mocker.patch.dict(
+ "flask.current_app.config",
+ {"LOCAL_EXTENSIONS": [], "EXTENSIONS_PATH": str(tmp_path)},
+ )
+ resp = client.delete("/api/v1/extensions/acme/chatbot")
+ assert resp.status_code == 404
(superset) 05/13: fix(extensions): port CodeAnt fixes — page-type guards, deactivation cleanup, settings race, feature flag gate
Posted by en...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch test/chatbot-local
in repository https://gitbox.apache.org/repos/asf/superset.git
commit a74684b06250087261eb65e1dbdc7fb13969133f
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Wed May 27 12:10:16 2026 +0200
fix(extensions): port CodeAnt fixes — page-type guards, deactivation cleanup, settings race, feature flag gate
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
superset-frontend/src/core/dashboard/index.ts | 2 ++
superset-frontend/src/core/explore/index.ts | 2 ++
.../src/extensions/ExtensionsList.tsx | 19 ++++++++-----
.../src/extensions/ExtensionsLoader.ts | 31 +++++++++++++++++-----
superset-frontend/src/views/App.tsx | 3 ++-
5 files changed, 43 insertions(+), 14 deletions(-)
diff --git a/superset-frontend/src/core/dashboard/index.ts b/superset-frontend/src/core/dashboard/index.ts
index fbf17f3fa62..611a4514f0b 100644
--- a/superset-frontend/src/core/dashboard/index.ts
+++ b/superset-frontend/src/core/dashboard/index.ts
@@ -34,11 +34,13 @@ import {
import { store, RootState } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
import { createActionListener } from '../utils';
+import { navigation } from '../navigation';
type DashboardContext = dashboardApi.DashboardContext;
type FilterValue = dashboardApi.FilterValue;
function buildDashboardContext(): DashboardContext | undefined {
+ if (navigation.getPageType() !== 'dashboard') return undefined;
const state = store.getState();
const info = (state as any).dashboardInfo;
if (!info?.id) return undefined;
diff --git a/superset-frontend/src/core/explore/index.ts b/superset-frontend/src/core/explore/index.ts
index 46b6b2ef84e..732ae975caa 100644
--- a/superset-frontend/src/core/explore/index.ts
+++ b/superset-frontend/src/core/explore/index.ts
@@ -34,10 +34,12 @@ import { SET_DATASOURCE } from 'src/explore/actions/datasourcesActions';
import { store, RootState } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
import { createActionListener } from '../utils';
+import { navigation } from '../navigation';
type ChartContext = exploreApi.ChartContext;
function buildChartContext(): ChartContext | undefined {
+ if (navigation.getPageType() !== 'explore') return undefined;
const state = store.getState();
const exploreState = (state as any).explore;
if (!exploreState) return undefined;
diff --git a/superset-frontend/src/extensions/ExtensionsList.tsx b/superset-frontend/src/extensions/ExtensionsList.tsx
index 5daceb60dc2..f57cefba970 100644
--- a/superset-frontend/src/extensions/ExtensionsList.tsx
+++ b/superset-frontend/src/extensions/ExtensionsList.tsx
@@ -79,6 +79,10 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
active_chatbot_id: null,
enabled: {},
});
+ // Always holds the latest settings value so saveSettings never closes over
+ // a stale snapshot when rapid toggles race against each other.
+ const settingsRef = useRef(settings);
+ settingsRef.current = settings;
const [chatbotRegistryVersion, setChatbotRegistryVersion] = useState(0);
useEffect(
@@ -97,7 +101,8 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
const saveSettings = useCallback(
(patch: Partial<ExtensionSettings>) => {
- const next = { ...settings, ...patch };
+ const next = { ...settingsRef.current, ...patch };
+ setSettings(next);
SupersetClient.put({
endpoint: '/api/v1/extensions/settings',
jsonPayload: next,
@@ -109,25 +114,27 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
})
.catch(() => addDangerToast(t('Failed to save extension settings.')));
},
- [settings, addDangerToast, addSuccessToast],
+ [addDangerToast, addSuccessToast],
);
const toggleEnabled = useCallback(
(extensionId: string, enabled: boolean) => {
saveSettings({
- enabled: { ...settings.enabled, [extensionId]: enabled },
+ enabled: { ...settingsRef.current.enabled, [extensionId]: enabled },
});
},
- [settings, saveSettings],
+ [saveSettings],
);
const setDefaultChatbot = useCallback(
(extensionId: string) => {
const next =
- settings.active_chatbot_id === extensionId ? null : extensionId;
+ settingsRef.current.active_chatbot_id === extensionId
+ ? null
+ : extensionId;
saveSettings({ active_chatbot_id: next });
},
- [settings, saveSettings],
+ [saveSettings],
);
const chatbotIds = useMemo(
diff --git a/superset-frontend/src/extensions/ExtensionsLoader.ts b/superset-frontend/src/extensions/ExtensionsLoader.ts
index cd99a27dce2..f96091e9d2b 100644
--- a/superset-frontend/src/extensions/ExtensionsLoader.ts
+++ b/superset-frontend/src/extensions/ExtensionsLoader.ts
@@ -177,17 +177,34 @@ class ExtensionsLoader {
// collect the Disposables and drive cleanup on deactivation.
const collected: (() => void)[] = [];
const originalSuperset = window.superset;
+
+ const wrap =
+ <TArgs extends unknown[]>(
+ fn: (...args: TArgs) => { dispose(): void },
+ ): ((...args: TArgs) => { dispose(): void }) =>
+ (...args: TArgs) => {
+ const disposable = fn(...args);
+ collected.push(() => disposable.dispose());
+ return disposable;
+ };
+
window.superset = {
...originalSuperset,
+ commands: {
+ ...originalSuperset.commands,
+ registerCommand: wrap(originalSuperset.commands.registerCommand),
+ },
+ menus: {
+ ...originalSuperset.menus,
+ registerMenuItem: wrap(originalSuperset.menus.registerMenuItem),
+ },
+ editors: {
+ ...originalSuperset.editors,
+ registerEditor: wrap(originalSuperset.editors.registerEditor),
+ },
views: {
...originalSuperset.views,
- registerView: (
- ...args: Parameters<typeof originalSuperset.views.registerView>
- ) => {
- const disposable = originalSuperset.views.registerView(...args);
- collected.push(() => disposable.dispose());
- return disposable;
- },
+ registerView: wrap(originalSuperset.views.registerView),
},
};
diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx
index d84b111ca84..d70aa0f86f9 100644
--- a/superset-frontend/src/views/App.tsx
+++ b/superset-frontend/src/views/App.tsx
@@ -38,6 +38,7 @@ import { Logger, LOG_ACTIONS_SPA_NAVIGATION } from 'src/logger/LogUtils';
import setupCodeOverrides from 'src/setup/setupCodeOverrides';
import { logEvent } from 'src/logger/actions';
import { store } from 'src/views/store';
+import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import ExtensionsStartup from 'src/extensions/ExtensionsStartup';
import ChatbotMount from 'src/components/ChatbotMount';
import { RootContextProviders } from './RootContextProviders';
@@ -119,7 +120,7 @@ const App = () => (
loaded and registered, but outside the Switch so the bubble persists
across route changes (SIP §3.2).
*/}
- <ChatbotMount />
+ {isFeatureEnabled(FeatureFlag.EnableExtensions) && <ChatbotMount />}
</ExtensionsStartup>
<ToastContainer />
</RootContextProviders>
(superset) 06/13: fix(extensions): match /sqllab path without trailing slash in derivePageType
Posted by en...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch test/chatbot-local
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 4e8145f14be0b4723ea9c7a3a5ce059b3e417766
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Wed May 27 16:26:45 2026 +0200
fix(extensions): match /sqllab path without trailing slash in derivePageType
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
CHATBOT_SIP.md | 585 +++++++++++++++++++++++++
superset-frontend/src/core/navigation/index.ts | 2 +-
2 files changed, 586 insertions(+), 1 deletion(-)
diff --git a/CHATBOT_SIP.md b/CHATBOT_SIP.md
new file mode 100644
index 00000000000..2da7db22861
--- /dev/null
+++ b/CHATBOT_SIP.md
@@ -0,0 +1,585 @@
+Chatbot extensions
+Author: Enzo Martellucci
+Team: Preset
+Status: Draft | Under Review | Completed
+Day: May, 2026
+
+1. Introduction
+ This document defines the extension point that allows an external chatbot to be embedded into the Superset UI.
+ 1.1 Motivation
+ The goal is to let any community-built chatbot plug into Superset through the standard extension system using the same well-defined APIs already established for SQL Lab and the dashboard, without the chatbot reaching into Superset's internal Redux store or source modules.
+ 1.2 Items Not Included
+ These are explicitly out of scope for this SIP and are either deferred to
+ another SIP or left to the extension implementation:
+
+- Frontend tool execution / agentic UI manipulation (the agent changing a
+ chart's config, a dashboard's layout, or the SQL editor's contents live on
+ screen) is out of scope and deferred to the _[SIP] Proposal for
+ integrating Superset client actions for AI agent applications_ (Justin Park).
+ This SIP defines how the chatbot is _mounted and given context_; that SIP
+ defines how the chatbot \*acts on the UI”.
+- The chatbot UI itself is out of scope. This SIP defines the host
+ contract; the look, feel, conversation model, streaming, and persistence are
+ entirely the extension's responsibility.
+- The MCP/LLM backend (server, model communication, tool selection) is out
+ of scope.
+
+2. Functional requirements
+ Registration & rendering
+ Extensions must be able to register a chatbot provider
+ Only one chatbot should be visible/active at a time (Singleton)
+ The chatbot should be visible across all supported application surfaces
+ Extensions must be able to render a fully custom chatbot UI
+ Content sharing APIs/hooks
+ The chatbot must receive contextual information about the current page:
+ Page type: `home`, `dashboard`, `chart`, `sqllab`, `dataset`
+ The current dashboard/chart data, the saved chart, the dashboard filters and
+ the dashboard-ui-control state
+ The chatbot must be notified when the page context changes (navigation entity change, title change) without polling.
+ The page context the chatbot receives must be host-derived and aligned with the current user's backend-authorized application view: the host computes it and exposes only what the current user is already authorized to see. The chatbot must not read the host's Redux store or other internal state to assemble context (the chatbot must not depend on internal frontend structure to assemble the context).
+ The chatbot must support conversation state/history (owned by the extension, never the host) 3) Administration
+ An admin can enable/disable the chatbot and when more than one chatbot extension is installed, choose which one is active
+
+2.1 Non-functional requirements
+Context sharing must respect Superset permission and security boundaries. The host must not expose entities, metadata, or datasource-derived context the current user is not authorized to access.
+Namespace APIs must remain decoupled from frontend implementation details and internal state-management architecture.
+The architecture must minimize impact on existing frontend performance. In particular, exposing page context must not force unnecessary host view re-renders.
+Context change notifications must not require polling.
+Third-party chatbot failures must not break the main application (fault isolation at the mount boundary).
+The integration system must remain extensible for future AI capabilities and additional application surfaces.
+The architecture should avoid hard dependency on any specific LLM vendor.
+The system must support incremental adoption and migration from existing integrations. 3. Proposed Extension Point
+3.1 Summary
+One extension point is proposed.
+Extension Point
+Contribution Area ID
+Registration API
+Cardinality
+Chatbot bubble
+`superset.chatbot`
+`views.registerView()`
+Exclusive (singleton)
+
+3.2 The Chatbot Bubble
+Contribution area: `superset.chatbot`
+The host renders a fixed mount point at the bottom-right corner of the Superset application shell. A chatbot extension registers a single React component into this slot, and the host renders it persistently across all routes (dashboard, SQL Lab, explore, datasets, etc.).
+New contribution scope — manifest schema change required the static contribution schema in `@apache-superset/core` is currently SQL-Lab-only: `ViewContributions` only accepts a `sqllab` scope keyed by `SqlLabLocation`.
+There is no app-shell-level scope, so `superset.chatbot` cannot be declared in `extension.json` today.
+
+This SIP therefore requires extending the manifest contribution schema to add an app-root scope (e.g. `app` / `appShell`) that can carry the `superset.chatbot` location.
+This is a schema change, not just a runtime `registerView()` call: both the
+static manifest declaration and the runtime registration must understand the new
+scope.
+The extension owns the bubble's collapsed appearance (the icon), its expanded appearance (the conversation panel), all animations, all open/close state, and all internal chatbot behavior.
+
+Singleton / exclusive behavior
+Unlike most contribution areas (which accept multiple contributions and render them as a list or stack), `superset.chatbot` is exclusive; only one chatbot extension can be active at a time.
+Two floating bubbles in the same corner would be confusing, and the conversation model is fundamentally singular.
+If no chatbot extension is installed, the corner stays empty and no bubble appears.
+If exactly one chatbot extension is installed, its component is mounted automatically.
+If multiple chatbot extensions are installed, the host resolves the conflict via a configurable policy (see Section 4, open questions).
+Note that the existing `registerView` / `getViews(location)` registry already supports "multiple contributions registered at a location, host chooses what to render".
+
+Singleton behavior is therefore a host-side selection policy at the `superset.chatbot` location, not a new registration primitive: the host enumerates the candidates registered at `superset.chatbot` and picks one according to the admin setting (see §2, Administration).
+
+`getViews` does not expose providers — by design there is a concrete limitation in the public API as it exists today. `getViews(location)` returns `View[] | undefined` — i.e. only the view descriptors (`id`, `name`, `description`).
+It does not return the `provider` functions that `registerView` was given.
+
+This is important to state precisely, because it is not an oversight to "fix" by widening `getViews`:
+
+- A `provider` is a function that constructs and returns a React element — calling it _renders_ the view. The view descriptor (`id`/`name`/`description`) is inert metadata; the provider is executable rendering logic.
+- `getViews` is a **public, extension-facing** API. If it returned providers, any extension could obtain and invoke another extension's provider directly — rendering a view outside the host's mount point, lifecycle, and fault-isolation boundary.
+ Keeping `getViews` descriptor-only is a deliberate boundary: the public surface lets extensions _
+ discover what is registered, but only the **host** can \_render_ what is registered (via the registration/mount path).
+- Consequence for the singleton picker: with the public API alone, the host can enumerate the registered chatbots but cannot obtain the provider needed to render the selected one. The picker described here therefore cannot be built on `getViews` as-is — and `getViews` should **not** be changed to expose providers, because that would dissolve the boundary above.
+
+Proposed solution: this SIP requires a **host-internal** resolution mechanism for exclusive locations — one that the host can use to obtain a provider, without that capability becoming part of the public extension-facing contract.
+
+const candidates = getViews('superset.chatbot');
+
+const selectedId =
+adminSettings.activeChatbotId ??
+candidates?.[0]?.id;
+
+const provider = getViewProvider(
+'superset.chatbot',
+selectedId,
+);
+
+return provider?.();
+
+`getViews()` remains a descriptor-only public API.
+The host uses an internal provider-resolution mechanism to obtain the renderable provider for the selected chatbot.
+
+Two acceptable forms:
+
+- (preferred) Add an internal host-only registry accessor that returns the
+ `provider` for a registered view id at a given location
+ `getViewProvider(location, id)`. `getViews` stays unchanged as the public, descriptor-only enumeration API; the provider accessor is host-internal and not part of the extension-facing contract.
+ The host renders the chatbot via `getViewProvider('superset.chatbot', selectedId)`.
+
+- (alternative) Treat `superset.chatbot` as a first-class exclusive contribution area in core with a dedicated `getActiveChatbot()` resolver that applies the
+ admin policy internally and returns a single renderable provider.
+
+The preferred form is the smaller change and keeps the public `getViews` semantics intact — `getViews` stays descriptor-only, and the provider-resolution capability lives entirely host-internal, preserving the boundary described above.
+Either way, this is a required core change and must be tracked as such in the Migration Plan (§6 P1); the SIP cannot rely on `getViews` alone.
+
+Positioning and lifecycle (host responsibilities)
+The host provides:
+Provide the APIs for the interactions
+Provide the context
+Mounting at the app root level, so the bubble persists across route changes.
+Eager loading of the chatbot bundle as part of the app shell startup, so the bubble is available immediately rather than appearing late.
+Disposable cleanup when the extension is deactivated, uninstalled, or replaced.
+Fixed positioning at the bottom-right corner of the viewport (24px margin from the edges).
+A managed z-index above dashboard content and the standard toast layer, below modal dialogs.
+Component contract (extension responsibilities)
+The registered component is the entire chatbot UI. It is responsible for:
+The collapsed bubble state (icon, optional notification badge, hover/focus states).
+The expanded panel state (header, conversation, composer, side panels).
+All open/close transitions, keyboard shortcuts (e.g., Cmd+K to open), and accessibility (focus trap, ARIA labels, screen reader support).
+All conversation state, message history, streaming, and persistence.
+All LLM communication and tool execution.
+Responsive behavior on narrow viewports (typically transitioning to a full-screen overlay below ~768px).
+The host does not inject styling or behavior into the registered component beyond what the standard namespaces provide.
+Registration example
+
+import { views } from '@apache-superset/core';
+import { ChatbotApp } from './ChatbotApp';
+
+export function activate(context: ExtensionContext) {
+const disposable = views.registerView(
+{
+id: 'superset.copilot',
+name: 'Superset Copilot',
+icon: 'Bubble',
+},
+'superset.chatbot',
+() => <ChatbotApp />,
+);
+
+context.subscriptions.push(disposable);
+}
+
+The extension registers a single renderable provider into the
+`superset.chatbot` contribution area.
+
+The host resolves exactly one active chatbot provider and mounts it
+at the application-shell level.
+
+The chatbot needs an icon:
+It identifies the chatbot in the admin "Default chatbot" picker (see §4) and in any contribution manifest listing, and it is the natural identity for the collapsed bubble.
+The current `View` interface in `@apache-superset/core` is `{ id, name, description? }` and has no `icon` field, so
+this SIP **proposes adding an `icon` field to the `View` descriptor**.
+The registration example above must be updated to pass one once the form below is decided.
+
+The remaining open concern is who owns the icon and whether it is mutable:
+
+- (a) Static descriptor icon. `icon` is a fixed field on the `View` descriptor, set once at `registerView()` time and never changed.
+ The host uses it for the admin picker / manifest listing.
+ The collapsed bubble shown in the corner is still rendered by the extension component itself (see Component contract), so a static descriptor icon and a richer extension-rendered bubble can coexist:
+ The descriptor icon identifies metadata, the bubble is a live UI.
+- (b) Runtime-updatable icon.
+ The chatbot owner can change its icon after registration — e.g. to reflect a notification/unread badge, a loading or "thinking" state, or dynamic rebranding.
+ This requires more than a descriptor field: either a mutable handle returned from `registerView()` (e.g.
+ `handle.setIcon(...)`) or a small chatbot-scoped API to update it.
+ It also raises the question of where that icon is consumed — the admin picker would show a point-in-time value, while the bubble already updates freely because the extension renders it.
+
+Recommendation: (a) for the descriptor `icon` — keep the registry field static and simple, since its only consumers are identity surfaces (picker, manifest) that do not need live updates.
+Dynamic icon states (notification badge, thinking
+indicator) belong to the collapsed bubble, which the extension component already owns and re-renders on its own; they do not need to flow through the host
+descriptor.
+If a concrete need emerges for the host to reflect a live chatbot icon outside the bubble, option (b) can be revisited in a follow-up rather than expanding `registerView` now.
+
+State APIs the chatbot uses
+The chatbot does not receive a dedicated host-managed state namespace.
+It reads host state through the same public namespaces available to any other extension:
+The table below distinguishes namespaces that **exist today** in `@apache-superset/core` from those this SIP **must add** as new work.
+
+Namespace
+Status
+Purpose for the chatbot
+sqlLab
+Exists today
+SQL Lab-specific APIs and state (`getCurrentTab()` and `onDidChange*` events)
+authentication
+Exists today
+Current user Auth/session CSRF token for same-session requests the chatbot uses the user's existing browser session; no separate token is issued or sent
+commands
+Exists today
+Host actions utilities
+dashboard
+NEW (added by this SIP)
+Dashboard-specific APIs and state (current dashboard, filters, dashboard-ui-control state)
+explore
+NEW (added by this SIP)
+Chart/explore-specific APIs and state (saved chart, current chart data)
+
+dataset
+NEW (added by this SIP)
+Dataset-specific APIs and state (current dataset); provides context for the `dataset` page type
+
+navigation
+NEW (added by this SIP)
+Route/page surface, including the `onDidChangePage` change event
+
+Namespaces this SIP must add — required core work, only `sqlLab`, `authentication`, `commands`, `menus` and `editors` currently exist in `@apache-superset/core` (`superset-frontend/packages/superset-core/src/`).
+
+There is no `dashboard`, `explore`, `dataset`, or `navigation` namespace today, and no
+`navigation.onDidChangePage` event, these do not exist yet — this SIP is what introduces them.
+Each new namespace must follow the established `sqlLab` shape: a
+state getter plus an `Event<T>` change subscription. Concretely, this SIP requires
+adding:
+
+- `dashboard` namespace — `dashboard.getCurrentDashboard()` plus change events for
+ the dashboard entity, its filters, and its UI-control state.
+- `explore` namespace — `explore.getCurrentChart()` (saved chart + current chart
+ data) plus a change event.
+- `navigation` namespace — a current-page getter (`pageType` + focused entity) and
+ the `navigation.onDidChangePage` event used for the without-polling notification
+ in §2.
+
+Page context
+Per-surface namespaces
+The chatbot does not interact directly with the host application's internal state management implementation.
+Instead, each application surface exposes a dedicated namespace that provides a stable, host-managed API for that surface.
+These namespaces expose curated API types rather than internal implementation details such as Redux slices or component-local state.
+The purpose of the namespace layer is to:
+provide a stable extension contract
+normalize surface-specific context
+avoid coupling extensions to host implementation details
+preserve compatibility across future frontend architecture changes
+For example, the host may evolve from Redux-based state management to another implementation in the future without requiring chatbot extensions to change, as long as the namespace contract remains stable.
+Each namespace exposes normalized semantic context aligned with the current user's backend-authorized application view
+
+Permission enforcement itself remains a backend concern — the namespace surfaces data that backend APIs have already scoped to the current user; it is not a security boundary of its own.
+
+Namespace composition model
+
+This SIP intentionally does not introduce a single aggregate context namespace.
+Instead:
+each surface exposes its own namespace
+chatbot extensions compose those already-safe pieces into a higher-level PageContext
+
+For example:
+
+const pageContext = {
+pageType: navigation.getPageType(),
+
+dashboard: dashboard.getCurrentDashboard(),
+
+sqlLab: sqlLab.getCurrentTab(),
+
+chart: explore.getCurrentChart(),
+
+dataset: dataset.getCurrentDataset(),
+};
+
+The extension-side adapter assembles normalized semantic context fragments.
+It does not derive or permission-filter entities itself.
+
+Namespace API shape
+Each namespace follows the established sqlLab API shape:
+synchronous state getter(s)
+Event<T>-style change subscriptions
+
+This allows chatbot extensions to react to page-context changes without polling.
+
+Example:
+
+const dashboardContext = dashboard.getCurrentDashboard();
+
+const disposable = dashboard.onDidChangeDashboard(nextDashboard => {
+chatbot.updateContext(nextDashboard);
+});
+
+The new namespaces follow the sqlLab namespace shape, but not necessarily its implementation model.
+
+Existing sqlLab namespace behavior
+The existing sqlLab namespace (superset-frontend/src/core/sqlLab/index.ts) already follows this general API shape today.
+
+Internally, the current implementation is a lightweight wrapper around Redux state (store.getState()), but that implementation detail is not part of the extension contract and should not be relied upon by extensions.
+
+This SIP extends the namespace-based model to additional application surfaces (dashboard, explore, dataset, navigation), standardizing semantic context normalization and a stable extension contract.
+
+Where permission enforcement happens
+Permission enforcement is a backend concern, and this is the normal Superset data flow: every frontend surface receives its state from backend APIs that already scope data to the current user's permissions. Frontend application state reflects backend-authorized API responses.
+
+`sqlLab` follows the standard Superset authorization model: backend APIs scope SQL Lab data to the current user's permissions before that data enters frontend application state (for example through `DatabaseFilter` applied in `DatabaseDAO.find_all()`).
+
+The `sqlLab` namespace therefore exposes a stable extension-facing API over already-authorized application state rather than enforcing authorization itself.
+
+The new namespaces follow the same principle: a namespace getter surfaces data from backend APIs that scope it to the current user — it is not itself a security boundary.
+
+Namespace normalization requirements
+
+Each namespace getter must:
+normalize application state into stable semantic extension-facing APIs
+expose context aligned with backend authorization semantics
+avoid exposing raw Redux structures or internal frontend implementation details
+provide a stable abstraction layer independent of frontend state-management implementation details
+
+For example, a native implementation such as:
+// ❌ Avoid exposing internal host state directly
+
+dashboard.getCurrentDashboard() {
+return store.getState().dashboard;
+}
+
+would incorrectly expose internal dashboard state directly to extensions and would tightly couple extensions to the host implementation.
+Instead, namespaces must expose normalized semantic context objects.
+
+// ✅ Expose a stable semantic contract
+dashboard.getCurrentDashboard() {
+return {
+id: dashboard.id,
+title: dashboard.title,
+filters: dashboard.filters,
+charts: dashboard.visibleCharts,
+uiState: dashboard.uiState,
+};
+}
+
+Per-surface context contracts
+Each namespace defines the semantic context contract exposed to extensions for that application surface.
+Namespaces:
+normalize frontend/application state into stable extension-facing APIs
+expose only context relevant to that surface
+avoid exposing unrelated implementation details or internal Redux structures
+preserve the permission semantics already enforced by the backend APIs serving that surface
+The namespace layer is therefore:
+an abstraction boundary
+a semantic normalization layer
+a stable extension contract
+It is not the primary authorization boundary; authorization remains enforced by backend APIs and Superset access-control mechanisms.
+dashboard
+dashboard.getCurrentDashboard() exposes:
+dashboard identity
+visible dashboard charts
+dashboard filter state
+dashboard UI-control state
+semantic dashboard context relevant to chatbot integrations
+The namespace should avoid exposing:
+unrelated internal dashboard rendering structures
+Redux-specific implementation details
+internal entity graphs not intended as extension contracts
+The dashboard context must remain aligned with the backend permission semantics of the dashboard APIs serving that surface.
+explore
+explore.getCurrentChart() exposes:
+current chart identity
+saved chart metadata
+transient chart-editing context
+semantic chart state relevant to the active Explore session
+The namespace should avoid exposing:
+unrelated chart entities
+unrelated datasource metadata
+frontend implementation-specific state structures
+
+dataset
+dataset.getCurrentDataset() exposes:
+dataset identity
+dataset metadata
+semantic dataset context relevant to the active page
+The namespace must remain aligned with backend-enforced dataset visibility and column-access semantics.
+navigation
+navigation exposes lightweight routing and surface context:
+page type
+focused entity identity
+page-change events
+It should not embed full entity payloads already exposed through other namespaces.
+
+Namespace compatibility
+Namespace contracts are part of the public Superset extension API surface and follow the normal compatibility guarantees of @apache-superset/core.
+Breaking changes to namespace contracts require standard deprecation and migration paths rather than silent behavioral changes.
+Extensions should depend only on the documented namespace contracts and not on internal frontend implementation details.
+
+Reusing existing permission logic
+The backend scoping required above (notably the dashboard-context endpoint) must reuse the host's existing permission checks — the same ChartFilter / datasource-access and can_access logic already used by the API layer — rather than reimplementing parallel filtering logic. This ensures the context the chatbot receives cannot drift from the backend authorization model.
+
+4. Design decision
+ Host-derived context vs. chatbot-assembled context
+ Multiple approaches were evaluated for how chatbot extensions receive page context.
+ Option 1 — chatbot-assembled context
+ The host exposes low-level primitives, routing information, and surface-specific state.
+ Each chatbot extension independently derives:
+ the current pageType
+ the focused entity
+ dashboard/chart/dataset context
+ its own higher-level context object
+ Option 2 — host-derived context (chosen)
+ The host computes normalized, permission-safe per-surface context through stable extension namespaces.
+ The chatbot consumes and composes those already-safe fragments into its own application-specific context.
+ This SIP chooses Option 2.
+ The primary reason is that context derivation, semantic normalization, and extension-facing context contracts should remain host-owned concerns rather than extension responsibilities.
+ Under Option 1, each chatbot extension independently derives semantic context from low-level frontend state and routing information.
+ While much of that state already originates from permission-checked backend APIs, the meaning and visibility semantics of that state vary by surface and are tightly coupled to internal frontend implementation details.
+ This creates several problems:
+ extensions become coupled to Redux structure and frontend internals
+ semantic context derivation becomes inconsistent across chatbot implementations
+ frontend architecture changes become breaking changes for extensions
+ permission-aware normalization logic becomes duplicated across extensions
+ Option 2 establishes a host-owned semantic normalization layer through stable extension namespaces.
+
+The host computes and normalizes the exposed context — sourced from permission-scoped backend APIs before it reaches the extension, providing a stable semantic contract across application surfaces. Permission enforcement stays on the backend (where it already lives); the namespace layer provides normalization and a stable contract, not a security boundary.
+
+Option 2 also provides:
+stable, typed extension-facing contracts
+semantic per-surface APIs
+decoupling from frontend implementation details
+compatibility with future frontend architecture changes
+For example, the host may evolve away from Redux-based state management in the future without requiring chatbot extensions to change, as long as the namespace contracts remain stable.
+
+Alternative evaluated: route-only / backend-derived context
+A third approach was also evaluated.
+Under this model:
+the frontend exposes only routing information (URL + route params)
+the chatbot backend or MCP server reconstructs context independently
+all entity resolution happens server-side through APIs
+This approach was rejected because it loses synchronization with unsaved frontend state and transient UI context.
+For example:
+unsaved chart edits in Explore
+dashboard filter changes not yet persisted
+temporary UI-control state
+selected charts/tabs
+local SQL editor changes
+draft configuration changes
+are all part of the user's active working context but may not yet exist in persisted backend state or be representable through the URL alone.
+A route-only model would therefore cause the chatbot context to drift from what the user is actively seeing and editing in the frontend.
+The chosen namespace-based model allows the chatbot to remain synchronized with live frontend state while still preserving stable, host-managed extension APIs.
+
+Current namespace behavior
+The existing sqlLab namespace already follows the general model introduced by this SIP:
+backend APIs enforce authorization and visibility
+frontend state reflects the user's authorized application state
+the namespace layer exposes stable semantic extension-facing APIs over that state
+This SIP extends that model to additional application surfaces such as:
+dashboard
+explore
+dataset
+navigation
+The new namespaces introduced by this SIP are intended to provide:
+stable semantic extension contracts
+normalized page-context APIs
+decoupling from frontend implementation details
+compatibility across future frontend architecture changes
+Some application surfaces may require dedicated context-oriented APIs to expose semantic chatbot context cleanly and consistently.
+For example, dashboard chatbot context requires a stable representation of:
+dashboard entities
+dashboard filter state
+dashboard UI-control state
+active chart context
+rather than direct exposure of internal frontend state structures.
+The namespace layer therefore acts as:
+a semantic normalization layer
+a stable extension abstraction boundary
+the public context-sharing API for chatbot extensions
+Extensions consume namespace APIs rather than internal host state or frontend implementation details.
+
+Tradeoffs accepted by this design
+The cost of Option 2 is accepted intentionally.
+The host must:
+build and maintain the context derivation layer
+expose new namespaces as additional application surfaces become extension-aware
+evolve namespace contracts over time
+Chatbot extensions are intentionally limited to the context exposed by those namespaces.
+If additional context is required, the namespace contract must evolve explicitly rather than allowing extensions to depend on arbitrary host state directly.
+That restriction is deliberate because it preserves:
+stable extension contracts
+implementation independence
+centralized semantic normalization
+consistent semantic context contracts aligned with backend authorization semantics
+
+Extension-owned conversation state
+All conversation state, message history, tool-call state, streaming buffers, and persistence remain entirely owned by the chatbot extension.
+They are never reflected in:
+the host Redux store
+host-managed frontend state
+shared application reducers
+host persistence layers
+The host owns page-context exposure only.
+The chatbot owns all conversational runtime state.
+
+Design considerations
+The bubble must not occlude critical dashboard content. The host guarantees a safe zone in the corner, but the extension is responsible for not extending the expanded panel in a way that covers important UI on smaller viewports.
+The host does not animate the bubble or panel — animation is the extension's responsibility, so each chatbot can have its own brand identity.
+The chatbot extension is initialized as part of the application shell lifecycle so the bubble is available consistently across routes and can be invoked immediately by the user. Since extensions are delivered through Module Federation, chatbot implementations should still be mindful of remote bundle size and avoid expensive synchronous initialization work during activation or first render.
+If the chatbot extension fails to load (Module Federation fetch error, runtime exception during activation), the host logs the error, shows a notification and leaves the corner empty rather than displaying a broken placeholder.
+
+5. Open Questions
+
+Per-page visibility. Should the chatbot extension declare which pages it's relevant for (e.g., "only show on dashboard and SQL Lab")?
+Two options:
+Extension hides itself via navigation.onDidChangePage events.
+Extension declares page scopes in extension.json and the host enforces them.
+The first is simpler and keeps the host policy-free; the second is more discoverable.
+
+Generalizing the slot. Should superset.chatbot be a chatbot-specific area, or should it generalize to something like app.floating-action that could accept multiple stacked widgets (chatbot, tour guide, notification widget)?
+My recommendation is to keep it chatbot-specific for now. If other floating-widget use cases emerge, introduce a separate contribution area rather than overloading this one.
+
+6. Related Documents
+ Contribution types
+ Client actions
+
+7. Migration Plan
+ Base branch chat-prototype
+
+Required core changes
+The following are net-new additions to `@apache-superset/core` and the host that
+this SIP depends on; they do not exist today and must land for the chatbot
+extension point to be implementable:
+
+- New `dashboard` namespace — current-dashboard getter plus change events for the
+ dashboard entity, its filters, and its UI-control state. Serves the `dashboard`
+ page type.
+
+- New `explore` namespace — current-chart getter (saved chart + chart data) plus a
+ change event. Serves the `chart` page type (Explore view).
+- New `dataset` namespace — current-dataset getter plus a change event. Serves the
+ `dataset` page type.
+
+- New `navigation` namespace — current-page getter and the `onDidChangePage` event.
+- Semantic normalization and stable extension-facing contracts for the new
+ `dashboard`, `explore`, `dataset`, and `navigation` namespaces.
+
+ The namespace layer must expose curated semantic context APIs over
+ backend-authorized application state while avoiding direct exposure of
+ Redux structures or other frontend implementation details.
+
+ This work also includes the permission-scoped dashboard-context backend
+ endpoint required to align dashboard chatbot context with existing
+ Superset authorization semantics (§2.1).
+
+- New app-root contribution scope in the manifest schema (`ViewContributions`) so
+ `superset.chatbot` can be declared in `extension.json` (currently SQL-Lab-only).
+- Host-side exclusive-location resolution — an internal provider accessor (e.g.
+ `getViewProvider(location, id)`) or a dedicated `getActiveChatbot()` resolver, so
+ the host can render the selected singleton; `getViews` returns descriptors only.
+- Admin setting for singleton conflict resolution (the "Default chatbot" picker,
+ §4 option (c)).
+- New `icon` field on the `View` descriptor interface (§3.2) — required for the
+ admin picker and manifest listing.
+ Open decision before implementation: whether the field is static (set at `registerView()`) or runtime-updatable by the chatbot owner; this SIP recommends static.
+
+8. Phases
+ The required core changes above are sequenced into the following phases. Tickets
+ must reference these phase numbers (P1–P4) — there is no other phase numbering for
+ this SIP.
+
+- P1 — Mount point & registration. The `superset.chatbot` contribution area, the
+ app-root contribution scope in `ViewContributions`, host-side exclusive-location
+ resolution (`getViewProvider` / `getActiveChatbot`), eager mount at the app
+ shell, fault isolation at the mount boundary, and the extension
+ lifecycle/teardown contract — the deactivate/uninstall/replace **signal** and the
+ `Disposable` cleanup drives.
+ Open item in P1: whether teardown needs an
+ async-aware hook (e.g. `deactivate(): Promise<void>`) in addition to the
+ synchronous `Disposable`, since `Disposable.dispose()` is not awaited.
+- P2 — Admin & singleton selection. The `icon` field on `View`, the "Default
+ chatbot" admin setting, and admin enable/disable behavior (§2, §4).
+- P3 — Context namespaces. The new `dashboard`, `explore`, `dataset`, and
+ `navigation` namespaces (semantic normalization + stable contract), plus the
+ permission-scoped dashboard-context endpoint that closes the §2.1 gap on the
+ backend. This is the critical path for page-context tickets.
+
+- P4 — Navigation/client-action commands. Out of this SIP's scope — owned by the
+ client-actions SIP (Justin Park).
diff --git a/superset-frontend/src/core/navigation/index.ts b/superset-frontend/src/core/navigation/index.ts
index 96e87754f9e..01859ba6fed 100644
--- a/superset-frontend/src/core/navigation/index.ts
+++ b/superset-frontend/src/core/navigation/index.ts
@@ -36,7 +36,7 @@ function derivePageType(pathname: string): PageType {
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 === '/sqllab' || pathname.startsWith('/sqllab/')) return 'sqllab';
if (pathname.startsWith('/dataset/')) return 'dataset';
if (pathname.startsWith('/superset/welcome/')) return 'home';
return 'other';
(superset) 04/13: fix(extensions): defer chatbot render until settings load; isolate provider errors
Posted by en...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch test/chatbot-local
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 96f2fb36592fca777e7962750fa7f90441b2797b
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Wed May 27 09:57:05 2026 +0200
fix(extensions): defer chatbot render until settings load; isolate provider errors
- Don't call getActiveChatbot before settings arrive — start with null
so no chatbot is rendered until the admin selection is known, avoiding
a flash of the wrong chatbot when multiple are registered
- On settings fetch failure fall back to first-to-register instead of
rendering nothing forever
- Wrap provider call in a ChatbotRenderer child component so
ErrorBoundary actually catches provider-level throws (calling the
provider inline during render means errors bubble before the boundary
can mount)
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
superset-frontend/src/components/ChatbotMount/index.tsx | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx b/superset-frontend/src/components/ChatbotMount/index.tsx
index e85a0d3bf7c..87d4894a918 100644
--- a/superset-frontend/src/components/ChatbotMount/index.tsx
+++ b/superset-frontend/src/components/ChatbotMount/index.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
import { useState, useEffect, useCallback } from 'react';
+import { ReactElement } from 'react';
import { SupersetClient } from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/theme';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
@@ -27,12 +28,19 @@ import { CHATBOT_LOCATION } from 'src/views/contributions';
const CHATBOT_EDGE_MARGIN = 24;
+type ActiveChatbot = { provider: () => ReactElement };
+
+// Renders the provider as a component so ErrorBoundary catches provider-level throws.
+const ChatbotRenderer = ({ provider }: { provider: () => ReactElement }) =>
+ provider();
+
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, {}),
+ // null = settings not yet loaded; don't render anything until settings arrive.
+ const [activeChatbot, setActiveChatbot] = useState<ActiveChatbot | null>(
+ null,
);
const fetchSettings = useCallback(() => {
@@ -48,6 +56,7 @@ const ChatbotMount = () => {
})
.catch(() => {
// Settings fetch failure is non-fatal — fall back to first-to-register.
+ setActiveChatbot(getActiveChatbot(null, {}));
});
return () => {
cancelled = true;
@@ -81,7 +90,9 @@ const ChatbotMount = () => {
z-index: ${theme.zIndexPopupBase + 2};
`}
>
- <ErrorBoundary>{activeChatbot.provider()}</ErrorBoundary>
+ <ErrorBoundary>
+ <ChatbotRenderer provider={activeChatbot.provider} />
+ </ErrorBoundary>
</div>
);
};
(superset) 11/13: fix(extensions): address context-sharing PR review — blockers, quality, and tests
Posted by en...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch test/chatbot-local
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 23d4574caf498f4470472b7dca91cf1e2bac642f
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Thu May 28 00:12:30 2026 +0200
fix(extensions): address context-sharing PR review — blockers, quality, and tests
- Add CREATE_NEW_SLICE and SLICE_UPDATED to exploreChangePredicate so
onDidChangeChart fires when a chart is saved for the first time
- Remove dataset namespace: no producer exists yet; ships it once a
caller is wired in DatasetCreation or equivalent
- Remove ...supersetCore spread from window.superset assignment so
un-contracted symbols from @apache-superset/core are not leaked onto
the global object; list namespaces explicitly instead
- Add defensive array copy for filter values in buildDashboardContext
so extension mutations cannot affect Redux state
- Lazy-initialize currentPageType in navigation to avoid module-load
window.location access (throws in non-browser test environments)
- Fix /sqllab exact-match missing from derivePageType
- Add unit tests: navigation (7), explore (9), dashboard (11) — 27 tests
covering page-type gating, dispose semantics, predicate coverage, and
defensive copy invariant
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
superset-frontend/src/core/dashboard/index.test.ts | 168 +++++++++++++++++++++
superset-frontend/src/core/dashboard/index.ts | 13 +-
superset-frontend/src/core/explore/index.test.ts | 157 +++++++++++++++++++
superset-frontend/src/core/explore/index.ts | 6 +-
superset-frontend/src/core/index.ts | 1 -
.../src/core/navigation/index.test.ts | 89 +++++++++++
superset-frontend/src/core/navigation/index.ts | 16 +-
.../src/extensions/ExtensionsStartup.tsx | 15 +-
8 files changed, 443 insertions(+), 22 deletions(-)
diff --git a/superset-frontend/src/core/dashboard/index.test.ts b/superset-frontend/src/core/dashboard/index.test.ts
new file mode 100644
index 00000000000..f5078c2e7c1
--- /dev/null
+++ b/superset-frontend/src/core/dashboard/index.test.ts
@@ -0,0 +1,168 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// ---------------------------------------------------------------------------
+// Captured listeners — allows tests to trigger action notifications manually.
+// ---------------------------------------------------------------------------
+type ListenerEntry = {
+ predicate: (action: { type: string }) => boolean;
+ effect: (action: { type: string }) => void;
+};
+
+const capturedListeners: ListenerEntry[] = [];
+
+// Declared before jest.mock so the factory closure can reference it.
+let mockState: Record<string, unknown>;
+
+jest.mock('src/views/store', () => ({
+ store: { getState: () => mockState, dispatch: jest.fn() },
+ listenerMiddleware: {
+ startListening: (opts: {
+ predicate: (action: { type: string }) => boolean;
+ effect: (action: { type: string }) => void;
+ }) => {
+ const entry = { predicate: opts.predicate, effect: opts.effect };
+ capturedListeners.push(entry);
+ return () => {
+ const idx = capturedListeners.indexOf(entry);
+ if (idx !== -1) capturedListeners.splice(idx, 1);
+ };
+ },
+ },
+}));
+
+jest.mock('../navigation', () => ({
+ navigation: { getPageType: jest.fn(() => 'dashboard') },
+}));
+
+function dispatch(actionType: string) {
+ const action = { type: actionType };
+ capturedListeners
+ .filter(e => e.predicate(action))
+ .forEach(e => e.effect(action));
+}
+
+// Imported after mocks
+// eslint-disable-next-line import/first
+import { dashboard } from './index';
+
+function makeState(
+ overrides: Partial<{
+ dashboardInfo: unknown;
+ nativeFilters: unknown;
+ dataMask: unknown;
+ }> = {},
+) {
+ return {
+ dashboardInfo: { id: 1, dashboard_title: 'Sales', slug: 'sales' },
+ nativeFilters: { filters: { 'filter-1': { name: 'Region' } } },
+ dataMask: { 'filter-1': { filterState: { value: ['West'] } } },
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ mockState = makeState();
+});
+
+afterEach(() => {
+ capturedListeners.length = 0;
+ jest.restoreAllMocks();
+});
+
+test('getCurrentDashboard returns undefined when not on dashboard page', () => {
+ const { navigation } = jest.requireMock('../navigation');
+ (navigation.getPageType as jest.Mock).mockReturnValueOnce('explore');
+ expect(dashboard.getCurrentDashboard()).toBeUndefined();
+});
+
+
+test('getCurrentDashboard returns undefined when dashboardInfo is absent', () => {
+ mockState = makeState({ dashboardInfo: undefined });
+ expect(dashboard.getCurrentDashboard()).toBeUndefined();
+});
+
+test('getCurrentDashboard returns dashboard context with active filters', () => {
+ expect(dashboard.getCurrentDashboard()).toEqual({
+ dashboardId: 1,
+ title: 'Sales',
+ filters: [{ filterId: 'filter-1', label: 'Region', value: ['West'] }],
+ });
+});
+
+test('getCurrentDashboard excludes filters with null value', () => {
+ mockState = makeState({
+ dataMask: { 'filter-1': { filterState: { value: null } } },
+ });
+ expect(dashboard.getCurrentDashboard()?.filters).toHaveLength(0);
+});
+
+test('getCurrentDashboard excludes dataMask entries not in nativeFilters', () => {
+ mockState = makeState({
+ dataMask: { 'chart-filter': { filterState: { value: 'foo' } } },
+ });
+ expect(dashboard.getCurrentDashboard()?.filters).toHaveLength(0);
+});
+
+test('filter array value is a defensive copy — mutation does not affect Redux state', () => {
+ const ctx = dashboard.getCurrentDashboard();
+ const original = [...(mockState as any).dataMask['filter-1'].filterState.value];
+ (ctx!.filters[0].value as string[]).push('East');
+ expect((mockState as any).dataMask['filter-1'].filterState.value).toEqual(original);
+});
+
+// Action type strings match the constants in src/dashboard/actions/hydrate
+// and src/dataMask/actions — kept as literals so this test file has no
+// import dependency on those modules.
+test.each([
+ 'HYDRATE_DASHBOARD',
+ 'UPDATE_DATA_MASK',
+ 'SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE',
+])('onDidChangeDashboard fires on action type %s', actionType => {
+ const listener = jest.fn();
+ const disposable = dashboard.onDidChangeDashboard(listener);
+
+ dispatch(actionType);
+
+ expect(listener).toHaveBeenCalledWith(
+ expect.objectContaining({ dashboardId: 1, title: 'Sales' }),
+ );
+ disposable.dispose();
+});
+
+test('onDidChangeDashboard does not fire when not on dashboard page', () => {
+ const { navigation } = jest.requireMock('../navigation');
+ (navigation.getPageType as jest.Mock).mockReturnValue('explore');
+
+ const listener = jest.fn();
+ const disposable = dashboard.onDidChangeDashboard(listener);
+ dispatch('HYDRATE_DASHBOARD');
+
+ expect(listener).not.toHaveBeenCalled();
+ (navigation.getPageType as jest.Mock).mockReturnValue('dashboard');
+ disposable.dispose();
+});
+
+test('disposed listener is not called', () => {
+ const listener = jest.fn();
+ const disposable = dashboard.onDidChangeDashboard(listener);
+ disposable.dispose();
+ dispatch('HYDRATE_DASHBOARD');
+ expect(listener).not.toHaveBeenCalled();
+});
diff --git a/superset-frontend/src/core/dashboard/index.ts b/superset-frontend/src/core/dashboard/index.ts
index 611a4514f0b..dbfcdfac85c 100644
--- a/superset-frontend/src/core/dashboard/index.ts
+++ b/superset-frontend/src/core/dashboard/index.ts
@@ -54,11 +54,14 @@ function buildDashboardContext(): DashboardContext | undefined {
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,
- }));
+ .map(([id, mask]: [string, any]) => {
+ const raw = mask.filterState.value;
+ return {
+ filterId: id,
+ label: nativeFilters[id]?.name ?? id,
+ value: Array.isArray(raw) ? [...raw] : raw,
+ };
+ });
return {
dashboardId: info.id as number,
diff --git a/superset-frontend/src/core/explore/index.test.ts b/superset-frontend/src/core/explore/index.test.ts
new file mode 100644
index 00000000000..744478bc956
--- /dev/null
+++ b/superset-frontend/src/core/explore/index.test.ts
@@ -0,0 +1,157 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// ---------------------------------------------------------------------------
+// Captured listeners — allows tests to trigger action notifications manually.
+// ---------------------------------------------------------------------------
+type ListenerEntry = {
+ predicate: (action: { type: string }) => boolean;
+ effect: (action: { type: string }) => void;
+};
+
+const capturedListeners: ListenerEntry[] = [];
+
+// Declared before jest.mock so the factory closure can reference it.
+let mockState: Record<string, unknown>;
+
+jest.mock('src/views/store', () => ({
+ store: { getState: () => mockState, dispatch: jest.fn() },
+ listenerMiddleware: {
+ startListening: (opts: {
+ predicate: (action: { type: string }) => boolean;
+ effect: (action: { type: string }) => void;
+ }) => {
+ const entry = { predicate: opts.predicate, effect: opts.effect };
+ capturedListeners.push(entry);
+ return () => {
+ const idx = capturedListeners.indexOf(entry);
+ if (idx !== -1) capturedListeners.splice(idx, 1);
+ };
+ },
+ },
+}));
+
+jest.mock('../navigation', () => ({
+ navigation: { getPageType: jest.fn(() => 'explore') },
+}));
+
+function dispatch(actionType: string) {
+ const action = { type: actionType };
+ capturedListeners
+ .filter(e => e.predicate(action))
+ .forEach(e => e.effect(action));
+}
+
+// Imported after mocks
+// eslint-disable-next-line import/first
+import { explore } from './index';
+
+beforeEach(() => {
+ mockState = {
+ explore: {
+ slice: { slice_id: 42, slice_name: 'My Chart' },
+ datasource: { id: 7, table_name: 'orders' },
+ controls: { viz_type: { value: 'bar' } },
+ sliceName: 'My Chart',
+ form_data: {},
+ },
+ };
+});
+
+afterEach(() => {
+ capturedListeners.length = 0;
+ jest.restoreAllMocks();
+});
+
+test('getCurrentChart returns undefined when not on explore page', () => {
+ const { navigation } = jest.requireMock('../navigation');
+ (navigation.getPageType as jest.Mock).mockReturnValueOnce('dashboard');
+ expect(explore.getCurrentChart()).toBeUndefined();
+});
+
+test('getCurrentChart returns undefined when explore state is absent', () => {
+ mockState = {};
+ expect(explore.getCurrentChart()).toBeUndefined();
+});
+
+test('getCurrentChart returns chart context from Redux state', () => {
+ expect(explore.getCurrentChart()).toEqual({
+ chartId: 42,
+ chartName: 'My Chart',
+ vizType: 'bar',
+ datasourceId: 7,
+ datasourceName: 'orders',
+ });
+});
+
+test('getCurrentChart returns null chartId for unsaved chart', () => {
+ mockState = {
+ explore: {
+ slice: null,
+ datasource: { id: 1, table_name: 'events' },
+ controls: { viz_type: { value: 'line' } },
+ sliceName: null,
+ form_data: { viz_type: 'line' },
+ },
+ };
+ expect(explore.getCurrentChart()?.chartId).toBeNull();
+});
+
+// Action type strings match the constants in src/explore/actions/exploreActions
+// and src/explore/actions/datasourcesActions — kept as literals so this test
+// file has no import dependency on those modules.
+test.each([
+ 'HYDRATE_EXPLORE',
+ 'UPDATE_FORM_DATA', // SET_FORM_DATA constant resolves to this string
+ 'UPDATE_CHART_TITLE',
+ 'SET_DATASOURCE',
+ 'CREATE_NEW_SLICE',
+ 'SLICE_UPDATED',
+])('onDidChangeChart fires on action type %s', actionType => {
+ const listener = jest.fn();
+ const disposable = explore.onDidChangeChart(listener);
+
+ dispatch(actionType);
+
+ expect(listener).toHaveBeenCalledWith(
+ expect.objectContaining({ chartId: 42, vizType: 'bar' }),
+ );
+ disposable.dispose();
+});
+
+test('onDidChangeChart does not fire when page type is not explore', () => {
+ const { navigation } = jest.requireMock('../navigation');
+ (navigation.getPageType as jest.Mock).mockReturnValue('dashboard');
+
+ const listener = jest.fn();
+ const disposable = explore.onDidChangeChart(listener);
+ dispatch('HYDRATE_EXPLORE');
+
+ expect(listener).not.toHaveBeenCalled();
+ (navigation.getPageType as jest.Mock).mockReturnValue('explore');
+ disposable.dispose();
+});
+
+test('disposed listener is not called', () => {
+ const listener = jest.fn();
+ const disposable = explore.onDidChangeChart(listener);
+ disposable.dispose();
+ dispatch('HYDRATE_EXPLORE');
+ expect(listener).not.toHaveBeenCalled();
+});
diff --git a/superset-frontend/src/core/explore/index.ts b/superset-frontend/src/core/explore/index.ts
index 732ae975caa..2e09ffd34cc 100644
--- a/superset-frontend/src/core/explore/index.ts
+++ b/superset-frontend/src/core/explore/index.ts
@@ -27,7 +27,9 @@
import type { explore as exploreApi } from '@apache-superset/core';
import { HYDRATE_EXPLORE } from 'src/explore/actions/hydrateExplore';
import {
+ CREATE_NEW_SLICE,
SET_FORM_DATA,
+ SLICE_UPDATED,
UPDATE_CHART_TITLE,
} from 'src/explore/actions/exploreActions';
import { SET_DATASOURCE } from 'src/explore/actions/datasourcesActions';
@@ -64,7 +66,9 @@ const exploreChangePredicate: AnyListenerPredicate<RootState> = action =>
action.type === HYDRATE_EXPLORE ||
action.type === SET_FORM_DATA ||
action.type === UPDATE_CHART_TITLE ||
- action.type === SET_DATASOURCE;
+ action.type === SET_DATASOURCE ||
+ action.type === CREATE_NEW_SLICE ||
+ action.type === SLICE_UPDATED;
const getCurrentChart: typeof exploreApi.getCurrentChart = () =>
buildChartContext();
diff --git a/superset-frontend/src/core/index.ts b/superset-frontend/src/core/index.ts
index d259597457c..bc170079057 100644
--- a/superset-frontend/src/core/index.ts
+++ b/superset-frontend/src/core/index.ts
@@ -29,7 +29,6 @@ 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';
diff --git a/superset-frontend/src/core/navigation/index.test.ts b/superset-frontend/src/core/navigation/index.test.ts
new file mode 100644
index 00000000000..a8c570153fd
--- /dev/null
+++ b/superset-frontend/src/core/navigation/index.test.ts
@@ -0,0 +1,89 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Reset module state between tests so currentPageType is re-initialized.
+beforeEach(() => {
+ jest.resetModules();
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { pathname: '/' },
+ });
+});
+
+async function importNavigation() {
+ const mod = await import('./index');
+ return mod;
+}
+
+test('getPageType returns "other" for unknown pathname', async () => {
+ const { navigation } = await importNavigation();
+ expect(navigation.getPageType()).toBe('other');
+});
+
+test('getPageType derives page type from window.location.pathname', async () => {
+ window.location.pathname = '/superset/dashboard/42/';
+ const { navigation } = await importNavigation();
+ expect(navigation.getPageType()).toBe('dashboard');
+});
+
+test('notifyPageChange updates the current page type', async () => {
+ const { navigation, notifyPageChange } = await importNavigation();
+ notifyPageChange('/explore/?form_data={}');
+ expect(navigation.getPageType()).toBe('explore');
+});
+
+test('notifyPageChange fires listeners on page type change', async () => {
+ const { navigation, notifyPageChange } = await importNavigation();
+ const listener = jest.fn();
+ const disposable = navigation.onDidChangePage(listener);
+
+ notifyPageChange('/superset/dashboard/1/');
+ expect(listener).toHaveBeenCalledWith('dashboard');
+
+ disposable.dispose();
+});
+
+test('notifyPageChange does not fire listeners when page type is unchanged', async () => {
+ window.location.pathname = '/superset/dashboard/1/';
+ const { navigation, notifyPageChange } = await importNavigation();
+ const listener = jest.fn();
+ navigation.onDidChangePage(listener);
+
+ notifyPageChange('/superset/dashboard/2/');
+ expect(listener).not.toHaveBeenCalled();
+});
+
+test('onDidChangePage listener is removed after dispose', async () => {
+ const { navigation, notifyPageChange } = await importNavigation();
+ const listener = jest.fn();
+ const disposable = navigation.onDidChangePage(listener);
+
+ disposable.dispose();
+ notifyPageChange('/superset/dashboard/1/');
+ expect(listener).not.toHaveBeenCalled();
+});
+
+test('sqllab path is matched with and without trailing slash', async () => {
+ const { notifyPageChange, navigation } = await importNavigation();
+ notifyPageChange('/sqllab');
+ expect(navigation.getPageType()).toBe('sqllab');
+ notifyPageChange('/explore/');
+ notifyPageChange('/sqllab/history');
+ expect(navigation.getPageType()).toBe('sqllab');
+});
diff --git a/superset-frontend/src/core/navigation/index.ts b/superset-frontend/src/core/navigation/index.ts
index 01859ba6fed..fb9cf9d2b1e 100644
--- a/superset-frontend/src/core/navigation/index.ts
+++ b/superset-frontend/src/core/navigation/index.ts
@@ -36,23 +36,31 @@ function derivePageType(pathname: string): PageType {
if (pathname.startsWith('/explore/')) return 'explore';
if (pathname.startsWith('/superset/explore/')) return 'explore';
if (pathname.startsWith('/chart/add')) return 'explore';
- if (pathname === '/sqllab' || pathname.startsWith('/sqllab/')) return 'sqllab';
+ if (pathname === '/sqllab' || 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);
+let currentPageType: PageType | undefined;
+
+function getOrInitPageType(): PageType {
+ if (currentPageType === undefined) {
+ currentPageType = derivePageType(window.location.pathname);
+ }
+ return currentPageType;
+}
/** Called by ExtensionsStartup whenever the React Router location changes. */
export const notifyPageChange = (pathname: string): void => {
const next = derivePageType(pathname);
- if (next === currentPageType) return;
+ if (next === getOrInitPageType()) return;
currentPageType = next;
listeners.forEach(fn => fn(next));
};
-const getPageType: typeof navigationApi.getPageType = () => currentPageType;
+const getPageType: typeof navigationApi.getPageType = () => getOrInitPageType();
const onDidChangePage: typeof navigationApi.onDidChangePage = (
listener: (pageType: PageType) => void,
diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx
index 6b7dfd45e88..26ba1f928de 100644
--- a/superset-frontend/src/extensions/ExtensionsStartup.tsx
+++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx
@@ -18,8 +18,6 @@
*/
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 {
@@ -27,7 +25,6 @@ import {
core,
commands,
dashboard,
- dataset,
editors,
explore,
extensions,
@@ -48,7 +45,6 @@ declare global {
core: typeof core;
commands: typeof commands;
dashboard: typeof dashboard;
- dataset: typeof dataset;
editors: typeof editors;
explore: typeof explore;
extensions: typeof extensions;
@@ -84,10 +80,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
// browser's default error surfacing so host error reporting is unaffected.
useEffect(() => {
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
- logging.error(
- '[extensions] Unhandled rejection:',
- event.reason,
- );
+ logging.error('[extensions] Unhandled rejection:', event.reason);
};
window.addEventListener('unhandledrejection', handleUnhandledRejection);
return () => {
@@ -107,14 +100,14 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
return;
}
- // Provide the implementations for @apache-superset/core
+ // Provide the implementations for @apache-superset/core.
+ // Namespaces are listed explicitly — do not spread the core package here,
+ // as that would leak un-contracted symbols onto window.superset.
window.superset = {
- ...supersetCore,
authentication,
core,
commands,
dashboard,
- dataset,
editors,
explore,
extensions,
(superset) 02/13: feat(extensions): port import/delete UI and backend endpoints to test branch
Posted by en...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch test/chatbot-local
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 97ba65b0cd161e3e41b1df062d1fc2aeeca9b914
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Tue May 26 21:30:32 2026 +0200
feat(extensions): port import/delete UI and backend endpoints to test branch
- POST /api/v1/extensions/ — admin upload of .supx bundles
- DELETE /api/v1/extensions/<publisher>/<name> — admin removal
- Import button in ExtensionsList SubMenu (file picker, .supx only)
- Per-row delete action with confirmation dialog
- Keeps existing Select (default chatbot) and Switch (enabled) UI
- Add publisher to build_extension_data response
- Add subscribeToLocation / getRegisteredViewIds / getViewProvider to
src/core/views/index.ts so ExtensionsList can detect chatbot extensions
- Fix scripts/oxlint.sh set -e / [ -n "" ] false-positive exit
- Fix settings.py MySQL fallback: use read-then-update instead of
try/except/rollback to satisfy the custom consider-using-transaction rule
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
scripts/oxlint.sh | 2 +-
.../src/extensions/ExtensionsList.tsx | 145 +++++++++++++++++-
superset/extensions/api.py | 169 ++++++++++++++++++++-
superset/extensions/settings.py | 48 +++---
superset/extensions/utils.py | 1 +
5 files changed, 327 insertions(+), 38 deletions(-)
diff --git a/scripts/oxlint.sh b/scripts/oxlint.sh
index 95f48afabb6..9baf026ddf2 100755
--- a/scripts/oxlint.sh
+++ b/scripts/oxlint.sh
@@ -55,7 +55,7 @@ if [ ${#js_ts_files[@]} -gt 0 ]; then
echo "$output" >&2
exit 1
}
- [ -n "$output" ] && echo "$output"
+ if [ -n "$output" ]; then echo "$output"; fi
else
echo "No JavaScript/TypeScript files to lint"
fi
diff --git a/superset-frontend/src/extensions/ExtensionsList.tsx b/superset-frontend/src/extensions/ExtensionsList.tsx
index e7974c76817..e31b14a87f2 100644
--- a/superset-frontend/src/extensions/ExtensionsList.tsx
+++ b/superset-frontend/src/extensions/ExtensionsList.tsx
@@ -18,11 +18,20 @@
*/
import { t } from '@apache-superset/core/translation';
import { css } from '@apache-superset/core/theme';
-import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ FunctionComponent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import { SupersetClient } from '@superset-ui/core';
-import { Select } from '@superset-ui/core/components';
+import { ConfirmStatusChange, Select, Tooltip } from '@superset-ui/core/components';
import { Switch } from '@superset-ui/core/components/Switch';
+import { Icons } from '@superset-ui/core/components/Icons';
import { useListViewResource } from 'src/views/CRUD/hooks';
+import { createErrorHandler } from 'src/views/CRUD/utils';
import { ListView } from 'src/components';
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
import withToasts from 'src/components/MessageToasts/withToasts';
@@ -34,6 +43,7 @@ const PAGE_SIZE = 25;
type Extension = {
id: string;
name: string;
+ publisher: string;
enabled: boolean;
};
@@ -51,6 +61,9 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
addDangerToast,
addSuccessToast,
}) => {
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const [uploading, setUploading] = useState(false);
+
const {
state: { loading, resourceCount, resourceCollection },
fetchData,
@@ -107,8 +120,67 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
const chatbotExtensions = useMemo(() => {
const chatbotIds = new Set(getRegisteredViewIds(CHATBOT_LOCATION));
return resourceCollection.filter(ext => chatbotIds.has(ext.id));
+ // chatbotRegistryVersion is intentionally in deps to re-evaluate when views register
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [resourceCollection, chatbotRegistryVersion]);
+ const handleUploadClick = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ if (!file.name.endsWith('.supx')) {
+ addDangerToast(t('File must have a .supx extension.'));
+ e.target.value = '';
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append('bundle', file);
+
+ setUploading(true);
+ SupersetClient.post({
+ endpoint: '/api/v1/extensions/',
+ body: formData,
+ headers: { Accept: 'application/json' },
+ })
+ .then(() => {
+ addSuccessToast(t('Extension installed successfully.'));
+ refreshData();
+ })
+ .catch(
+ createErrorHandler(errMsg =>
+ addDangerToast(
+ t('There was an issue installing the extension: %s', errMsg),
+ ),
+ ),
+ )
+ .finally(() => {
+ setUploading(false);
+ e.target.value = '';
+ });
+ };
+
+ const handleDelete = (extension: Extension) => {
+ const { publisher, name } = extension;
+ SupersetClient.delete({
+ endpoint: `/api/v1/extensions/${publisher}/${name}`,
+ }).then(
+ () => {
+ addSuccessToast(t('Deleted: %s', extension.name));
+ refreshData();
+ },
+ createErrorHandler(errMsg =>
+ addDangerToast(
+ t('There was an issue deleting %s: %s', extension.name, errMsg),
+ ),
+ ),
+ );
+ };
+
const columns = useMemo(
() => [
{
@@ -117,7 +189,9 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
size: 'lg',
id: 'name',
Cell: ({
- row: { original: { name } },
+ row: {
+ original: { name },
+ },
}: any) => name,
},
{
@@ -126,7 +200,9 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
size: 'sm',
id: 'enabled',
Cell: ({
- row: { original: { id } },
+ row: {
+ original: { id },
+ },
}: any) => (
<Switch
data-test="toggle-enabled"
@@ -136,6 +212,39 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
/>
),
},
+ {
+ Header: t('Actions'),
+ id: 'actions',
+ disableSortBy: true,
+ Cell: ({ row: { original } }: any) => (
+ <ConfirmStatusChange
+ title={t('Please confirm')}
+ description={
+ <>
+ {t('Are you sure you want to delete')} <b>{original.name}</b>?
+ </>
+ }
+ onConfirm={() => handleDelete(original)}
+ >
+ {(confirmDelete: () => void) => (
+ <Tooltip
+ id="delete-extension-tooltip"
+ title={t('Delete')}
+ placement="bottom"
+ >
+ <span
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={confirmDelete}
+ >
+ <Icons.DeleteOutlined iconSize="l" />
+ </span>
+ </Tooltip>
+ )}
+ </ConfirmStatusChange>
+ ),
+ },
],
[loading, settings, toggleEnabled],
);
@@ -148,11 +257,33 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
const menuData: SubMenuProps = {
activeChild: 'Extensions',
name: t('Extensions'),
- buttons: [],
+ buttons: [
+ {
+ name: (
+ <Tooltip
+ id="import-extension-tooltip"
+ title={t('Import extension (.supx)')}
+ placement="bottomRight"
+ >
+ <Icons.DownloadOutlined iconSize="l" />
+ </Tooltip>
+ ),
+ buttonStyle: 'link',
+ onClick: handleUploadClick,
+ loading: uploading,
+ },
+ ],
};
return (
<>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".supx"
+ style={{ display: 'none' }}
+ onChange={handleFileChange}
+ />
<SubMenu {...menuData} />
{chatbotOptions.length > 1 && (
<div style={{ padding: '16px 24px' }}>
@@ -167,7 +298,9 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
saveSettings({ active_chatbot_id: (value as string) ?? null })
}
placeholder={t('First registered (automatic)')}
- css={css`width: 280px;`}
+ css={css`
+ width: 280px;
+ `}
/>
</div>
)}
diff --git a/superset/extensions/api.py b/superset/extensions/api.py
index d116c6ca70e..380e2c94165 100644
--- a/superset/extensions/api.py
+++ b/superset/extensions/api.py
@@ -16,9 +16,11 @@
# under the License.
import mimetypes
from io import BytesIO
+from pathlib import Path
from typing import Any
+from zipfile import is_zipfile, ZipFile
-from flask import request, send_file
+from flask import current_app, request, send_file
from flask.wrappers import Response
from flask_appbuilder.api import BaseApi, expose, protect, safe
@@ -29,13 +31,28 @@ from superset.extensions.settings import (
)
from superset.extensions.utils import (
build_extension_data,
+ get_bundle_files_from_zip,
get_extensions,
+ get_loaded_extension,
)
+from superset.utils.core import check_is_safe_zip
class ExtensionsRestApi(BaseApi):
allow_browser_login = True
resource_name = "extensions"
+ class_permission_name = "Extensions"
+ base_permissions = [
+ "can_get_list",
+ "can_get",
+ "can_put",
+ "can_post",
+ "can_delete",
+ "can_content",
+ "can_info",
+ "can_get_settings",
+ "can_put_settings",
+ ]
def response(self, status_code: int, **kwargs: Any) -> Response:
"""Helper method to create JSON responses."""
@@ -172,6 +189,156 @@ class ExtensionsRestApi(BaseApi):
extension_data = build_extension_data(extension)
return self.response(200, result=extension_data)
+ @protect()
+ @safe
+ @expose("/", methods=("POST",))
+ def post(self, **kwargs: Any) -> Response:
+ """Upload and install an extension bundle (.supx file).
+ ---
+ post:
+ summary: Upload a .supx extension bundle.
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ properties:
+ bundle:
+ type: string
+ format: binary
+ description: The .supx extension bundle file.
+ responses:
+ 201:
+ description: Extension installed successfully.
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ result:
+ type: object
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 403:
+ $ref: '#/components/responses/403'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ if not security_manager.is_admin():
+ return self.response(403, message="Admin access required.")
+
+ extensions_path = current_app.config.get("EXTENSIONS_PATH")
+ if not extensions_path:
+ return self.response(
+ 400,
+ message=(
+ "EXTENSIONS_PATH is not configured. Set it in superset_config.py "
+ "to enable extension uploads."
+ ),
+ )
+
+ upload = request.files.get("bundle")
+ if not upload:
+ return self.response(
+ 400, message="No file provided. Send a 'bundle' field."
+ )
+
+ if not upload.filename or not upload.filename.endswith(".supx"):
+ return self.response(400, message="File must have a .supx extension.")
+
+ stream = BytesIO(upload.read())
+ if not is_zipfile(stream):
+ return self.response(400, message="File is not a valid ZIP archive.")
+
+ stream.seek(0)
+ try:
+ with ZipFile(stream, "r") as zip_file:
+ check_is_safe_zip(zip_file)
+ files = list(get_bundle_files_from_zip(zip_file))
+ extension = get_loaded_extension(files, source_base_path="upload://")
+ except Exception as ex: # pylint: disable=broad-except
+ return self.response(400, message=f"Invalid extension bundle: {ex}")
+
+ # Persist to EXTENSIONS_PATH so the extension survives restarts.
+ dest_dir = Path(extensions_path)
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ dest_file = dest_dir / f"{extension.manifest.id}.supx"
+
+ stream.seek(0)
+ dest_file.write_bytes(stream.read())
+
+ return self.response(201, result=build_extension_data(extension))
+
+ @protect()
+ @safe
+ @expose("/<publisher>/<name>", methods=("DELETE",))
+ def delete(self, publisher: str, name: str, **kwargs: Any) -> Response:
+ """Delete an uploaded extension bundle.
+ ---
+ delete:
+ summary: Delete an extension by its publisher and name.
+ parameters:
+ - in: path
+ schema:
+ type: string
+ name: publisher
+ - in: path
+ schema:
+ type: string
+ name: name
+ responses:
+ 200:
+ description: Extension deleted.
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 403:
+ $ref: '#/components/responses/403'
+ 404:
+ $ref: '#/components/responses/404'
+ """
+ if not security_manager.is_admin():
+ return self.response(403, message="Admin access required.")
+
+ composite_id = f"{publisher}.{name}"
+ extensions = get_extensions()
+ extension = extensions.get(composite_id)
+ if not extension:
+ return self.response_404()
+
+ # LOCAL_EXTENSIONS are managed via config — cannot be deleted through the UI.
+ local_paths = {
+ str((Path(p) / "dist").resolve())
+ for p in current_app.config.get("LOCAL_EXTENSIONS", [])
+ }
+ if extension.source_base_path in local_paths:
+ return self.response(
+ 400,
+ message=(
+ "Local extensions configured via LOCAL_EXTENSIONS cannot be "
+ "deleted through the UI. Remove them from your configuration."
+ ),
+ )
+
+ # Locate and remove the .supx file from EXTENSIONS_PATH.
+ extensions_path = current_app.config.get("EXTENSIONS_PATH")
+ if not extensions_path:
+ return self.response(
+ 400,
+ message="EXTENSIONS_PATH is not configured; cannot remove bundle file.",
+ )
+
+ supx_file = Path(extensions_path) / f"{composite_id}.supx"
+ if not supx_file.exists():
+ return self.response_404()
+
+ supx_file.unlink()
+ return self.response(200, message="Extension deleted.")
+
@protect()
@safe
@expose("/settings", methods=("GET",))
diff --git a/superset/extensions/settings.py b/superset/extensions/settings.py
index 1224c9da9fa..367ab4aef44 100644
--- a/superset/extensions/settings.py
+++ b/superset/extensions/settings.py
@@ -21,7 +21,6 @@ from typing import Any
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
-from sqlalchemy.exc import IntegrityError
from superset import db
from superset.models.core import ExtensionEnabled, ExtensionSettings
@@ -65,20 +64,15 @@ def _upsert_settings_row(
)
db.session.execute(stmt)
else:
- # MySQL/MariaDB and other dialects: INSERT then UPDATE on conflict.
- try:
- db.session.execute(
- ExtensionSettings.__table__.insert().values(
- id=_SETTINGS_ROW_ID, active_chatbot_id=active_chatbot_id
- )
- )
- except IntegrityError:
- db.session.rollback()
- db.session.execute(
- ExtensionSettings.__table__.update()
- .where(ExtensionSettings.__table__.c.id == _SETTINGS_ROW_ID)
- .values(active_chatbot_id=active_chatbot_id)
+ # MySQL/MariaDB: session.merge handles INSERT-or-UPDATE without rollback.
+ obj = db.session.get(ExtensionSettings, _SETTINGS_ROW_ID)
+ if obj is None:
+ obj = ExtensionSettings(
+ id=_SETTINGS_ROW_ID, active_chatbot_id=active_chatbot_id
)
+ db.session.add(obj)
+ else:
+ obj.active_chatbot_id = active_chatbot_id
def _upsert_enabled_flag(extension_id: str, enabled: bool) -> None:
@@ -105,22 +99,16 @@ def _upsert_enabled_flag(extension_id: str, enabled: bool) -> None:
)
db.session.execute(stmt)
else:
- # MySQL/MariaDB and other dialects: INSERT then UPDATE on conflict.
- try:
- db.session.execute(
- ExtensionEnabled.__table__.insert().values(
- extension_id=extension_id, enabled=enabled
- )
- )
- except IntegrityError:
- db.session.rollback()
- db.session.execute(
- ExtensionEnabled.__table__.update()
- .where(
- ExtensionEnabled.__table__.c.extension_id == extension_id
- )
- .values(enabled=enabled)
- )
+ # MySQL/MariaDB: read-then-update without rollback.
+ obj = (
+ db.session.query(ExtensionEnabled)
+ .filter_by(extension_id=extension_id)
+ .first()
+ )
+ if obj is None:
+ db.session.add(ExtensionEnabled(extension_id=extension_id, enabled=enabled))
+ else:
+ obj.enabled = enabled
@transaction()
diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py
index 6332c97435d..878fbf0bb75 100644
--- a/superset/extensions/utils.py
+++ b/superset/extensions/utils.py
@@ -234,6 +234,7 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
manifest = extension.manifest
extension_data: dict[str, Any] = {
"id": manifest.id,
+ "publisher": manifest.publisher,
"name": extension.name,
"version": extension.version,
"description": manifest.description or "",
(superset) 08/13: fix(extensions): push settings payload through pub/sub to eliminate re-fetch delay on toggle
Posted by en...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch test/chatbot-local
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 22d9332794bc3c63c081fa518380ce9a10cdfca6
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Wed May 27 23:02:36 2026 +0200
fix(extensions): push settings payload through pub/sub to eliminate re-fetch delay on toggle
Co-Authored-By: Claude Sonnet 4.6 <no...@anthropic.com>
---
.../src/components/ChatbotMount/index.tsx | 21 ++++++++++++---------
superset-frontend/src/core/extensions/index.ts | 15 +++++++++++----
superset-frontend/src/extensions/ExtensionsList.tsx | 2 +-
3 files changed, 24 insertions(+), 14 deletions(-)
diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx b/superset-frontend/src/components/ChatbotMount/index.tsx
index 116e6743bfd..3eaec221741 100644
--- a/superset-frontend/src/components/ChatbotMount/index.tsx
+++ b/superset-frontend/src/components/ChatbotMount/index.tsx
@@ -39,15 +39,20 @@ const ChatbotMount = () => {
const [adminSelectedId, setAdminSelectedId] = useState<string | null | undefined>(undefined);
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
- const fetchSettings = useCallback(() => {
+ const applySettings = useCallback(
+ (settings: { active_chatbot_id: string | null; enabled: Record<string, boolean> }) => {
+ setAdminSelectedId(settings.active_chatbot_id ?? null);
+ setEnabledMap(settings.enabled ?? {});
+ },
+ [],
+ );
+
+ 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);
+ applySettings(json.result);
})
.catch(() => {
// Settings fetch failure is non-fatal — fall back to first-to-register.
@@ -56,11 +61,9 @@ const ChatbotMount = () => {
return () => {
cancelled = true;
};
- }, []);
-
- useEffect(() => fetchSettings(), [fetchSettings]);
+ }, [applySettings]);
- useEffect(() => subscribeToExtensionSettings(fetchSettings), [fetchSettings]);
+ useEffect(() => subscribeToExtensionSettings(applySettings), [applySettings]);
const registryVersion = useSyncExternalStore(
subscribeToRegistry,
diff --git a/superset-frontend/src/core/extensions/index.ts b/superset-frontend/src/core/extensions/index.ts
index 72cfd450fce..7ca3e7e7d6c 100644
--- a/superset-frontend/src/core/extensions/index.ts
+++ b/superset-frontend/src/core/extensions/index.ts
@@ -30,14 +30,21 @@ export const extensions: typeof extensionsApi = {
getAllExtensions,
};
-const settingsListeners = new Set<() => void>();
+type ExtensionSettings = {
+ active_chatbot_id: string | null;
+ enabled: Record<string, boolean>;
+};
+
+const settingsListeners = new Set<(settings: ExtensionSettings) => void>();
-export const notifyExtensionSettingsChanged = (): void => {
- settingsListeners.forEach(fn => fn());
+export const notifyExtensionSettingsChanged = (
+ settings: ExtensionSettings,
+): void => {
+ settingsListeners.forEach(fn => fn(settings));
};
export const subscribeToExtensionSettings = (
- listener: () => void,
+ listener: (settings: ExtensionSettings) => void,
): (() => void) => {
settingsListeners.add(listener);
return () => settingsListeners.delete(listener);
diff --git a/superset-frontend/src/extensions/ExtensionsList.tsx b/superset-frontend/src/extensions/ExtensionsList.tsx
index 9e8df4c43f8..9660ab5b867 100644
--- a/superset-frontend/src/extensions/ExtensionsList.tsx
+++ b/superset-frontend/src/extensions/ExtensionsList.tsx
@@ -110,7 +110,7 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
})
.then(({ json }) => {
setSettings(json.result);
- notifyExtensionSettingsChanged();
+ notifyExtensionSettingsChanged(json.result);
addSuccessToast(t('Settings saved.'));
})
.catch(() => addDangerToast(t('Failed to save extension settings.')));