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 6a07ad2369cf9d761ef64f6da5dce1caa653474f Author: Enzo Martellucci <[email protected]> AuthorDate: Tue May 26 10:18:41 2026 +0200 feat(extensions): chatbot mount point, singleton resolver, and admin settings (SIP P1+P2) --- .../superset-core/src/contributions/index.ts | 3 - .../src/components/ChatbotMount/index.tsx | 44 +++++----- superset-frontend/src/core/chatbot/index.ts | 25 +++--- superset-frontend/src/core/views/index.ts | 33 +------- .../src/extensions/ExtensionsList.tsx | 98 ++++++++++++++++++++-- superset/extensions/api.py | 49 ++++++++++- superset/extensions/settings.py | 55 ++++++++++++ ...25_00-00_b2c3d4e5f6a7_add_extension_settings.py | 47 +++++++++++ superset/models/core.py | 16 ++++ 9 files changed, 293 insertions(+), 77 deletions(-) diff --git a/superset-frontend/packages/superset-core/src/contributions/index.ts b/superset-frontend/packages/superset-core/src/contributions/index.ts index 42dddd90647..787f10261b5 100644 --- a/superset-frontend/packages/superset-core/src/contributions/index.ts +++ b/superset-frontend/packages/superset-core/src/contributions/index.ts @@ -18,11 +18,8 @@ */ import { View } from '../views'; -import type { ChatbotView } from '../views'; import { Menu } from '../menus'; -export type { ChatbotView }; - export type SqlLabLocation = | 'leftSidebar' | 'rightSidebar' diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx b/superset-frontend/src/components/ChatbotMount/index.tsx index 4efb9aa47e9..a88291c56fc 100644 --- a/superset-frontend/src/components/ChatbotMount/index.tsx +++ b/superset-frontend/src/components/ChatbotMount/index.tsx @@ -16,21 +16,8 @@ * 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 { 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'; @@ -39,24 +26,31 @@ import { CHATBOT_LOCATION } from 'src/views/contributions'; 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 persists across routes. - * Re-resolves when the chatbot registry changes (extension activated or - * deactivated at runtime via the P1.A lifecycle contract). - * Renders null when no chatbot extension is registered. - */ const ChatbotMount = () => { const theme = useTheme(); - const [activeChatbot, setActiveChatbot] = useState(getActiveChatbot); + const [adminSelectedId, setAdminSelectedId] = useState<string | null>(null); + const [activeChatbot, setActiveChatbot] = useState(() => + getActiveChatbot(null), + ); + + useEffect(() => { + SupersetClient.get({ endpoint: '/api/v1/extensions/settings' }) + .then(({ json }) => { + const id = json.result?.active_chatbot_id ?? null; + setAdminSelectedId(id); + setActiveChatbot(getActiveChatbot(id)); + }) + .catch(() => { + // Settings fetch failure is non-fatal — fall back to first-to-register. + }); + }, []); useEffect( () => subscribeToLocation(CHATBOT_LOCATION, () => - setActiveChatbot(getActiveChatbot()), + setActiveChatbot(getActiveChatbot(adminSelectedId)), ), - [], + [adminSelectedId], ); if (!activeChatbot) { diff --git a/superset-frontend/src/core/chatbot/index.ts b/superset-frontend/src/core/chatbot/index.ts index 4598445ce6f..4b1d1bb00a7 100644 --- a/superset-frontend/src/core/chatbot/index.ts +++ b/superset-frontend/src/core/chatbot/index.ts @@ -46,28 +46,27 @@ export interface ActiveChatbot { /** * Resolves which single chatbot extension is currently active. * - * Selection policy (P1): + * Selection policy: * - 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. + * - If `adminSelectedId` is provided and matches a registered chatbot, that one wins. + * - Otherwise the first-to-register chatbot is used as a fallback. * + * @param adminSelectedId The id stored in the admin "Default chatbot" setting, if any. * @returns The active chatbot's id and provider, or `undefined` if none. */ -export const getActiveChatbot = (): ActiveChatbot | undefined => { +export const getActiveChatbot = ( + adminSelectedId?: string | null, +): 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 selectedId = + adminSelectedId && registeredIds.includes(adminSelectedId) + ? adminSelectedId + : registeredIds[0]; + const provider = getViewProvider(CHATBOT_LOCATION, selectedId); if (!provider) { return undefined; diff --git a/superset-frontend/src/core/views/index.ts b/superset-frontend/src/core/views/index.ts index 7925ef3d2d4..41c2a545ad5 100644 --- a/superset-frontend/src/core/views/index.ts +++ b/superset-frontend/src/core/views/index.ts @@ -102,23 +102,9 @@ const getViews: typeof viewsApi.getViews = ( }; /** - * 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. + * 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, @@ -131,18 +117,7 @@ export const getViewProvider = ( 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. - */ +/** 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) : []; diff --git a/superset-frontend/src/extensions/ExtensionsList.tsx b/superset-frontend/src/extensions/ExtensionsList.tsx index 6f4f9c2f56d..a2b3a953382 100644 --- a/superset-frontend/src/extensions/ExtensionsList.tsx +++ b/superset-frontend/src/extensions/ExtensionsList.tsx @@ -17,20 +17,31 @@ * under the License. */ import { t } from '@apache-superset/core/translation'; -import { FunctionComponent, useMemo } from 'react'; +import { css } from '@apache-superset/core/theme'; +import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'; +import { SupersetClient } from '@superset-ui/core'; +import { Select } from '@superset-ui/core/components'; +import { Switch } from '@superset-ui/core/components/Switch'; import { useListViewResource } from 'src/views/CRUD/hooks'; import { ListView } from 'src/components'; import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; import withToasts from 'src/components/MessageToasts/withToasts'; +import { CHATBOT_LOCATION } from 'src/views/contributions'; +import { getRegisteredViewIds } from 'src/core/views'; const PAGE_SIZE = 25; type Extension = { - id: number; + id: string; name: string; enabled: boolean; }; +type ExtensionSettings = { + active_chatbot_id: string | null; + enabled: Record<string, boolean>; +}; + interface ExtensionsListProps { addDangerToast: (msg: string) => void; addSuccessToast: (msg: string) => void; @@ -50,6 +61,45 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({ addDangerToast, ); + const [settings, setSettings] = useState<ExtensionSettings>({ + active_chatbot_id: null, + enabled: {}, + }); + + useEffect(() => { + SupersetClient.get({ endpoint: '/api/v1/extensions/settings' }) + .then(({ json }) => setSettings(json.result)) + .catch(() => addDangerToast(t('Failed to load extension settings.'))); + }, [addDangerToast]); + + const saveSettings = useCallback( + (patch: Partial<ExtensionSettings>) => { + const next = { ...settings, ...patch }; + SupersetClient.put({ + endpoint: '/api/v1/extensions/settings', + jsonPayload: next, + }) + .then(({ json }) => { + setSettings(json.result); + addSuccessToast(t('Settings saved.')); + }) + .catch(() => addDangerToast(t('Failed to save extension settings.'))); + }, + [settings, addDangerToast, addSuccessToast], + ); + + const toggleEnabled = useCallback( + (extensionId: string, enabled: boolean) => { + saveSettings({ enabled: { ...settings.enabled, [extensionId]: enabled } }); + }, + [settings, saveSettings], + ); + + const chatbotExtensions = useMemo(() => { + const chatbotIds = new Set(getRegisteredViewIds(CHATBOT_LOCATION)); + return resourceCollection.filter(ext => chatbotIds.has(ext.id)); + }, [resourceCollection]); + const columns = useMemo( () => [ { @@ -58,15 +108,34 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({ size: 'lg', id: 'name', Cell: ({ - row: { - original: { name }, - }, + row: { original: { name } }, }: any) => name, }, + { + Header: t('Enabled'), + accessor: 'enabled', + size: 'sm', + id: 'enabled', + Cell: ({ + row: { original: { id, enabled } }, + }: any) => ( + <Switch + data-test="toggle-enabled" + checked={settings.enabled[id] ?? enabled} + onClick={(checked: boolean) => toggleEnabled(id, checked)} + size="small" + /> + ), + }, ], - [loading], // We need to monitor loading to avoid stale state in actions + [loading, settings, toggleEnabled], ); + const chatbotOptions = chatbotExtensions.map(ext => ({ + label: ext.name, + value: ext.id, + })); + const menuData: SubMenuProps = { activeChild: 'Extensions', name: t('Extensions'), @@ -76,6 +145,23 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({ return ( <> <SubMenu {...menuData} /> + {chatbotOptions.length > 1 && ( + <div style={{ padding: '16px 24px' }}> + <label htmlFor="chatbot-select" style={{ marginRight: 8 }}> + {t('Default chatbot')} + </label> + <Select + allowClear + options={chatbotOptions} + value={settings.active_chatbot_id ?? undefined} + onChange={value => + saveSettings({ active_chatbot_id: (value as string) ?? null }) + } + placeholder={t('First registered (automatic)')} + css={css`width: 280px;`} + /> + </div> + )} <ListView<Extension> columns={columns} count={resourceCount} diff --git a/superset/extensions/api.py b/superset/extensions/api.py index b1b5734979e..f39db3686ed 100644 --- a/superset/extensions/api.py +++ b/superset/extensions/api.py @@ -18,10 +18,14 @@ import mimetypes from io import BytesIO from typing import Any -from flask import send_file +from flask import request, send_file from flask.wrappers import Response from flask_appbuilder.api import BaseApi, expose, protect, safe +from superset.extensions.settings import ( + get_extension_settings, + update_extension_settings, +) from superset.extensions.utils import ( build_extension_data, get_extensions, @@ -167,6 +171,49 @@ class ExtensionsRestApi(BaseApi): extension_data = build_extension_data(extension) return self.response(200, result=extension_data) + @protect() + @safe + @expose("/settings", methods=("GET",)) + def get_settings(self, **kwargs: Any) -> Response: + """Get global extension admin settings. + --- + get: + summary: Get extension admin settings (active chatbot, enabled flags). + responses: + 200: + description: Extension settings + """ + return self.response(200, result=get_extension_settings()) + + @protect() + @safe + @expose("/settings", methods=("PUT",)) + def put_settings(self, **kwargs: Any) -> Response: + """Update global extension admin settings. + --- + put: + summary: Update extension admin settings. + requestBody: + content: + application/json: + schema: + type: object + properties: + active_chatbot_id: + type: string + nullable: true + enabled: + type: object + additionalProperties: + type: boolean + responses: + 200: + description: Updated settings + """ + body = request.get_json(silent=True) or {} + result = update_extension_settings(body) + return self.response(200, result=result) + @protect() @safe @expose("/<publisher>/<name>/<file>", methods=("GET",)) diff --git a/superset/extensions/settings.py b/superset/extensions/settings.py new file mode 100644 index 00000000000..30189df8e28 --- /dev/null +++ b/superset/extensions/settings.py @@ -0,0 +1,55 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Admin settings persistence for extensions (active chatbot, enable/disable).""" + +from typing import Any + +from superset import db +from superset.models.core import ExtensionEnabled, ExtensionSettings + +_SETTINGS_ROW_ID = 1 + + +def get_extension_settings() -> dict[str, Any]: + row = db.session.get(ExtensionSettings, _SETTINGS_ROW_ID) + enabled_rows = db.session.query(ExtensionEnabled).all() + return { + "active_chatbot_id": row.active_chatbot_id if row else None, + "enabled": {r.extension_id: r.enabled for r in enabled_rows}, + } + + +def update_extension_settings(body: dict[str, Any]) -> dict[str, Any]: + row = db.session.get(ExtensionSettings, _SETTINGS_ROW_ID) + if row is None: + row = ExtensionSettings(id=_SETTINGS_ROW_ID) + db.session.add(row) + + if "active_chatbot_id" in body: + row.active_chatbot_id = body["active_chatbot_id"] or None + + if "enabled" in body: + for extension_id, enabled in body["enabled"].items(): + flag = db.session.get(ExtensionEnabled, extension_id) + if flag is None: + flag = ExtensionEnabled(extension_id=extension_id) + db.session.add(flag) + flag.enabled = bool(enabled) + + db.session.commit() + return get_extension_settings() diff --git a/superset/migrations/versions/2026-05-25_00-00_b2c3d4e5f6a7_add_extension_settings.py b/superset/migrations/versions/2026-05-25_00-00_b2c3d4e5f6a7_add_extension_settings.py new file mode 100644 index 00000000000..9773a5104aa --- /dev/null +++ b/superset/migrations/versions/2026-05-25_00-00_b2c3d4e5f6a7_add_extension_settings.py @@ -0,0 +1,47 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""Add extension_settings table for chatbot admin selection and enable/disable. + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-05-25 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +revision = "b2c3d4e5f6a7" +down_revision = "a1b2c3d4e5f6" + + +def upgrade() -> None: + op.create_table( + "extension_settings", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("active_chatbot_id", sa.String(250), nullable=True), + ) + op.create_table( + "extension_enabled", + sa.Column("extension_id", sa.String(250), primary_key=True), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"), + ) + + +def downgrade() -> None: + op.drop_table("extension_enabled") + op.drop_table("extension_settings") diff --git a/superset/models/core.py b/superset/models/core.py index 1f99630ab3d..35a36f588d5 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -108,6 +108,22 @@ class KeyValue(Model): # pylint: disable=too-few-public-methods value = Column(utils.MediumText(), nullable=False) +class ExtensionSettings(Model): # pylint: disable=too-few-public-methods + """Global admin settings for extensions (singleton row, id=1).""" + + __tablename__ = "extension_settings" + id = Column(Integer, primary_key=True) + active_chatbot_id = Column(String(250), nullable=True) + + +class ExtensionEnabled(Model): # pylint: disable=too-few-public-methods + """Per-extension enable/disable flag.""" + + __tablename__ = "extension_enabled" + extension_id = Column(String(250), primary_key=True) + enabled = Column(Boolean, nullable=False, default=True) + + class CssTemplate(AuditMixinNullable, UUIDMixin, Model): """CSS templates for dashboards"""
