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 <[email protected]> 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 <[email protected]> --- .../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
