This is an automated email from the ASF dual-hosted git repository. EnxDev pushed a commit to branch chat-prototype in repository https://gitbox.apache.org/repos/asf/superset.git
commit 7b418becc79b2059ddc4ecb093805062e8d71566 Author: Enzo Martellucci <[email protected]> AuthorDate: Mon May 25 15:04:36 2026 +0200 feat(extensions): add superset.chatbot contribution point (SIP P1.1) - Add `app` scope and `AppLocation` type to `ViewContributions` manifest schema - Add host-internal `getViewProvider` and `getRegisteredViewIds` accessors to the views registry - Add `getActiveChatbot` resolver with first-to-register fallback policy - Mount `ChatbotMount` in the app shell (fixed bottom-right, persists across routes --- .../superset-core/src/contributions/index.ts | 51 +----------- .../components/ChatbotMount/ChatbotMount.test.tsx | 91 ++++++++++++++++++++ .../src/components/ChatbotMount/index.tsx | 81 ++++++++++++++++++ superset-frontend/src/core/chatbot/index.test.ts | 96 ++++++++++++++++++++++ superset-frontend/src/core/chatbot/index.ts | 77 +++++++++++++++++ superset-frontend/src/core/views/index.test.ts | 63 +++++++++++++- superset-frontend/src/core/views/index.ts | 47 +++++++++++ superset-frontend/src/views/App.tsx | 8 ++ superset-frontend/src/views/contributions.ts | 31 +++++++ 9 files changed, 497 insertions(+), 48 deletions(-) diff --git a/superset-frontend/packages/superset-core/src/contributions/index.ts b/superset-frontend/packages/superset-core/src/contributions/index.ts index faccbb305dc..787f10261b5 100644 --- a/superset-frontend/packages/superset-core/src/contributions/index.ts +++ b/superset-frontend/packages/superset-core/src/contributions/index.ts @@ -17,23 +17,9 @@ * under the License. */ -/** - * @fileoverview Manifest schema for Superset extension contributions. - * - * This module defines the aggregate interfaces used by the extension.json - * manifest and the `superset-extensions` build command. Individual metadata - * types are defined in their respective namespace modules (commands, views, - * menus, editors) and re-exported here for the manifest schema. - */ - -import { Command } from '../commands'; import { View } from '../views'; import { Menu } from '../menus'; -import { Editor } from '../editors'; -/** - * Valid locations within SQL Lab. - */ export type SqlLabLocation = | 'leftSidebar' | 'rightSidebar' @@ -43,43 +29,14 @@ export type SqlLabLocation = | 'results' | 'queryHistory'; -/** - * Nested structure for view contributions by scope and location. - * @example - * { - * sqllab: { - * panels: [{ id: "my-ext.panel", name: "My Panel" }], - * leftSidebar: [{ id: "my-ext.sidebar", name: "My Sidebar" }] - * } - * } - */ +/** Valid locations within the app shell (persist across all routes). */ +export type AppLocation = 'chatbot'; + export interface ViewContributions { sqllab?: Partial<Record<SqlLabLocation, View[]>>; + app?: Partial<Record<AppLocation, View[]>>; } -/** - * Nested structure for menu contributions by scope and location. - * @example - * { - * sqllab: { - * editor: { primary: [...], secondary: [...] } - * } - * } - */ export interface MenuContributions { sqllab?: Partial<Record<SqlLabLocation, Menu>>; } - -/** - * Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module. - */ -export interface Contributions { - /** List of commands. */ - commands: Command[]; - /** Nested mapping of menu contributions by scope and location. */ - menus: MenuContributions; - /** Nested mapping of view contributions by scope and location. */ - views: ViewContributions; - /** List of editors. */ - editors?: Editor[]; -} 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..17d16c9e9f3 --- /dev/null +++ b/superset-frontend/src/components/ChatbotMount/index.tsx @@ -0,0 +1,81 @@ +/** + * 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 mount point for the singleton `superset.chatbot` + * contribution area. + * + * The host owns the slot: a fixed bottom-right anchor that persists across all + * routes, with a managed z-index. The extension owns everything rendered + * inside it — the collapsed bubble, the expanded panel, all open/close state, + * animations, and behavior (SIP §3.2 "Component contract"). + * + * Singleton resolution (which of possibly several registered chatbots renders) + * is delegated to `getActiveChatbot`. If no chatbot extension is registered, + * this component renders nothing and the corner stays empty. + */ + +import { css, useTheme } from '@apache-superset/core/theme'; +import { ErrorBoundary } from 'src/components/ErrorBoundary'; +import { getActiveChatbot } from 'src/core/chatbot'; + +/** + * Margin from the viewport edges for the chatbot anchor, per SIP §3.2. + */ +const CHATBOT_EDGE_MARGIN = 24; + +/** + * Renders the active chatbot extension into a fixed bottom-right slot. + * + * Mounted once at the app root so the bubble is available consistently across + * routes. Renders `null` when no chatbot extension is registered. + * + * Resolution happens once at mount: chatbot extensions are registered + * synchronously during extension loading, before this component renders (it + * sits inside `ExtensionsStartup`). Reacting to chatbots that register or + * unregister at runtime — deactivate / uninstall / replace — is the extension + * lifecycle work tracked separately under SIP §8 phase P1. + */ +const ChatbotMount = () => { + const theme = useTheme(); + const activeChatbot = getActiveChatbot(); + + 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 + * (toasts sit at zIndexPopupBase + 1), 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..4598445ce6f --- /dev/null +++ b/superset-frontend/src/core/chatbot/index.ts @@ -0,0 +1,77 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/** + * @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 (P1): + * - If no chatbot is registered, returns `undefined` — the corner stays empty. + * - If one or more chatbots are registered, the first one to register wins. + * + * `Set` preserves insertion order, so "first to register" is deterministic. + * + * This is the P1 fallback policy. P2 introduces an admin "Default chatbot" + * setting (SIP §4 option (c)); when that lands, the admin-selected id takes + * precedence here and this first-to-register behavior remains only as the + * fallback used when no admin setting is configured. + * + * @returns The active chatbot's id and provider, or `undefined` if none. + */ +export const getActiveChatbot = (): ActiveChatbot | undefined => { + const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION); + if (registeredIds.length === 0) { + return undefined; + } + + // Deterministic first-to-register fallback. P2 will consult the admin + // "Default chatbot" setting before this point. + const [selectedId] = registeredIds; + 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..6f29143d453 100644 --- a/superset-frontend/src/core/views/index.ts +++ b/superset-frontend/src/core/views/index.ts @@ -77,6 +77,53 @@ const getViews: typeof viewsApi.getViews = ( .filter((c): c is View => !!c); }; +/** + * Host-internal accessor that returns the registered `provider` for a view id + * at a given location. + * + * This is deliberately NOT part of the public `@apache-superset/core` `views` + * API. The public `getViews` returns descriptors only (`id`/`name`/...), so an + * extension can discover what is registered but cannot obtain — and therefore + * cannot render — another extension's view outside the host's mount point, + * lifecycle, and fault-isolation boundary. + * + * The host uses this accessor to render exclusive (singleton) contribution + * areas such as `superset.chatbot`, where it must enumerate the candidates and + * then render exactly one. See `getActiveChatbot` in `src/core/chatbot`. + * + * @param location The contribution location (e.g. `superset.chatbot`). + * @param id The registered view id. + * @returns The provider function, or undefined if no matching view is + * registered at that location. + */ +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 accessor that returns the ordered list of view ids registered + * at a location, in registration order. + * + * Registration order is meaningful for exclusive locations: the host's + * deterministic fallback policy ("first to register wins") relies on it. + * Like {@link getViewProvider}, this is host-internal and not part of the + * public API. + * + * @param location The contribution location. + * @returns View ids in registration order, or an empty array if none. + */ +export const getRegisteredViewIds = (location: string): string[] => { + const ids = locationIndex.get(location); + return ids ? Array.from(ids) : []; +}; + export const views: typeof viewsApi = { registerView, getViews, diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 4f30a552e94..d84b111ca84 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -39,6 +39,7 @@ import setupCodeOverrides from 'src/setup/setupCodeOverrides'; import { logEvent } from 'src/logger/actions'; import { store } from 'src/views/store'; import ExtensionsStartup from 'src/extensions/ExtensionsStartup'; +import ChatbotMount from 'src/components/ChatbotMount'; import { RootContextProviders } from './RootContextProviders'; import { ScrollToTop } from './ScrollToTop'; @@ -112,6 +113,13 @@ const App = () => ( </Route> ))} </Switch> + {/* + The singleton chatbot bubble. Rendered as a sibling of the route + Switch — inside ExtensionsStartup so chatbot extensions have been + loaded and registered, but outside the Switch so the bubble persists + across route changes (SIP §3.2). + */} + <ChatbotMount /> </ExtensionsStartup> <ToastContainer /> </RootContextProviders> diff --git a/superset-frontend/src/views/contributions.ts b/superset-frontend/src/views/contributions.ts new file mode 100644 index 00000000000..ec075222b23 --- /dev/null +++ b/superset-frontend/src/views/contributions.ts @@ -0,0 +1,31 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +/** + * View locations for app-shell extension integration. + * + * These define locations that persist across all routes, mirroring the `app` + * scope of the `ViewContributions` manifest schema. + */ +export const AppViewLocations = { + app: { + chatbot: 'superset.chatbot', + }, +} as const; + +export const CHATBOT_LOCATION = AppViewLocations.app.chatbot;
