This is an automated email from the ASF dual-hosted git repository. EnxDev pushed a commit to branch enxdev/chat-prototype in repository https://gitbox.apache.org/repos/asf/superset.git
commit e2e679ae23e40dca0625933b205ad46353d7fb4d Author: Enzo Martellucci <[email protected]> AuthorDate: Tue May 26 16:09:28 2026 +0200 feat(extensions): define the chatbot entry point in the frontend API Adds getActiveChatbot() singleton resolver (first-to-register + admin active_chatbot_id + enabled-flag enforcement), subscribeToLocation() for reactive re-resolution, and ChatbotMount — the fixed bottom-right slot that persists across routes and renders the active chatbot. Co-Authored-By: Claude Sonnet 4.6 <[email protected]> --- .../components/ChatbotMount/ChatbotMount.test.tsx | 91 ++++++++++++++++++++ .../src/components/ChatbotMount/index.tsx | 84 +++++++++++++++++++ superset-frontend/src/core/chatbot/index.test.ts | 96 ++++++++++++++++++++++ superset-frontend/src/core/chatbot/index.ts | 87 ++++++++++++++++++++ superset-frontend/src/core/views/index.test.ts | 63 +++++++++++++- superset-frontend/src/core/views/index.ts | 46 +++++++++++ 6 files changed, 466 insertions(+), 1 deletion(-) diff --git a/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx new file mode 100644 index 00000000000..ba546005398 --- /dev/null +++ b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx @@ -0,0 +1,91 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { render, screen } from 'spec/helpers/testing-library'; +import { views } from 'src/core'; +import { CHATBOT_LOCATION } from 'src/views/contributions'; +import ChatbotMount from '.'; + +const disposables: Array<{ dispose: () => void }> = []; + +afterEach(() => { + disposables.forEach(d => d.dispose()); + disposables.length = 0; +}); + +test('renders nothing when no chatbot extension is registered', () => { + render(<ChatbotMount />); + + expect(screen.queryByTestId('chatbot-mount')).not.toBeInTheDocument(); +}); + +test('renders the registered chatbot inside the fixed mount slot', () => { + const provider = () => React.createElement('div', null, 'My Chatbot Bubble'); + disposables.push( + views.registerView( + { id: 'superset.chatbot', name: 'Superset Chatbot' }, + CHATBOT_LOCATION, + provider, + ), + ); + + render(<ChatbotMount />); + + expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument(); + expect(screen.getByText('My Chatbot Bubble')).toBeInTheDocument(); +}); + +test('renders only the first-to-register chatbot when several are installed', () => { + const firstProvider = () => React.createElement('div', null, 'First Bubble'); + const secondProvider = () => + React.createElement('div', null, 'Second Bubble'); + disposables.push( + views.registerView( + { id: 'first.chatbot', name: 'First Chatbot' }, + CHATBOT_LOCATION, + firstProvider, + ), + views.registerView( + { id: 'second.chatbot', name: 'Second Chatbot' }, + CHATBOT_LOCATION, + secondProvider, + ), + ); + + render(<ChatbotMount />); + + expect(screen.getByText('First Bubble')).toBeInTheDocument(); + expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument(); +}); + +test('isolates a failing chatbot so it does not crash the host', () => { + const FailingChatbot = () => { + throw new Error('chatbot blew up'); + }; + disposables.push( + views.registerView( + { id: 'superset.chatbot', name: 'Superset Chatbot' }, + CHATBOT_LOCATION, + () => React.createElement(FailingChatbot), + ), + ); + + // The host-owned error boundary catches the failure; render does not throw. + expect(() => render(<ChatbotMount />)).not.toThrow(); +}); diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx b/superset-frontend/src/components/ChatbotMount/index.tsx new file mode 100644 index 00000000000..68e692c57a7 --- /dev/null +++ b/superset-frontend/src/components/ChatbotMount/index.tsx @@ -0,0 +1,84 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState, useEffect } from 'react'; +import { SupersetClient } from '@superset-ui/core'; +import { css, useTheme } from '@apache-superset/core/theme'; +import { ErrorBoundary } from 'src/components/ErrorBoundary'; +import { getActiveChatbot } from 'src/core/chatbot'; +import { subscribeToLocation } from 'src/core/views'; +import { CHATBOT_LOCATION } from 'src/views/contributions'; + +const CHATBOT_EDGE_MARGIN = 24; + +const ChatbotMount = () => { + const theme = useTheme(); + const [adminSelectedId, setAdminSelectedId] = useState<string | null>(null); + const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({}); + const [activeChatbot, setActiveChatbot] = useState(() => + getActiveChatbot(null, {}), + ); + + useEffect(() => { + let cancelled = false; + SupersetClient.get({ endpoint: '/api/v1/extensions/settings' }) + .then(({ json }) => { + if (cancelled) return; + const id = json.result?.active_chatbot_id ?? null; + const enabled: Record<string, boolean> = json.result?.enabled ?? {}; + setAdminSelectedId(id); + setEnabledMap(enabled); + setActiveChatbot(getActiveChatbot(id, enabled)); + }) + .catch(() => { + // Settings fetch failure is non-fatal — fall back to first-to-register. + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect( + () => + subscribeToLocation(CHATBOT_LOCATION, () => + setActiveChatbot(getActiveChatbot(adminSelectedId, enabledMap)), + ), + [adminSelectedId, enabledMap], + ); + + if (!activeChatbot) { + return null; + } + + return ( + <div + data-test="chatbot-mount" + css={css` + position: fixed; + right: ${CHATBOT_EDGE_MARGIN}px; + bottom: ${CHATBOT_EDGE_MARGIN}px; + /* Above dashboard content and the toast layer, below modal dialogs. */ + z-index: ${theme.zIndexPopupBase + 2}; + `} + > + <ErrorBoundary>{activeChatbot.provider()}</ErrorBoundary> + </div> + ); +}; + +export default ChatbotMount; diff --git a/superset-frontend/src/core/chatbot/index.test.ts b/superset-frontend/src/core/chatbot/index.test.ts new file mode 100644 index 00000000000..0b8d6f2f6ec --- /dev/null +++ b/superset-frontend/src/core/chatbot/index.test.ts @@ -0,0 +1,96 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { views } from 'src/core/views'; +import { CHATBOT_LOCATION } from 'src/views/contributions'; +import { getActiveChatbot } from './index'; + +const disposables: Array<{ dispose: () => void }> = []; + +afterEach(() => { + disposables.forEach(d => d.dispose()); + disposables.length = 0; +}); + +test('getActiveChatbot returns undefined when no chatbot is registered', () => { + expect(getActiveChatbot()).toBeUndefined(); +}); + +test('getActiveChatbot resolves the single registered chatbot', () => { + const provider = () => React.createElement('div', null, 'Chatbot'); + disposables.push( + views.registerView( + { id: 'superset.chatbot', name: 'Superset Chatbot' }, + CHATBOT_LOCATION, + provider, + ), + ); + + const active = getActiveChatbot(); + expect(active).toEqual({ id: 'superset.chatbot', provider }); +}); + +test('getActiveChatbot picks the first-to-register when multiple are installed', () => { + const firstProvider = () => React.createElement('div', null, 'First'); + const secondProvider = () => React.createElement('div', null, 'Second'); + disposables.push( + views.registerView( + { id: 'first.chatbot', name: 'First Chatbot' }, + CHATBOT_LOCATION, + firstProvider, + ), + views.registerView( + { id: 'second.chatbot', name: 'Second Chatbot' }, + CHATBOT_LOCATION, + secondProvider, + ), + ); + + const active = getActiveChatbot(); + expect(active?.id).toBe('first.chatbot'); + expect(active?.provider).toBe(firstProvider); +}); + +test('getActiveChatbot ignores views registered at other locations', () => { + const provider = () => React.createElement('div', null, 'Panel'); + disposables.push( + views.registerView( + { id: 'some.panel', name: 'Some Panel' }, + 'sqllab.panels', + provider, + ), + ); + + expect(getActiveChatbot()).toBeUndefined(); +}); + +test('getActiveChatbot stops resolving a chatbot once it is disposed', () => { + const provider = () => React.createElement('div', null, 'Chatbot'); + const disposable = views.registerView( + { id: 'superset.chatbot', name: 'Superset Chatbot' }, + CHATBOT_LOCATION, + provider, + ); + + expect(getActiveChatbot()?.id).toBe('superset.chatbot'); + + disposable.dispose(); + + expect(getActiveChatbot()).toBeUndefined(); +}); diff --git a/superset-frontend/src/core/chatbot/index.ts b/superset-frontend/src/core/chatbot/index.ts new file mode 100644 index 00000000000..4cb92b4bc9a --- /dev/null +++ b/superset-frontend/src/core/chatbot/index.ts @@ -0,0 +1,87 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/** + * @fileoverview Host-internal resolver for the exclusive `superset.chatbot` + * contribution area. + * + * `superset.chatbot` is a singleton contribution area: multiple chatbot + * extensions may register a view there, but the host renders exactly one. + * This module owns the host-side selection policy. + * + * This is host-internal infrastructure — it is NOT part of the public + * `@apache-superset/core` API. Extensions register via the public + * `views.registerView()`; only the host resolves which one is active. + */ + +import { ReactElement } from 'react'; +import { CHATBOT_LOCATION } from 'src/views/contributions'; +import { getRegisteredViewIds, getViewProvider } from 'src/core/views'; + +/** + * The resolved active chatbot: a view id paired with its renderable provider. + */ +export interface ActiveChatbot { + /** The registered view id of the selected chatbot. */ + id: string; + /** The provider that renders the chatbot's React element. */ + provider: () => ReactElement; +} + +/** + * Resolves which single chatbot extension is currently active. + * + * Selection policy: + * - If no chatbot is registered, returns `undefined` — the corner stays empty. + * - Disabled chatbots (per `enabledMap`) are excluded before selection. + * - If `adminSelectedId` matches an enabled registered chatbot, that one wins. + * - Otherwise the first enabled chatbot in registration order is used as a fallback. + * + * @param adminSelectedId The id stored in the admin "Default chatbot" setting, if any. + * @param enabledMap Per-extension enabled flags from the admin settings API. + * @returns The active chatbot's id and provider, or `undefined` if none. + */ +export const getActiveChatbot = ( + adminSelectedId?: string | null, + enabledMap?: Record<string, boolean>, +): ActiveChatbot | undefined => { + const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION); + if (registeredIds.length === 0) { + return undefined; + } + + const candidates = enabledMap + ? registeredIds.filter(id => enabledMap[id] !== false) + : registeredIds; + + if (candidates.length === 0) { + return undefined; + } + + const selectedId = + adminSelectedId && candidates.includes(adminSelectedId) + ? adminSelectedId + : candidates[0]; + + const provider = getViewProvider(CHATBOT_LOCATION, selectedId); + if (!provider) { + return undefined; + } + + return { id: selectedId, provider }; +}; diff --git a/superset-frontend/src/core/views/index.test.ts b/superset-frontend/src/core/views/index.test.ts index d98a05b7f4f..16413b109a0 100644 --- a/superset-frontend/src/core/views/index.test.ts +++ b/superset-frontend/src/core/views/index.test.ts @@ -17,7 +17,12 @@ * under the License. */ import React from 'react'; -import { views, resolveView } from './index'; +import { + views, + resolveView, + getViewProvider, + getRegisteredViewIds, +} from './index'; const disposables: Array<{ dispose: () => void }> = []; @@ -110,3 +115,59 @@ test('dispose removes the view registration', () => { expect(views.getViews('sqllab.panels')).toBeUndefined(); }); + +test('getViewProvider returns the registered provider for a matching location', () => { + const provider = () => React.createElement('div', null, 'Test'); + disposables.push( + views.registerView( + { id: 'test.provider', name: 'Test Provider' }, + 'superset.chatbot', + provider, + ), + ); + + expect(getViewProvider('superset.chatbot', 'test.provider')).toBe(provider); +}); + +test('getViewProvider returns undefined when the location does not match', () => { + const provider = () => React.createElement('div', null, 'Test'); + disposables.push( + views.registerView( + { id: 'test.provider', name: 'Test Provider' }, + 'sqllab.panels', + provider, + ), + ); + + // Registered, but at a different location. + expect(getViewProvider('superset.chatbot', 'test.provider')).toBeUndefined(); +}); + +test('getViewProvider returns undefined for an unknown id', () => { + expect(getViewProvider('superset.chatbot', 'nonexistent')).toBeUndefined(); +}); + +test('getRegisteredViewIds returns ids in registration order', () => { + const provider = () => React.createElement('div', null, 'Test'); + disposables.push( + views.registerView( + { id: 'first.chatbot', name: 'First' }, + 'superset.chatbot', + provider, + ), + views.registerView( + { id: 'second.chatbot', name: 'Second' }, + 'superset.chatbot', + provider, + ), + ); + + expect(getRegisteredViewIds('superset.chatbot')).toEqual([ + 'first.chatbot', + 'second.chatbot', + ]); +}); + +test('getRegisteredViewIds returns an empty array for an unused location', () => { + expect(getRegisteredViewIds('superset.chatbot')).toEqual([]); +}); diff --git a/superset-frontend/src/core/views/index.ts b/superset-frontend/src/core/views/index.ts index 5bed7d10910..41c2a545ad5 100644 --- a/superset-frontend/src/core/views/index.ts +++ b/superset-frontend/src/core/views/index.ts @@ -39,6 +39,27 @@ const viewRegistry: Map< const locationIndex: Map<string, Set<string>> = new Map(); +/** Listeners notified whenever a view is registered or unregistered at a location. */ +const locationListeners: Map<string, Set<() => void>> = new Map(); + +const notifyListeners = (location: string) => { + locationListeners.get(location)?.forEach(fn => fn()); +}; + +/** + * Subscribe to registration changes at a specific location. + * Returns an unsubscribe function. + */ +export const subscribeToLocation = ( + location: string, + listener: () => void, +): (() => void) => { + const listeners = locationListeners.get(location) ?? new Set(); + listeners.add(listener); + locationListeners.set(location, listeners); + return () => listeners.delete(listener); +}; + const registerView: typeof viewsApi.registerView = ( view: View, location: string, @@ -52,9 +73,12 @@ const registerView: typeof viewsApi.registerView = ( ids.add(id); locationIndex.set(location, ids); + notifyListeners(location); + return new Disposable(() => { viewRegistry.delete(id); locationIndex.get(location)?.delete(id); + notifyListeners(location); }); }; @@ -77,6 +101,28 @@ const getViews: typeof viewsApi.getViews = ( .filter((c): c is View => !!c); }; +/** + * Host-internal: returns the provider for a registered view id at a location. + * Not part of the public `@apache-superset/core` API — `getViews` stays + * descriptor-only so extensions cannot render each other's views directly. + */ +export const getViewProvider = ( + location: string, + id: string, +): (() => ReactElement) | undefined => { + const entry = viewRegistry.get(id); + if (entry?.location !== location) { + return undefined; + } + return entry.provider; +}; + +/** Host-internal: view ids at a location in registration order. */ +export const getRegisteredViewIds = (location: string): string[] => { + const ids = locationIndex.get(location); + return ids ? Array.from(ids) : []; +}; + export const views: typeof viewsApi = { registerView, getViews,
