From 380e70060bb0693eb28a56e524a003f62ce16fa1 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com> Date: Mon, 8 Jun 2026 22:50:34 +0200 Subject: [PATCH 01/10] feat(extensions): define the superset.chatbot contribution point (#40439) --- superset-frontend/.eslintrc.js | 2 +- .../packages/superset-core/package.json | 16 + .../superset-core/src/common/index.ts | 49 ++ .../superset-core/src/contributions/index.ts | 4 + .../superset-core/src/dashboard/index.ts | 114 +++++ .../superset-core/src/dataset/index.ts | 73 +++ .../superset-core/src/explore/index.ts | 75 ++++ .../packages/superset-core/src/index.ts | 4 + .../superset-core/src/navigation/index.ts | 84 ++++ .../packages/superset-core/src/views/index.ts | 19 +- .../src/components/Select/Select.tsx | 6 +- .../tests/dashboard/clear-all-filters.spec.ts | 5 +- .../transformProps.test.ts | 4 +- .../src/components/Echart.tsx | 7 +- .../ChatbotMount/ChatbotMount.test.tsx | 114 +++++ .../src/components/ChatbotMount/index.tsx | 114 +++++ .../src/core/chatbot/index.test.ts | 167 +++++++ superset-frontend/src/core/chatbot/index.ts | 88 ++++ .../src/core/dashboard/index.test.ts | 220 +++++++++ superset-frontend/src/core/dashboard/index.ts | 123 ++++++ superset-frontend/src/core/dataset/index.ts | 63 +++ .../src/core/explore/index.test.ts | 157 +++++++ superset-frontend/src/core/explore/index.ts | 92 ++++ .../src/core/extensions/index.ts | 82 ++++ superset-frontend/src/core/index.ts | 4 + .../src/core/navigation/index.test.ts | 121 +++++ .../src/core/navigation/index.ts | 82 ++++ superset-frontend/src/core/sqlLab/index.ts | 22 +- .../src/core/sqlLab/sqlLab.test.ts | 39 ++ superset-frontend/src/core/utils.ts | 50 +++ .../src/core/views/index.test.ts | 63 ++- superset-frontend/src/core/views/index.ts | 54 ++- .../controls/CollectionControl/index.tsx | 4 +- .../src/extensions/ExtensionsList.test.tsx | 319 ++++++++++++-- .../src/extensions/ExtensionsList.tsx | 266 ++++++++++- .../src/extensions/ExtensionsLoader.test.ts | 104 +++++ .../src/extensions/ExtensionsLoader.ts | 116 ++++- .../src/extensions/ExtensionsStartup.test.tsx | 9 + .../src/extensions/ExtensionsStartup.tsx | 78 ++-- .../src/extensions/supersetGlobal.ts | 64 +++ .../EditDataset/EditDataset.test.tsx | 6 + .../datasets/AddDataset/EditDataset/index.tsx | 49 ++ .../features/roles/RoleListEditModal.test.tsx | 16 +- .../src/middleware/loggerMiddleware.ts | 7 +- superset-frontend/src/views/App.tsx | 9 + superset-frontend/src/views/contributions.ts | 31 ++ superset/commands/extension/__init__.py | 16 + .../commands/extension/settings/__init__.py | 16 + .../commands/extension/settings/exceptions.py | 27 ++ superset/commands/extension/settings/get.py | 29 ++ .../commands/extension/settings/update.py | 68 +++ superset/daos/extension.py | 77 ++++ superset/extensions/api.py | 316 ++++++++++++- superset/extensions/models.py | 41 ++ superset/extensions/schemas.py | 44 ++ superset/extensions/utils.py | 6 + ...-00_b2c3d4e5f6a7_add_extension_settings.py | 47 ++ superset/models/__init__.py | 2 + superset/models/core.py | 16 + tests/unit_tests/extensions/test_api.py | 417 ++++++++++++++++++ tests/unit_tests/extensions/test_settings.py | 371 ++++++++++++++++ 61 files changed, 4561 insertions(+), 127 deletions(-) create mode 100644 superset-frontend/packages/superset-core/src/dashboard/index.ts create mode 100644 superset-frontend/packages/superset-core/src/dataset/index.ts create mode 100644 superset-frontend/packages/superset-core/src/explore/index.ts create mode 100644 superset-frontend/packages/superset-core/src/navigation/index.ts create mode 100644 superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx create mode 100644 superset-frontend/src/components/ChatbotMount/index.tsx create mode 100644 superset-frontend/src/core/chatbot/index.test.ts create mode 100644 superset-frontend/src/core/chatbot/index.ts create mode 100644 superset-frontend/src/core/dashboard/index.test.ts create mode 100644 superset-frontend/src/core/dashboard/index.ts create mode 100644 superset-frontend/src/core/dataset/index.ts create mode 100644 superset-frontend/src/core/explore/index.test.ts create mode 100644 superset-frontend/src/core/explore/index.ts create mode 100644 superset-frontend/src/core/navigation/index.test.ts create mode 100644 superset-frontend/src/core/navigation/index.ts create mode 100644 superset-frontend/src/extensions/supersetGlobal.ts create mode 100644 superset-frontend/src/views/contributions.ts create mode 100644 superset/commands/extension/__init__.py create mode 100644 superset/commands/extension/settings/__init__.py create mode 100644 superset/commands/extension/settings/exceptions.py create mode 100644 superset/commands/extension/settings/get.py create mode 100644 superset/commands/extension/settings/update.py create mode 100644 superset/daos/extension.py create mode 100644 superset/extensions/models.py create mode 100644 superset/extensions/schemas.py create mode 100644 superset/migrations/versions/2026-05-25_00-00_b2c3d4e5f6a7_add_extension_settings.py create mode 100644 tests/unit_tests/extensions/test_api.py create mode 100644 tests/unit_tests/extensions/test_settings.py diff --git a/superset-frontend/.eslintrc.js b/superset-frontend/.eslintrc.js index 002d3f392521..d7b72a6d93ef 100644 --- a/superset-frontend/.eslintrc.js +++ b/superset-frontend/.eslintrc.js @@ -80,7 +80,7 @@ const restrictedImportsRules = { 'no-jest-mock-console': { name: 'jest-mock-console', message: 'Please use native Jest spies, i.e. jest.spyOn(console, "warn")', - } + }, }; module.exports = { diff --git a/superset-frontend/packages/superset-core/package.json b/superset-frontend/packages/superset-core/package.json index 468071eef753..698d69161d59 100644 --- a/superset-frontend/packages/superset-core/package.json +++ b/superset-frontend/packages/superset-core/package.json @@ -18,6 +18,22 @@ "types": "./lib/authentication/index.d.ts", "default": "./lib/authentication/index.js" }, + "./dashboard": { + "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" + }, + "./navigation": { + "types": "./lib/navigation/index.d.ts", + "default": "./lib/navigation/index.js" + }, "./commands": { "types": "./lib/commands/index.d.ts", "default": "./lib/commands/index.js" diff --git a/superset-frontend/packages/superset-core/src/common/index.ts b/superset-frontend/packages/superset-core/src/common/index.ts index cc399cf62751..8f8f5463c00c 100644 --- a/superset-frontend/packages/superset-core/src/common/index.ts +++ b/superset-frontend/packages/superset-core/src/common/index.ts @@ -213,6 +213,55 @@ export declare interface Event { (listener: (e: T) => any, thisArgs?: any): Disposable; } +/** + * Context handed to an extension's `activate` function. + * + * The extension binds the lifetime of everything it registers to this object by + * pushing the returned {@link Disposable}s onto `subscriptions`. Because the + * context is owned by the extension for as long as it is active, registrations + * performed asynchronously (after an `await`, in a timer, or in an event + * callback) are tracked just the same as synchronous ones — the host disposes + * the whole `subscriptions` array on deactivation. + * + * @example + * ```typescript + * export function activate(context: ExtensionContext) { + * context.subscriptions.push( + * commands.registerCommand('my_ext.hello', () => {}), + * ); + * } + * ``` + */ +export interface ExtensionContext { + /** + * Disposables to be cleaned up when the extension is deactivated. Push every + * {@link Disposable} returned by a `register*` call here. + */ + subscriptions: { dispose(): void }[]; +} + +/** + * Shape of an extension's entry module (its `./index`). + * + * Extensions are encouraged to export an `activate(context)` function so that + * their registrations are tracked via `context.subscriptions` regardless of + * whether they run synchronously or asynchronously. For backward compatibility, + * a module may instead register its contributions as top-level side effects when + * the module is evaluated; such registrations are only tracked when performed + * synchronously during module evaluation. + */ +export interface ExtensionModule { + /** + * Called by the host once the extension module has loaded. May be async; the + * host awaits it before considering the extension active. + */ + activate?(context: ExtensionContext): void | Promise; + /** + * Optional hook called before the host disposes `context.subscriptions`. + */ + deactivate?(): void | Promise; +} + /** * Represents a Superset extension with its metadata. * Extensions are modular components that can extend Superset's functionality diff --git a/superset-frontend/packages/superset-core/src/contributions/index.ts b/superset-frontend/packages/superset-core/src/contributions/index.ts index faccbb305dc8..86cf42d1959b 100644 --- a/superset-frontend/packages/superset-core/src/contributions/index.ts +++ b/superset-frontend/packages/superset-core/src/contributions/index.ts @@ -43,6 +43,9 @@ export type SqlLabLocation = | 'results' | 'queryHistory'; +/** Valid locations within the app shell (persist across all routes). */ +export type AppLocation = 'chatbot'; + /** * Nested structure for view contributions by scope and location. * @example @@ -55,6 +58,7 @@ export type SqlLabLocation = */ export interface ViewContributions { sqllab?: Partial>; + app?: Partial>; } /** diff --git a/superset-frontend/packages/superset-core/src/dashboard/index.ts b/superset-frontend/packages/superset-core/src/dashboard/index.ts new file mode 100644 index 000000000000..10f94f5ab8c2 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/dashboard/index.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. + */ + +/** + * @fileoverview Dashboard namespace for Superset extensions (P3). + * + * Exposes dashboard identity and filter state as a stable semantic API. + * Extensions must not depend on the Redux dashboard slice structure directly. + */ + +import { Event } from '../common'; + +/** + * A single native filter's current selected value(s). + * The value type is intentionally kept as `unknown` because filter values + * are heterogeneous (date ranges, string lists, numbers, etc.). + */ +export interface FilterValue { + /** The filter's stable id. */ + filterId: string; + /** Display label of the filter. */ + label: string; + /** Currently applied value, or `null` when the filter is cleared. */ + value: unknown; +} + +/** + * Summary of a single chart on the active dashboard. + * + * Exposes the identity, viz type, datasource, and current visibility of a + * chart so extensions can answer both "which charts are visible?" and + * "find the chart named X" without additional lookups. + */ +export interface ChartSummary { + /** Numeric chart (slice) id. */ + chartId: number; + /** Display name of the chart. */ + chartName: string; + /** Visualization type key (e.g. `'echarts_timeseries_bar'`). */ + vizType: string; + /** Datasource id, or `null` when not resolvable. */ + datasourceId: number | null; + /** Datasource name, or `null` when not resolvable. */ + datasourceName: string | null; + /** Whether the chart is currently visible (e.g. on the active tab). */ + isVisible: boolean; +} + +/** + * Normalized dashboard context exposed to extensions on the Dashboard page. + */ +export interface DashboardContext { + /** Numeric dashboard id. */ + dashboardId: number; + /** Display title of the dashboard. */ + title: string; + /** + * Active native filter values keyed by filter id. + * Only includes filters that have a value applied. + */ + filters: FilterValue[]; + /** + * Summaries of the dashboard's charts, including per-chart visibility. + * + * Optional: the contract is declared so extensions can compile against the + * stable shape, but population is delivered in a later phase (see + * CHATBOT_SIP.md §10/§11). The host returns an empty array until then. + */ + charts?: ChartSummary[]; +} + +/** + * Returns the normalized dashboard context for the page currently being viewed, + * or `undefined` when the user is not on a Dashboard page. + * + * @example + * ```typescript + * const dash = dashboard.getCurrentDashboard(); + * if (dash) { + * console.log(dash.title, dash.filters); + * } + * ``` + */ +export declare function getCurrentDashboard(): DashboardContext | undefined; + +/** + * Event fired when the dashboard identity or its active filter values change. + * Fired on native filter value changes and on navigation to a different dashboard. + * + * @example + * ```typescript + * const sub = dashboard.onDidChangeDashboard(dash => { + * chatbot.updateContext({ dashboard: dash }); + * }); + * sub.dispose(); + * ``` + */ +export declare const onDidChangeDashboard: Event; diff --git a/superset-frontend/packages/superset-core/src/dataset/index.ts b/superset-frontend/packages/superset-core/src/dataset/index.ts new file mode 100644 index 000000000000..ea3fafa4fdba --- /dev/null +++ b/superset-frontend/packages/superset-core/src/dataset/index.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. + */ + +/** + * @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; diff --git a/superset-frontend/packages/superset-core/src/explore/index.ts b/superset-frontend/packages/superset-core/src/explore/index.ts new file mode 100644 index 000000000000..162d1b2e6f77 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/explore/index.ts @@ -0,0 +1,75 @@ +/** + * 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 Explore namespace for Superset extensions (P3). + * + * Exposes the current chart/explore context as a stable semantic API. + * Normalized over Explore Redux state — extensions must not depend on + * the Redux slice structure directly. + */ + +import { Event } from '../common'; + +/** + * Normalized chart context exposed to extensions during an Explore session. + * Covers saved chart identity and transient editing context; excludes raw + * form-data internals and datasource-implementation details. + */ +export interface ChartContext { + /** The saved chart id, or `null` when the chart has not been persisted. */ + chartId: number | null; + /** Display name of the saved chart, or `null` for a new/unsaved chart. */ + chartName: string | null; + /** The visualization type currently selected in the editor. */ + vizType: string; + /** Id of the datasource backing the chart (physical or virtual dataset). */ + datasourceId: number | null; + /** Human-readable datasource name. */ + datasourceName: string | null; +} + +/** + * Returns the normalized chart context for the active Explore session, or + * `undefined` when the user is not on the Explore page. + * + * @example + * ```typescript + * const chart = explore.getCurrentChart(); + * if (chart) { + * console.log(chart.vizType, chart.chartName); + * } + * ``` + */ +export declare function getCurrentChart(): ChartContext | undefined; + +/** + * Event fired when the chart context changes within the active Explore session + * (e.g. when the viz type, datasource, or saved name changes). + * Not fired during route changes — subscribe to `navigation.onDidChangePage` for those. + * + * @example + * ```typescript + * const sub = explore.onDidChangeChart(chart => { + * chatbot.updateContext({ chart }); + * }); + * sub.dispose(); + * ``` + */ +export declare const onDidChangeChart: Event; diff --git a/superset-frontend/packages/superset-core/src/index.ts b/superset-frontend/packages/superset-core/src/index.ts index 75863372409e..79c699caff43 100644 --- a/superset-frontend/packages/superset-core/src/index.ts +++ b/superset-frontend/packages/superset-core/src/index.ts @@ -19,9 +19,13 @@ 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'; export * as menus from './menus'; +export * as navigation from './navigation'; export * as sqlLab from './sqlLab'; export * as views from './views'; export * as contributions from './contributions'; diff --git a/superset-frontend/packages/superset-core/src/navigation/index.ts b/superset-frontend/packages/superset-core/src/navigation/index.ts new file mode 100644 index 000000000000..0c8863a8b3d8 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/navigation/index.ts @@ -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. + */ + +/** + * @fileoverview Navigation namespace for Superset extensions (P3). + * + * Exposes the current application surface so extensions can react to route + * changes without polling. Entity-level context (chart, dashboard, dataset) + * is intentionally not included here — use the surface-specific namespace + * (`explore`, `dashboard`, `dataset`) to retrieve entity payloads. + */ + +import { Event } from '../common'; + +/** + * The set of top-level application surfaces. + * + * `'explore'`, `'dashboard'` and `'dataset'` are the single-entity + * editing/viewing surfaces where `explore.getCurrentChart()` / + * `dashboard.getCurrentDashboard()` / `dataset.getCurrentDataset()` resolve to a + * concrete entity. `'chart_list'`, `'dashboard_list'` and `'dataset_list'` are + * the browse/list surfaces, distinct from those because no single entity is + * active. `'sqllab'` is the SQL editor where `sqlLab.getCurrentTab()` resolves; + * `'query_history'` and `'saved_queries'` are the related SQL Lab browse pages, + * which are not the editor. `'other'` covers any route not explicitly enumerated. + */ +export type PageType = + | 'dashboard' + | 'dashboard_list' + | 'explore' + | 'chart_list' + | 'sqllab' + | 'query_history' + | 'saved_queries' + | 'dataset' + | 'dataset_list' + | 'home' + | 'other'; + +/** + * Returns the current page surface type. + * + * @example + * ```typescript + * const pageType = navigation.getPageType(); + * if (pageType === 'dashboard') { + * const ctx = dashboard.getCurrentDashboard(); + * } + * ``` + */ +export declare function getPageType(): PageType; + +/** + * Event fired whenever the user navigates to a different surface. + * Use the surface-specific namespace to read entity context after the event. + * + * @example + * ```typescript + * const sub = navigation.onDidChangePage(pageType => { + * if (pageType === 'dashboard') { + * const ctx = dashboard.getCurrentDashboard(); + * } + * }); + * // later: + * sub.dispose(); + * ``` + */ +export declare const onDidChangePage: Event; diff --git a/superset-frontend/packages/superset-core/src/views/index.ts b/superset-frontend/packages/superset-core/src/views/index.ts index 99c8ad09eb20..8ba7e60f6af0 100644 --- a/superset-frontend/packages/superset-core/src/views/index.ts +++ b/superset-frontend/packages/superset-core/src/views/index.ts @@ -48,6 +48,12 @@ export interface View { name: string; /** Optional description of the view, for display in contribution manifests. */ description?: string; + /** + * Optional icon identifier for the view, used in admin pickers and manifest + * listings. Static — set once at registerView() time. + * Dynamic icon states (e.g. notification badge) are the extension's concern. + */ + icon?: string; } /** @@ -56,12 +62,12 @@ export interface View { * The view provider function is called when the UI renders the location, * and should return a React element to display. * - * @param view The view descriptor (id and name). + * @param view The view descriptor (id, name, and optional icon/description). * @param location The location where this view should appear (e.g. "sqllab.panels"). * @param provider A function that returns the React element to render. * @returns A Disposable that unregisters the view when disposed. * - * @example + * @example SQL Lab panel * ```typescript * views.registerView( * { id: 'my_ext.result_stats', name: 'Result Stats' }, @@ -69,6 +75,15 @@ export interface View { * () => , * ); * ``` + * + * @example Chatbot bubble (`superset.chatbot` — singleton, host renders one) + * ```typescript + * views.registerView( + * { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' }, + * 'superset.chatbot', + * () => , + * ); + * ``` */ export declare function registerView( view: View, diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx b/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx index bab0e9a73949..971bcd45cd86 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx @@ -519,7 +519,8 @@ const Select = forwardRef( handleSelectAll(); }} > - {t('Select all')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`} + {t('Select all')}{' '} + {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`} ), diff --git a/superset-frontend/playwright/tests/dashboard/clear-all-filters.spec.ts b/superset-frontend/playwright/tests/dashboard/clear-all-filters.spec.ts index 4cd0c56f4b30..6bb30c3094b4 100644 --- a/superset-frontend/playwright/tests/dashboard/clear-all-filters.spec.ts +++ b/superset-frontend/playwright/tests/dashboard/clear-all-filters.spec.ts @@ -182,10 +182,7 @@ testWithAssets( // Now track POST /api/v1/chart/data requests around Clear All const postsAfterClearAll: string[] = []; const handler = (req: any) => { - if ( - req.url().includes('/api/v1/chart/data') && - req.method() === 'POST' - ) { + if (req.url().includes('/api/v1/chart/data') && req.method() === 'POST') { postsAfterClearAll.push(req.url()); } }; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.test.ts index 6ce84f5ffe4d..6a9752d57b9a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.test.ts @@ -288,9 +288,7 @@ describe('BigNumberWithTrendline transformProps', () => { height: 300, queriesData: [ { - data: [ - { __timestamp: 1, value: 100 }, - ] as unknown as BigNumberDatum[], + data: [{ __timestamp: 1, value: 100 }] as unknown as BigNumberDatum[], colnames: ['__timestamp', 'value'], coltypes: ['TEMPORAL', 'NUMERIC'], }, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx index 97b55f2a9a76..618cb62142c3 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx @@ -284,8 +284,11 @@ function Echart( // setOption(notMerge:true) replaces the dataZoom config, dropping any // range the user has engaged. Preserve it across the call. const previousZoom = notMerge - ? (chartRef.current?.getOption() as { dataZoom?: DataZoomComponentOption[] }) - ?.dataZoom + ? ( + chartRef.current?.getOption() as { + dataZoom?: DataZoomComponentOption[]; + } + )?.dataZoom : undefined; chartRef.current?.setOption(themedEchartOptions, { notMerge, 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 000000000000..35c08a5e9581 --- /dev/null +++ b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx @@ -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. + */ +import React from 'react'; +import { render, screen } from 'spec/helpers/testing-library'; +import { views } from 'src/core'; +import { setExtensionSettings } from 'src/core/extensions'; +import { CHATBOT_LOCATION } from 'src/views/contributions'; +import ChatbotMount from '.'; + +const disposables: Array<{ dispose: () => void }> = []; + +beforeEach(() => { + // The settings store is a module singleton; reset it so resolution starts + // from the empty default (no admin pin, all enabled) regardless of run order. + setExtensionSettings({ active_chatbot_id: null, enabled: {} }); +}); + +afterEach(() => { + disposables.forEach(d => d.dispose()); + disposables.length = 0; +}); + +test('renders nothing when no chatbot extension is registered', () => { + render(); + + 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(); + + 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(); + + 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()).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()).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 000000000000..0e07dff9a6a8 --- /dev/null +++ b/superset-frontend/src/components/ChatbotMount/index.tsx @@ -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. + */ +import { + type ReactElement, + useEffect, + useMemo, + useRef, + useSyncExternalStore, +} from 'react'; +import { t } from '@apache-superset/core/translation'; +import { logging } from '@apache-superset/core/utils'; +import { css, useTheme } from '@apache-superset/core/theme'; +import { ErrorBoundary } from 'src/components/ErrorBoundary'; +import { addDangerToast } from 'src/components/MessageToasts/actions'; +import { store } from 'src/views/store'; +import { getActiveChatbot } from 'src/core/chatbot'; +import { subscribeToRegistry, getRegistryVersion } from 'src/core/views'; +import { + getExtensionSettingsSnapshot, + loadExtensionSettings, + subscribeToExtensionSettings, +} from 'src/core/extensions'; + +const CHATBOT_EDGE_MARGIN = 24; + +/** + * 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(); + // Notify once per mount; a crash can re-render and would otherwise re-toast. + const crashNotified = useRef(false); + + // The active chatbot is a function of two host-owned stores: the admin + // settings (active id + enabled map) and the view registry (which chatbots + // are registered). Both are read via useSyncExternalStore so this re-resolves + // when either changes — no local copy of the settings state. + const settings = useSyncExternalStore( + subscribeToExtensionSettings, + getExtensionSettingsSnapshot, + ); + const registryVersion = useSyncExternalStore( + subscribeToRegistry, + getRegistryVersion, + ); + + useEffect(() => { + // Settings fetch failure is non-fatal: the store keeps its empty default, + // which getActiveChatbot treats as "all enabled, no admin pin". + loadExtensionSettings().catch(() => {}); + }, []); + + const activeChatbot = useMemo( + () => getActiveChatbot(settings.active_chatbot_id, settings.enabled), + [settings, registryVersion], + ); + + if (!activeChatbot) { + return null; + } + + return ( +
+ { + // Fault isolation (SIP §4.5): contain the crash, log it, surface a + // one-time notification, and leave the corner empty rather than + // parking a persistent error card. + logging.error('[chatbot] provider crashed', error); + if (!crashNotified.current) { + crashNotified.current = true; + store.dispatch(addDangerToast(t('The chatbot failed to load.'))); + } + }} + > + + +
+ ); +}; + +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 000000000000..80cd1d51e955 --- /dev/null +++ b/superset-frontend/src/core/chatbot/index.test.ts @@ -0,0 +1,167 @@ +/** + * 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(); +}); + +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/core/chatbot/index.ts b/superset-frontend/src/core/chatbot/index.ts new file mode 100644 index 000000000000..6ed14f388b68 --- /dev/null +++ b/superset-frontend/src/core/chatbot/index.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. + */ +/** + * @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, +): 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; + } + + // Mirror SIP §4.3's resolution table directly: when the admin pin names an + // enabled candidate, use it; otherwise use the first enabled candidate in + // registration order. `getRegisteredViewIds` and `getViewProvider` read the + // same synchronous registry maps, so a candidate id always has a live + // provider; the final guard is cheap defensiveness, not a fallback path. + const selectedId = + adminSelectedId && candidates.includes(adminSelectedId) + ? adminSelectedId + : candidates[0]; + + const provider = getViewProvider(CHATBOT_LOCATION, selectedId); + return provider ? { id: selectedId, provider } : undefined; +}; 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 000000000000..e8b7d6acbc6b --- /dev/null +++ b/superset-frontend/src/core/dashboard/index.test.ts @@ -0,0 +1,220 @@ +/** + * 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; + +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; + sliceEntities: unknown; + dashboardLayout: unknown; + }> = {}, +) { + return { + dashboardInfo: { id: 1, dashboard_title: 'Sales', slug: 'sales' }, + nativeFilters: { filters: { 'filter-1': { name: 'Region' } } }, + dataMask: { 'filter-1': { filterState: { value: ['West'] } } }, + sliceEntities: { slices: {} }, + dashboardLayout: { present: {} }, + ...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'] }], + // No charts on the (empty) layout fixture. + charts: [], + }); +}); + +test('getCurrentDashboard reports charts placed on the dashboard layout', () => { + mockState = makeState({ + sliceEntities: { + slices: { + 42: { + slice_name: 'Revenue by Region', + viz_type: 'echarts_timeseries_bar', + datasource_id: 7, + datasource_name: 'cleaned_sales', + }, + }, + }, + dashboardLayout: { + present: { + 'CHART-abc': { id: 'CHART-abc', type: 'CHART', meta: { chartId: 42 } }, + // A chart id with no matching slice entity still appears, with blanks. + 'CHART-def': { id: 'CHART-def', type: 'CHART', meta: { chartId: 99 } }, + // Non-chart components are ignored. + 'TAB-xyz': { id: 'TAB-xyz', type: 'TAB', meta: {} }, + }, + }, + }); + + expect(dashboard.getCurrentDashboard()?.charts).toEqual([ + { + chartId: 42, + chartName: 'Revenue by Region', + vizType: 'echarts_timeseries_bar', + datasourceId: 7, + datasourceName: 'cleaned_sales', + isVisible: true, + }, + { + chartId: 99, + chartName: '', + vizType: '', + datasourceId: null, + datasourceName: null, + isVisible: true, + }, + ]); +}); + +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 new file mode 100644 index 000000000000..c912f8459169 --- /dev/null +++ b/superset-frontend/src/core/dashboard/index.ts @@ -0,0 +1,123 @@ +/** + * 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 `dashboard` namespace. + * + * Wraps Redux dashboardInfo and dataMask state and normalizes them into the + * stable `DashboardContext` contract. Extensions must not depend on the Redux + * slice structure directly. + */ + +import type { dashboard as dashboardApi } from '@apache-superset/core'; +import type { DataMaskStateWithId } from '@superset-ui/core'; +import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate'; +import { + UPDATE_DATA_MASK, + SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE, +} from 'src/dataMask/actions'; +import { store, RootState } from 'src/views/store'; +import { AnyListenerPredicate } from '@reduxjs/toolkit'; +import getChartIdsFromLayout from 'src/dashboard/util/getChartIdsFromLayout'; +import { createActionListener } from '../utils'; +import { navigation } from '../navigation'; + +type DashboardContext = dashboardApi.DashboardContext; +type FilterValue = dashboardApi.FilterValue; +type ChartSummary = NonNullable[number]; + +function buildChartSummaries(state: RootState): ChartSummary[] { + const slices = state.sliceEntities?.slices ?? {}; + const layout = state.dashboardLayout?.present ?? {}; + + // Only charts actually placed on the dashboard layout — `slices` can also + // hold entities that are not on the current dashboard. + return getChartIdsFromLayout(layout).map(chartId => { + const slice = slices[chartId]; + return { + chartId, + chartName: slice?.slice_name ?? '', + vizType: slice?.viz_type ?? '', + datasourceId: slice?.datasource_id ?? null, + datasourceName: slice?.datasource_name ?? null, + // Tab-accurate visibility is a deferred phase (SIP §10/§11); every chart + // on the dashboard is reported visible for now. + isVisible: true, + }; + }); +} + +function buildDashboardContext(): DashboardContext | undefined { + if (navigation.getPageType() !== 'dashboard') return undefined; + // `store.getState()` is already typed as RootState, so the slices below are + // read with their real types — the host owns this normalization and must + // stay type-safe against slice reshapes. + const state = store.getState(); + const info = state.dashboardInfo; + if (!info?.id) return undefined; + + const nativeFilters = state.nativeFilters?.filters ?? {}; + const dataMask: DataMaskStateWithId = state.dataMask ?? {}; + + const filters: FilterValue[] = Object.entries(dataMask) + .filter(([id, mask]) => { + if (!(id in nativeFilters)) return false; + const value = mask?.filterState?.value; + return value !== null && value !== undefined; + }) + .map(([id, mask]) => { + const raw = mask.filterState?.value; + return { + filterId: id, + label: nativeFilters[id]?.name ?? id, + value: Array.isArray(raw) ? [...raw] : raw, + }; + }); + + return { + dashboardId: info.id, + title: info.dashboard_title ?? info.slug ?? String(info.id), + filters, + charts: buildChartSummaries(state), + }; +} + +const dashboardChangePredicate: AnyListenerPredicate = action => + action.type === HYDRATE_DASHBOARD || + action.type === UPDATE_DATA_MASK || + action.type === SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE; + +const getCurrentDashboard: typeof dashboardApi.getCurrentDashboard = () => + buildDashboardContext(); + +const onDidChangeDashboard: typeof dashboardApi.onDidChangeDashboard = ( + listener: (ctx: DashboardContext) => void, + thisArgs?: any, +) => + createActionListener( + dashboardChangePredicate, + listener, + () => buildDashboardContext() ?? null, + thisArgs, + ); + +export const dashboard: typeof dashboardApi = { + getCurrentDashboard, + onDidChangeDashboard, +}; diff --git a/superset-frontend/src/core/dataset/index.ts b/superset-frontend/src/core/dataset/index.ts new file mode 100644 index 000000000000..e0196fe2817d --- /dev/null +++ b/superset-frontend/src/core/dataset/index.ts @@ -0,0 +1,63 @@ +/** + * 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 { createEmitter } from '../utils'; + +type DatasetContext = datasetApi.DatasetContext; + +const emitter = createEmitter(undefined); + +/** + * 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 => { + emitter.fire(ctx); +}; + +const getCurrentDataset: typeof datasetApi.getCurrentDataset = () => { + const current = emitter.getCurrent(); + return current ? { ...current } : undefined; +}; + +const onDidChangeDataset: typeof datasetApi.onDidChangeDataset = ( + listener: (ctx: DatasetContext) => void, + thisArgs?: unknown, +) => { + const bound = thisArgs ? listener.bind(thisArgs) : listener; + // The public contract only emits a concrete context; skip `undefined` clears + // so subscribers are never handed an empty value. + return emitter.event(ctx => { + if (ctx) bound(ctx); + }); +}; + +export const dataset: typeof datasetApi = { + getCurrentDataset, + onDidChangeDataset, +}; 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 000000000000..744478bc9565 --- /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; + +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 new file mode 100644 index 000000000000..9539e1be313f --- /dev/null +++ b/superset-frontend/src/core/explore/index.ts @@ -0,0 +1,92 @@ +/** + * 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 `explore` namespace. + * + * Wraps Redux explore state and normalizes it into the stable `ChartContext` + * contract. Extensions must not depend on the Redux slice structure directly. + */ + +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'; +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; + // `store.getState()` is already RootState; read the typed `explore` slice + // directly rather than casting it away. + const state = store.getState(); + const exploreState = state.explore; + if (!exploreState) return undefined; + + const { slice, datasource, controls } = exploreState; + const vizType: string = + (controls?.viz_type?.value as string) ?? + exploreState.form_data?.viz_type ?? + ''; + + return { + chartId: slice?.slice_id ?? null, + chartName: exploreState.sliceName ?? slice?.slice_name ?? null, + vizType, + datasourceId: datasource?.id ?? null, + datasourceName: + datasource?.table_name ?? datasource?.datasource_name ?? null, + }; +} + +const exploreChangePredicate: AnyListenerPredicate = action => + action.type === HYDRATE_EXPLORE || + action.type === SET_FORM_DATA || + action.type === UPDATE_CHART_TITLE || + action.type === SET_DATASOURCE || + action.type === CREATE_NEW_SLICE || + action.type === SLICE_UPDATED; + +const getCurrentChart: typeof exploreApi.getCurrentChart = () => + buildChartContext(); + +const onDidChangeChart: typeof exploreApi.onDidChangeChart = ( + listener: (ctx: ChartContext) => void, + thisArgs?: any, +) => + createActionListener( + exploreChangePredicate, + listener, + () => buildChartContext() ?? null, + thisArgs, + ); + +export const explore: typeof exploreApi = { + getCurrentChart, + onDidChangeChart, +}; diff --git a/superset-frontend/src/core/extensions/index.ts b/superset-frontend/src/core/extensions/index.ts index ae49a135aed8..9a751259a837 100644 --- a/superset-frontend/src/core/extensions/index.ts +++ b/superset-frontend/src/core/extensions/index.ts @@ -17,6 +17,7 @@ * under the License. */ import { extensions as extensionsApi } from '@apache-superset/core'; +import { SupersetClient } from '@superset-ui/core'; import ExtensionsLoader from 'src/extensions/ExtensionsLoader'; const getExtension: typeof extensionsApi.getExtension = id => @@ -29,3 +30,84 @@ export const extensions: typeof extensionsApi = { getExtension, getAllExtensions, }; + +/** + * Deployment-wide extension admin settings. The keys are snake_case to match + * the `/api/v1/extensions/settings` wire shape this store loads and saves. + */ +export type ExtensionSettings = { + active_chatbot_id: string | null; + enabled: Record; +}; + +const SETTINGS_ENDPOINT = '/api/v1/extensions/settings'; + +const EMPTY_SETTINGS: ExtensionSettings = { + active_chatbot_id: null, + enabled: {}, +}; + +/** + * Single module-level store for extension admin settings. Both the chatbot + * mount and the admin list read this one source via `useSyncExternalStore`, + * so a save on the admin page is reflected everywhere without a bespoke + * second notification channel. + */ +let settings: ExtensionSettings = EMPTY_SETTINGS; +const settingsListeners = new Set<() => void>(); + +const emitSettingsChange = (): void => { + settingsListeners.forEach(fn => fn()); +}; + +/** Subscribe to settings changes (for `useSyncExternalStore`). */ +export const subscribeToExtensionSettings = ( + listener: () => void, +): (() => void) => { + settingsListeners.add(listener); + return () => { + settingsListeners.delete(listener); + }; +}; + +/** Current settings snapshot (for `useSyncExternalStore`). */ +export const getExtensionSettingsSnapshot = (): ExtensionSettings => settings; + +/** Replace the settings snapshot and notify subscribers. */ +export const setExtensionSettings = (next: ExtensionSettings): void => { + settings = next; + emitSettingsChange(); +}; + +/** + * Fetch settings from the server into the store. Resolves to the loaded value; + * on failure the store is left untouched and the error is rethrown so callers + * can surface it. + */ +export const loadExtensionSettings = async (): Promise => { + const { json } = await SupersetClient.get({ endpoint: SETTINGS_ENDPOINT }); + setExtensionSettings(json.result ?? EMPTY_SETTINGS); + return settings; +}; + +/** + * Optimistically apply a partial settings update and persist it. On failure the + * previous snapshot is restored. Returns a promise that rejects on error so the + * caller can toast. + */ +export const saveExtensionSettings = async ( + patch: Partial, +): Promise => { + const previous = settings; + const next = { ...previous, ...patch }; + setExtensionSettings(next); + try { + await SupersetClient.put({ + endpoint: SETTINGS_ENDPOINT, + jsonPayload: next, + }); + } catch (error) { + setExtensionSettings(previous); + throw error; + } +}; diff --git a/superset-frontend/src/core/index.ts b/superset-frontend/src/core/index.ts index 6a106ebe87ae..d259597457c3 100644 --- a/superset-frontend/src/core/index.ts +++ b/superset-frontend/src/core/index.ts @@ -28,10 +28,14 @@ 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'; export * from './menus'; export * from './models'; +export * from './navigation'; export * from './sqlLab'; export * from './utils'; export * from './views'; 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 000000000000..b9cb7451d240 --- /dev/null +++ b/superset-frontend/src/core/navigation/index.test.ts @@ -0,0 +1,121 @@ +/** + * 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/'); + expect(navigation.getPageType()).toBe('sqllab'); +}); + +test('chart and dashboard list pages get their own page types', async () => { + const { notifyPageChange, navigation } = await importNavigation(); + notifyPageChange('/chart/list/'); + expect(navigation.getPageType()).toBe('chart_list'); + notifyPageChange('/dashboard/list/'); + expect(navigation.getPageType()).toBe('dashboard_list'); +}); + +test('dataset list and single-dataset pages get distinct page types', async () => { + const { notifyPageChange, navigation } = await importNavigation(); + notifyPageChange('/tablemodelview/list/'); + expect(navigation.getPageType()).toBe('dataset_list'); + notifyPageChange('/dataset/42'); + expect(navigation.getPageType()).toBe('dataset'); +}); + +test('sqllab editor, query history, and saved queries get distinct page types', async () => { + const { notifyPageChange, navigation } = await importNavigation(); + notifyPageChange('/sqllab/'); + expect(navigation.getPageType()).toBe('sqllab'); + notifyPageChange('/sqllab/history/'); + expect(navigation.getPageType()).toBe('query_history'); + notifyPageChange('/savedqueryview/list/'); + expect(navigation.getPageType()).toBe('saved_queries'); +}); + +test('chart/add resolves to explore, not chart_list', async () => { + const { notifyPageChange, navigation } = await importNavigation(); + notifyPageChange('/chart/add'); + expect(navigation.getPageType()).toBe('explore'); +}); diff --git a/superset-frontend/src/core/navigation/index.ts b/superset-frontend/src/core/navigation/index.ts new file mode 100644 index 000000000000..f02afb217b2a --- /dev/null +++ b/superset-frontend/src/core/navigation/index.ts @@ -0,0 +1,82 @@ +/** + * 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 `navigation` namespace. + * + * Backed by browser location — no Redux dependency. + * The app shell calls `notifyPageChange(pathname)` whenever the route changes. + */ + +import type { navigation as navigationApi } from '@apache-superset/core'; +import { Disposable } from '../models'; + +type PageType = navigationApi.PageType; + +const listeners = new Set<(pageType: PageType) => void>(); + +function derivePageType(pathname: string): PageType { + if (pathname.startsWith('/superset/dashboard/')) return 'dashboard'; + if (pathname.startsWith('/dashboard/list')) return 'dashboard_list'; + if (pathname.startsWith('/explore/')) return 'explore'; + if (pathname.startsWith('/superset/explore/')) return 'explore'; + if (pathname.startsWith('/chart/add')) return 'explore'; + if (pathname.startsWith('/chart/list')) return 'chart_list'; + if (pathname.startsWith('/sqllab/history')) return 'query_history'; + if (pathname.startsWith('/savedqueryview/list')) return 'saved_queries'; + if (pathname === '/sqllab' || pathname.startsWith('/sqllab/')) + return 'sqllab'; + if (pathname.startsWith('/tablemodelview/list')) return 'dataset_list'; + if (pathname.startsWith('/dataset/')) return 'dataset'; + if (pathname.startsWith('/superset/welcome/')) return 'home'; + return 'other'; +} + +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 === getOrInitPageType()) return; + currentPageType = next; + listeners.forEach(fn => fn(next)); +}; + +const getPageType: typeof navigationApi.getPageType = () => getOrInitPageType(); + +const onDidChangePage: typeof navigationApi.onDidChangePage = ( + listener: (pageType: PageType) => void, + thisArgs?: any, +): Disposable => { + const bound = thisArgs ? listener.bind(thisArgs) : listener; + listeners.add(bound); + return new Disposable(() => listeners.delete(bound)); +}; + +export const navigation: typeof navigationApi = { + getPageType, + onDidChangePage, +}; diff --git a/superset-frontend/src/core/sqlLab/index.ts b/superset-frontend/src/core/sqlLab/index.ts index b14be7efd07b..17174f5a005b 100644 --- a/superset-frontend/src/core/sqlLab/index.ts +++ b/superset-frontend/src/core/sqlLab/index.ts @@ -56,6 +56,7 @@ import { QueryResultContext, QueryErrorResultContext, } from './models'; +import { navigation } from '../navigation'; const { CTASMethod } = sqlLabApi; @@ -301,8 +302,15 @@ function createQueryErrorContext( ); } -const getCurrentTab: typeof sqlLabApi.getCurrentTab = () => - getTab(activeEditorId()); +const getCurrentTab: typeof sqlLabApi.getCurrentTab = () => { + // Guard on the page type so the tab does not leak onto non-editor surfaces. + // The SQL Lab Redux slice persists after navigating away, so without this + // guard `getCurrentTab()` would keep returning the last editor's tab on, e.g., + // a dashboard or list page. Mirrors the page-type guards on + // `explore.getCurrentChart()` / `dashboard.getCurrentDashboard()`. + if (navigation.getPageType() !== 'sqllab') return undefined; + return getTab(activeEditorId()); +}; const getActivePanel: typeof sqlLabApi.getActivePanel = () => { const { activeSouthPaneTab } = getSqlLabState(); @@ -452,8 +460,14 @@ const onDidChangeActiveTab: typeof sqlLabApi.onDidChangeActiveTab = ( createActionListener( globalPredicate(SET_ACTIVE_QUERY_EDITOR), listener, - (action: { type: string; queryEditor: { id: string } }) => - getTab(action.queryEditor.id), + // Resolve the now-active tab the same way `getCurrentTab()` does (via the + // active-editor / tabHistory state) rather than from the raw action payload. + // The action's `queryEditor` carries the base editor without `unsavedQueryEditor` + // merged, so its `dbId` can still be undefined at this point, which made + // `getTab(action.queryEditor.id)` return undefined and silently swallow the + // event. Reading the resolved active tab keeps this event consistent with the + // getter and fires on every tab switch. + () => getCurrentTab() ?? null, thisArgs, ); diff --git a/superset-frontend/src/core/sqlLab/sqlLab.test.ts b/superset-frontend/src/core/sqlLab/sqlLab.test.ts index 5b7356c939b4..2b696353e8f0 100644 --- a/superset-frontend/src/core/sqlLab/sqlLab.test.ts +++ b/superset-frontend/src/core/sqlLab/sqlLab.test.ts @@ -119,6 +119,13 @@ jest.mock('src/views/store', () => ({ setupStore: jest.fn(), })); +// The sqlLab namespace guards `getCurrentTab()` on the page type. These tests +// exercise the editor surface, so report 'sqllab'. Per-test overrides (e.g. to +// assert the off-surface guard) can change the return value. +jest.mock('../navigation', () => ({ + navigation: { getPageType: jest.fn(() => 'sqllab') }, +})); + // Module under test — imported after mocks // eslint-disable-next-line import/first import { sqlLab } from '.'; @@ -388,6 +395,31 @@ test('onDidChangeActiveTab fires with Tab on SET_ACTIVE_QUERY_EDITOR', () => { disposable.dispose(); }); +test('onDidChangeActiveTab carries the newly-activated tab when switching away', () => { + // Switching from the first editor to a second one must report the second tab, + // not the first. Regression guard: resolving the tab from the live active + // editor (via getCurrentTab) instead of the raw action payload. + mockStore.dispatch({ + type: ADD_QUERY_EDITOR, + queryEditor: makeSecondEditor(), + }); + + const listener = jest.fn(); + const disposable = sqlLab.onDidChangeActiveTab(listener); + + mockStore.dispatch({ + type: SET_ACTIVE_QUERY_EDITOR, + queryEditor: { id: 'editor-2' }, + }); + + expect(listener).toHaveBeenCalledTimes(1); + const tab = listener.mock.calls[0][0]; + expect(tab.id).toBe('editor-2'); + expect(tab.databaseId).toBe(2); + + disposable.dispose(); +}); + test('onDidCreateTab fires with Tab on ADD_QUERY_EDITOR', () => { const listener = jest.fn(); const disposable = sqlLab.onDidCreateTab(listener); @@ -535,6 +567,13 @@ test('getCurrentTab returns the active tab with correct properties', () => { expect(tab!.schema).toBe('public'); }); +test('getCurrentTab returns undefined when not on the SQL Lab editor surface', () => { + const { navigation } = jest.requireMock('../navigation'); + (navigation.getPageType as jest.Mock).mockReturnValueOnce('dashboard'); + + expect(sqlLab.getCurrentTab()).toBeUndefined(); +}); + test('getActivePanel returns the active south pane tab', () => { const panel = sqlLab.getActivePanel(); expect(panel.id).toBe('Results'); diff --git a/superset-frontend/src/core/utils.ts b/superset-frontend/src/core/utils.ts index 1e4dded93c35..fe54011caece 100644 --- a/superset-frontend/src/core/utils.ts +++ b/superset-frontend/src/core/utils.ts @@ -20,6 +20,56 @@ import type { common as core } from '@apache-superset/core'; import { AnyAction } from 'redux'; import { listenerMiddleware, RootState, store } from 'src/views/store'; import { AnyListenerPredicate } from '@reduxjs/toolkit'; +import { Disposable } from './models'; + +/** + * A typed event subscription matching the public `Event` contract. + * Calling it with a listener (and optional `this` arg) subscribes and returns + * a {@link Disposable} that unsubscribes. + */ +export type EventSubscriber = ( + listener: (e: T) => void, + thisArgs?: unknown, +) => Disposable; + +/** + * A minimal host-internal event emitter shared by the producer-backed + * namespaces (dataset, navigation, settings, view registry). Each of those + * needs the same "publish a value and fan it out to subscribers" primitive; + * this collapses the duplicated Set + bind + Disposable boilerplate into one + * place. + * + * `event` is exposed to extensions as the namespace's `onDidChange*`; `fire` + * and `getCurrent` stay host-internal. + */ +export interface Emitter { + /** Subscribe to changes; conforms to the public `Event` shape. */ + event: EventSubscriber; + /** Notify all current subscribers with `value`. */ + fire: (value: T) => void; + /** The most recently fired value (or the initial value). */ + getCurrent: () => T; +} + +export function createEmitter(initial: T): Emitter { + const listeners = new Set<(e: T) => void>(); + let current = initial; + + return { + event: (listener, thisArgs) => { + const bound = thisArgs ? listener.bind(thisArgs) : listener; + listeners.add(bound); + return new Disposable(() => { + listeners.delete(bound); + }); + }, + fire: value => { + current = value; + listeners.forEach(fn => fn(value)); + }, + getCurrent: () => current, + }; +} export function createActionListener( predicate: AnyListenerPredicate, diff --git a/superset-frontend/src/core/views/index.test.ts b/superset-frontend/src/core/views/index.test.ts index d98a05b7f4f4..16413b109a08 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 5bed7d10910f..bc3bea3588ff 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> = 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 notifyRegistry = () => { + registryVersion += 1; + registrySubscribers.forEach(fn => fn()); +}; + +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, @@ -46,15 +67,24 @@ const registerView: typeof viewsApi.registerView = ( ): Disposable => { const { id } = view; + const previousLocation = viewRegistry.get(id)?.location; + if (previousLocation && previousLocation !== location) { + locationIndex.get(previousLocation)?.delete(id); + } + viewRegistry.set(id, { view, location, provider }); const ids = locationIndex.get(location) ?? new Set(); ids.add(id); locationIndex.set(location, ids); + notifyRegistry(); + return new Disposable(() => { + const registeredLocation = viewRegistry.get(id)?.location ?? location; viewRegistry.delete(id); - locationIndex.get(location)?.delete(id); + locationIndex.get(registeredLocation)?.delete(id); + notifyRegistry(); }); }; @@ -77,6 +107,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, diff --git a/superset-frontend/src/explore/components/controls/CollectionControl/index.tsx b/superset-frontend/src/explore/components/controls/CollectionControl/index.tsx index 2b0e01b01292..1a0393577ad0 100644 --- a/superset-frontend/src/explore/components/controls/CollectionControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/CollectionControl/index.tsx @@ -188,7 +188,9 @@ function CollectionControl({ // Two items can collide when keyAccessor returns falsy and the index // fallback is used — breaking dnd-kit reordering and React reconciliation. // Assign a stable nanoid per item ref when no key is available. - const generatedIdsRef = useRef>(new WeakMap()); + const generatedIdsRef = useRef>( + new WeakMap(), + ); const itemIds = useMemo( () => value.map(item => { diff --git a/superset-frontend/src/extensions/ExtensionsList.test.tsx b/superset-frontend/src/extensions/ExtensionsList.test.tsx index ee2be4691d1f..fbe581aced11 100644 --- a/superset-frontend/src/extensions/ExtensionsList.test.tsx +++ b/superset-frontend/src/extensions/ExtensionsList.test.tsx @@ -16,73 +16,304 @@ * 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) => ( + + + {(data ?? []).map((row: any) => + columns.map((col: any) => ( + + )), + )} + +
+ {col.Cell + ? col.Cell({ row: { original: row } }) + : row[col.accessor]} +
+ ), +})); + +// Stub SubMenu so tests aren't coupled to the navigation menu rendering chain. +jest.mock('src/features/home/SubMenu', () => ({ + __esModule: true, + default: ({ buttons }: any) => ( +
+ {(buttons ?? []).map((btn: any, i: number) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} +
+ ), +})); + +// 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), +})); + +// Stable snapshot reference: useSyncExternalStore requires getSnapshot to +// return the same object until it actually changes, otherwise it re-renders +// infinitely. +const mockSettings = { active_chatbot_id: null, enabled: {} }; +jest.mock('src/core/extensions', () => ({ + getExtensionSettingsSnapshot: jest.fn(() => mockSettings), + setExtensionSettings: jest.fn(), + loadExtensionSettings: jest.fn(() => Promise.resolve()), + subscribeToExtensionSettings: jest.fn(() => () => undefined), +})); + +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(, { +function renderList(props = {}) { + return render(, { 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')); + + 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')); - renderWithStore({ - addDangerToast, - addSuccessToast, + await userEvent.click(screen.getByTestId('delete-extension')); + + const confirmInput = await screen.findByTestId('delete-modal-input'); + fireEvent.change(confirmInput, { target: { value: 'DELETE' } }); + + 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: {} }); - // The component should accept these props without error - expect(addDangerToast).toBeDefined(); - expect(addSuccessToast).toBeDefined(); + 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('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('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 6f4f9c2f56db..3d4a5d9bbfe1 100644 --- a/superset-frontend/src/extensions/ExtensionsList.tsx +++ b/superset-frontend/src/extensions/ExtensionsList.tsx @@ -17,18 +17,41 @@ * under the License. */ import { t } from '@apache-superset/core/translation'; -import { FunctionComponent, useMemo } from 'react'; +import { SupersetClient } from '@superset-ui/core'; +import { + FunctionComponent, + useCallback, + useEffect, + useRef, + useState, + useMemo, + useSyncExternalStore, +} from 'react'; 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'; +import { ConfirmStatusChange, Tooltip } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { + getExtensionSettingsSnapshot, + loadExtensionSettings, + setExtensionSettings, + subscribeToExtensionSettings, +} from 'src/core/extensions'; +import { getRegisteredViewIds, subscribeToRegistry } from 'src/core/views'; + +const CHATBOT_LOCATION = 'superset.chatbot'; const PAGE_SIZE = 25; type Extension = { - id: number; + id: string; name: string; + publisher: string; enabled: boolean; + deletable: boolean; }; interface ExtensionsListProps { @@ -40,6 +63,21 @@ const ExtensionsList: FunctionComponent = ({ addDangerToast, addSuccessToast, }) => { + const fileInputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [chatbotExtensionIds, setChatbotExtensionIds] = useState>( + () => new Set(getRegisteredViewIds(CHATBOT_LOCATION)), + ); + + // The active chatbot lives in the host-owned settings store shared with the + // live ChatbotMount, so a change here is reflected there without a second + // notification channel. + const settings = useSyncExternalStore( + subscribeToExtensionSettings, + getExtensionSettingsSnapshot, + ); + const activeChatbotId = settings.active_chatbot_id; + const { state: { loading, resourceCount, resourceCollection }, fetchData, @@ -50,6 +88,111 @@ const ExtensionsList: FunctionComponent = ({ addDangerToast, ); + // Keep chatbotExtensionIds in sync with runtime view registrations + useEffect( + () => + subscribeToRegistry(() => { + setChatbotExtensionIds(new Set(getRegisteredViewIds(CHATBOT_LOCATION))); + }), + [], + ); + + // Load settings into the shared store on mount. + useEffect(() => { + // non-fatal: the store keeps its empty default on failure. + loadExtensionSettings().catch(() => {}); + }, []); + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + 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 = useCallback( + (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), + ), + ), + ); + }, + [addDangerToast, addSuccessToast, refreshData], + ); + + const handleSetDefaultChatbot = useCallback( + (extension: Extension) => { + const newId = activeChatbotId === extension.id ? null : extension.id; + SupersetClient.put({ + endpoint: '/api/v1/extensions/settings', + jsonPayload: { active_chatbot_id: newId }, + }).then( + () => { + // Reflect the change in the shared settings store; the component and + // the live ChatbotMount both re-resolve from it immediately. + setExtensionSettings({ + ...getExtensionSettingsSnapshot(), + active_chatbot_id: newId, + }); + addSuccessToast( + newId + ? t('%s set as default chatbot.', extension.name) + : t('Default chatbot cleared.'), + ); + }, + createErrorHandler(errMsg => + addDangerToast( + t('There was an issue updating chatbot settings: %s', errMsg), + ), + ), + ); + }, + [activeChatbotId, addDangerToast, addSuccessToast], + ); + const columns = useMemo( () => [ { @@ -63,18 +206,133 @@ const ExtensionsList: FunctionComponent = ({ }, }: any) => name, }, + { + Header: t('Publisher'), + accessor: 'publisher', + id: 'publisher', + Cell: ({ + row: { + original: { publisher }, + }, + }: any) => publisher, + }, + { + Header: t('Actions'), + id: 'actions', + disableSortBy: true, + Cell: ({ row: { original } }: any) => { + const isChatbot = chatbotExtensionIds.has(original.id); + const isDefault = activeChatbotId === original.id; + return ( + <> + {isChatbot && ( + + handleSetDefaultChatbot(original)} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + handleSetDefaultChatbot(original); + } + }} + > + {isDefault ? ( + + ) : ( + + )} + + + )} + {original.deletable && ( + + {t('Are you sure you want to delete')}{' '} + {original.name}? + + } + onConfirm={() => handleDelete(original)} + > + {(confirmDelete: () => void) => ( + + { + if (e.key === 'Enter' || e.key === ' ') { + confirmDelete(); + } + }} + > + + + + )} + + )} + + ); + }, + }, + ], + [ + activeChatbotId, + chatbotExtensionIds, + handleSetDefaultChatbot, + handleDelete, ], - [loading], // We need to monitor loading to avoid stale state in actions ); const menuData: SubMenuProps = { activeChild: 'Extensions', name: t('Extensions'), - buttons: [], + buttons: [ + { + name: ( + + + + ), + buttonStyle: 'link', + onClick: handleUploadClick, + loading: uploading, + }, + ], }; return ( <> + columns={columns} diff --git a/superset-frontend/src/extensions/ExtensionsLoader.test.ts b/superset-frontend/src/extensions/ExtensionsLoader.test.ts index 1d2493671ad1..c90fda5ef8cf 100644 --- a/superset-frontend/src/extensions/ExtensionsLoader.test.ts +++ b/superset-frontend/src/extensions/ExtensionsLoader.test.ts @@ -38,6 +38,13 @@ function createMockExtension(overrides: Partial = {}): Extension { beforeEach(() => { (ExtensionsLoader as any).instance = undefined; + // Minimal host registry surface the loader wraps during module evaluation. + (window as any).superset = { + commands: { registerCommand: jest.fn() }, + menus: { registerMenuItem: jest.fn() }, + editors: { registerEditor: jest.fn() }, + views: { registerView: jest.fn() }, + }; }); test('creates a singleton instance', () => { @@ -142,3 +149,100 @@ test('logs error when initializeExtensions fails', async () => { errorSpy.mockRestore(); }); + +/** + * Stubs the module-federation machinery `loadModule` depends on so a fake + * extension entry module (its `./index` factory) can be loaded in jsdom. + * Returns a cleanup function that restores the patched globals. + */ +function mockRemoteModule(containerName: string, factory: () => unknown) { + const appendChildSpy = jest + .spyOn(document.head, 'appendChild') + .mockImplementation((element: Node) => { + if (element instanceof HTMLScriptElement && element.onload) { + setTimeout(() => (element.onload as any)(new Event('load')), 0); + } + return element; + }); + + (global as any).__webpack_init_sharing__ = jest + .fn() + .mockResolvedValue(undefined); + (global as any).__webpack_share_scopes__ = { default: {} }; + (window as any)[containerName] = { + init: jest.fn().mockResolvedValue(undefined), + get: jest.fn().mockResolvedValue(factory), + }; + + return () => { + appendChildSpy.mockRestore(); + delete (global as any).__webpack_init_sharing__; + delete (global as any).__webpack_share_scopes__; + delete (window as any)[containerName]; + }; +} + +const remoteExtension = (overrides: Partial = {}) => + createMockExtension({ + id: 'remote-ext', + remoteEntry: 'http://example/remoteEntry.js', + ...overrides, + }); + +test('disposes synchronous activation-time registrations on deactivation', async () => { + const loader = ExtensionsLoader.getInstance(); + const dispose = jest.fn(); + // Legacy side-effect style: register synchronously during module evaluation. + const factory = () => { + window.superset.views.registerView( + { id: 'remote-ext.view', name: 'View' }, + 'sqllab.panels', + (() => null) as any, + ); + return undefined; + }; + const registerView = jest + .spyOn(window.superset.views, 'registerView') + .mockReturnValue({ dispose } as any); + const cleanup = mockRemoteModule('remote-ext', factory); + + await loader.initializeExtension(remoteExtension()); + loader.deactivateExtension('remote-ext'); + + expect(dispose).toHaveBeenCalledTimes(1); + + registerView.mockRestore(); + cleanup(); +}); + +test('tracks registrations made asynchronously inside activate(context)', async () => { + const loader = ExtensionsLoader.getInstance(); + const dispose = jest.fn(); + const registerView = jest + .spyOn(window.superset.views, 'registerView') + .mockReturnValue({ dispose } as any); + + // Modern style: register AFTER an await — the window.superset wrap is already + // gone by then, so this is only tracked because activate pushes to context. + const factory = () => ({ + activate: async (context: core.ExtensionContext) => { + await Promise.resolve(); + const disposable = window.superset.views.registerView( + { id: 'remote-ext.async-view', name: 'Async View' }, + 'sqllab.panels', + (() => null) as any, + ); + context.subscriptions.push(disposable); + }, + }); + const cleanup = mockRemoteModule('remote-ext', factory); + + await loader.initializeExtension(remoteExtension()); + loader.deactivateExtension('remote-ext'); + + expect(registerView).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); + + registerView.mockRestore(); + cleanup(); +}); diff --git a/superset-frontend/src/extensions/ExtensionsLoader.ts b/superset-frontend/src/extensions/ExtensionsLoader.ts index 0b74ef9be861..d54475ac6aec 100644 --- a/superset-frontend/src/extensions/ExtensionsLoader.ts +++ b/superset-frontend/src/extensions/ExtensionsLoader.ts @@ -17,10 +17,17 @@ * under the License. */ import { SupersetClient } from '@superset-ui/core'; +import { t } from '@apache-superset/core/translation'; import { logging } from '@apache-superset/core/utils'; import type { common as core } from '@apache-superset/core'; +import { addDangerToast } from 'src/components/MessageToasts/actions'; +import { store } from 'src/views/store'; +// Side-effect import: brings the `window.superset` global augmentation into scope. +import 'src/extensions/supersetGlobal'; type Extension = core.Extension; +type ExtensionContext = core.ExtensionContext; +type ExtensionModule = core.ExtensionModule; /** * Loads extension modules via webpack module federation. @@ -36,6 +43,9 @@ class ExtensionsLoader { private initializationPromise: Promise | null = null; + /** Disposables registered by each extension via its context, keyed by extension id. */ + private extensionDisposables: Map = new Map(); + // eslint-disable-next-line no-useless-constructor private constructor() { // Private constructor for singleton pattern @@ -81,14 +91,16 @@ class ExtensionsLoader { /** * Initializes a single extension. - * If the extension has a remote entry, loads the module (which triggers + * If the extension has a remote entry, loads the module and runs its + * `activate(context)` hook (or, for legacy extensions, its top-level * side-effect registrations for commands, views, menus, and editors). * @param extension The extension to initialize. */ public async initializeExtension(extension: Extension) { try { if (extension.remoteEntry) { - await this.loadModule(extension); + const subscriptions = await this.loadModule(extension); + this.extensionDisposables.set(extension.id, subscriptions); } this.extensionIndex.set(extension.id, extension); } catch (error) { @@ -96,15 +108,41 @@ class ExtensionsLoader { `Failed to initialize extension ${extension.name}\n`, error, ); + store.dispatch( + addDangerToast(t('Extension "%s" failed to load.', extension.name)), + ); } } /** - * Loads a single extension module via webpack module federation. - * The module's top-level side effects fire contribution registrations. + * Deactivates an extension by disposing all of its registered contributions + * and removing it from the index. + * + * Contributions are disposed from the extension's `context.subscriptions`, + * which it populates during `activate(context)`. This tracks registrations + * regardless of when they happen — synchronous or asynchronous — so long as + * the extension pushes each returned Disposable onto its context. Legacy + * extensions that register as top-level side effects are tracked only for the + * synchronous module-evaluation window (see `loadModule`). + */ + public deactivateExtension(id: string): void { + const subscriptions = this.extensionDisposables.get(id); + if (subscriptions) { + subscriptions.forEach(subscription => subscription.dispose()); + this.extensionDisposables.delete(id); + } + this.extensionIndex.delete(id); + } + + /** + * Loads a single extension module via webpack module federation and runs its + * `activate(context)` hook. Returns the Disposables the extension registered + * (its `context.subscriptions`) so the loader can dispose them on deactivation. * @param extension The extension to load. */ - private async loadModule(extension: Extension): Promise { + private async loadModule( + extension: Extension, + ): Promise<{ dispose(): void }[]> { const { remoteEntry, id } = extension; // Load the remote entry script @@ -149,8 +187,72 @@ class ExtensionsLoader { await container.init(__webpack_share_scopes__.default); const factory = await container.get('./index'); - // Execute the module factory - side effects fire registrations - factory(); + + // The extension binds the lifetime of its registrations to this context by + // pushing the returned Disposables onto `subscriptions`. Because the context + // object outlives the synchronous module-evaluation window, registrations + // performed asynchronously inside `activate` (after an `await`, in a timer, + // or in an event callback) are tracked just like synchronous ones. + const context: ExtensionContext = { subscriptions: [] }; + + // Backward-compatibility path: extensions that register contributions as + // top-level side effects (rather than via `activate(context)`) do not push + // to `context.subscriptions` themselves. Wrapping the registrars captures + // those disposables — but ONLY while they fire synchronously during module + // evaluation, since the wrap is removed immediately afterwards. Extensions + // that register asynchronously must use `activate(context)` to be tracked. + const originalSuperset = window.superset; + + const wrap = + ( + fn: (...args: TArgs) => { dispose(): void }, + ): ((...args: TArgs) => { dispose(): void }) => + (...args: TArgs) => { + const disposable = fn(...args); + context.subscriptions.push(disposable); + 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: wrap(originalSuperset.views.registerView), + }, + }; + + let module: ExtensionModule | undefined; + try { + // Evaluate the module factory. Legacy extensions fire their contribution + // registrations as a synchronous side effect here; modern extensions + // return a module exposing `activate`. + module = factory() as ExtensionModule | undefined; + } finally { + // Restore the real registrars before `activate` runs so that registrations + // are tracked via `context.subscriptions` (which the extension controls and + // which survives async boundaries) rather than via the synchronous wrap. + window.superset = originalSuperset; + } + + // Preferred path: hand the extension its context so it can track every + // registration it makes, synchronous or asynchronous. + if (typeof module?.activate === 'function') { + await module.activate(context); + } + + return context.subscriptions; } /** diff --git a/superset-frontend/src/extensions/ExtensionsStartup.test.tsx b/superset-frontend/src/extensions/ExtensionsStartup.test.tsx index 4a8d92c8d8a7..bdc646423bfa 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.test.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.test.tsx @@ -72,6 +72,7 @@ afterEach(() => { test('renders without crashing', () => { render(, { useRedux: true, + useRouter: true, initialState: mockInitialState, }); @@ -88,6 +89,7 @@ test('sets up global superset object when user is logged in', async () => { render(, { useRedux: true, + useRouter: true, initialState: mockInitialState, }); @@ -109,6 +111,7 @@ test('sets up global superset object when user is logged in', async () => { test('does not set up global superset object when user is not logged in', async () => { render(, { useRedux: true, + useRouter: true, initialState: mockInitialStateNoUser, }); @@ -127,6 +130,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => { render(, { useRedux: true, + useRouter: true, initialState: mockInitialState, }); @@ -144,6 +148,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => { test('does not initialize ExtensionsLoader when user is not logged in', async () => { render(, { useRedux: true, + useRouter: true, initialState: mockInitialStateNoUser, }); @@ -169,6 +174,7 @@ test('only initializes once even with multiple renders', async () => { const { rerender } = render(, { useRedux: true, + useRouter: true, initialState: mockInitialState, }); @@ -205,6 +211,7 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled render(, { useRedux: true, + useRouter: true, initialState: mockInitialState, }); @@ -234,6 +241,7 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is render(, { useRedux: true, + useRouter: true, initialState: mockInitialState, }); @@ -268,6 +276,7 @@ test('continues rendering children even when ExtensionsLoader initialization fai , { useRedux: true, + useRouter: true, initialState: mockInitialState, }, ); diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx index beb5590220c1..87abcd4192e7 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -16,48 +16,66 @@ * specific language governing permissions and limitations * under the License. */ -import { useEffect, useState } from 'react'; -// eslint-disable-next-line no-restricted-syntax -import * as supersetCore from '@apache-superset/core'; +import { useEffect, useRef, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import { logging } from '@apache-superset/core/utils'; import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import { authentication, core, commands, + dashboard, + dataset, editors, + explore, extensions, menus, + navigation, sqlLab, views, } from 'src/core'; +import { notifyPageChange } from 'src/core/navigation'; import { useSelector } from 'react-redux'; import { RootState } from 'src/views/store'; import ExtensionsLoader from './ExtensionsLoader'; - -declare global { - interface Window { - superset: { - authentication: typeof authentication; - core: typeof core; - commands: typeof commands; - editors: typeof editors; - extensions: typeof extensions; - menus: typeof menus; - sqlLab: typeof sqlLab; - views: typeof views; - }; - } -} +// Side-effect import: brings the `window.superset` global augmentation into scope. +import 'src/extensions/supersetGlobal'; const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ children, }) => { const [initialized, setInitialized] = useState(false); + const location = useLocation(); + const prevPathname = useRef(null); const userId = useSelector( ({ user }) => user.userId, ); + // Notify the navigation namespace on every route change. + useEffect(() => { + if (prevPathname.current !== location.pathname) { + prevPathname.current = location.pathname; + notifyPageChange(location.pathname); + } + }, [location.pathname]); + + // Log unhandled rejections that may originate from extension code. + // Registered once for the lifetime of the app; does not suppress the + // browser's default error surfacing so host error reporting is unaffected. + useEffect(() => { + const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + logging.error('[extensions] Unhandled rejection:', event.reason); + }; + window.addEventListener('unhandledrejection', handleUnhandledRejection); + return () => { + window.removeEventListener( + 'unhandledrejection', + handleUnhandledRejection, + ); + }; + }, []); + useEffect(() => { if (initialized) return; @@ -67,27 +85,33 @@ 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, menus, + navigation, sqlLab, views, }; - const setup = async () => { - if (isFeatureEnabled(FeatureFlag.EnableExtensions)) { - await ExtensionsLoader.getInstance().initializeExtensions(); - } - setInitialized(true); - }; + // Render the host immediately; extension bundles load in the background. + // ChatbotMount re-resolves reactively once the chatbot extension registers + // (via subscribeToRegistry / getRegistryVersion), so the bubble appears + // without blocking the UI. + setInitialized(true); - setup(); + if (isFeatureEnabled(FeatureFlag.EnableExtensions)) { + ExtensionsLoader.getInstance().initializeExtensions(); + } }, [initialized, userId]); if (!initialized) { diff --git a/superset-frontend/src/extensions/supersetGlobal.ts b/superset-frontend/src/extensions/supersetGlobal.ts new file mode 100644 index 000000000000..70e5786e9999 --- /dev/null +++ b/superset-frontend/src/extensions/supersetGlobal.ts @@ -0,0 +1,64 @@ +/** + * 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. + */ + +/** + * Global `window.superset` type augmentation. + * + * Lives in its own module (rather than inline in ExtensionsStartup) so every + * file that reads or writes `window.superset` — notably ExtensionsLoader — + * sees the type regardless of how files are batched during compilation. Both + * the startup component and the loader import this module for its side effect. + */ + +import type { + authentication, + commands, + core, + dashboard, + dataset, + editors, + explore, + extensions, + menus, + navigation, + sqlLab, + views, +} from 'src/core'; + +/** The host namespaces exposed to extensions on `window.superset`. */ +export interface SupersetGlobal { + authentication: typeof authentication; + core: typeof core; + commands: typeof commands; + dashboard: typeof dashboard; + dataset: typeof dataset; + editors: typeof editors; + explore: typeof explore; + extensions: typeof extensions; + menus: typeof menus; + navigation: typeof navigation; + sqlLab: typeof sqlLab; + views: typeof views; +} + +declare global { + interface Window { + superset: SupersetGlobal; + } +} diff --git a/superset-frontend/src/features/datasets/AddDataset/EditDataset/EditDataset.test.tsx b/superset-frontend/src/features/datasets/AddDataset/EditDataset/EditDataset.test.tsx index 184e15d11c00..e854dc6e34e1 100644 --- a/superset-frontend/src/features/datasets/AddDataset/EditDataset/EditDataset.test.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/EditDataset/EditDataset.test.tsx @@ -21,12 +21,18 @@ import { render, screen } from 'spec/helpers/testing-library'; import EditDataset from './index'; const DATASET_ENDPOINT = 'glob:*api/v1/dataset/1/related_objects'; +// EditPage also fetches the dataset entity itself to publish the `dataset` +// extension-namespace context (setCurrentDataset). +const DATASET_RESOURCE_ENDPOINT = 'glob:*api/v1/dataset/1'; const mockedProps = { id: '1', }; fetchMock.get(DATASET_ENDPOINT, { charts: { results: [], count: 2 } }); +fetchMock.get(DATASET_RESOURCE_ENDPOINT, { + result: { id: 1, table_name: 'test_table', schema: 'public' }, +}); test('should render edit dataset view with tabs', async () => { render(); diff --git a/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx b/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx index e0438cee0bd6..b47e783a8b56 100644 --- a/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx @@ -16,9 +16,12 @@ * specific language governing permissions and limitations * under the License. */ +import { useEffect } from 'react'; import { t } from '@apache-superset/core/translation'; import { styled } from '@apache-superset/core/theme'; import useGetDatasetRelatedCounts from 'src/features/datasets/hooks/useGetDatasetRelatedCounts'; +import { useSingleViewResource } from 'src/views/CRUD/hooks'; +import { setCurrentDataset } from 'src/core/dataset'; import { Badge } from '@superset-ui/core/components'; import Tabs from '@superset-ui/core/components/Tabs'; @@ -47,6 +50,13 @@ interface EditPageProps { id: string; } +// Stable no-op error handler so `useSingleViewResource`'s `fetchResource` +// keeps a stable identity across renders (it lists the handler in its deps). +// An inline handler would change every render and re-trigger the fetch effect, +// causing an update loop. Fetch failure is non-fatal here — the dataset +// context simply stays empty. +const noopErrorHandler = () => {}; + const TRANSLATIONS = { USAGE_TEXT: t('Usage'), COLUMNS_TEXT: t('Columns'), @@ -62,6 +72,45 @@ const TABS_KEYS = { const EditPage = ({ id }: EditPageProps) => { const { usageCount } = useGetDatasetRelatedCounts(id); + // Publish the focused dataset to the `dataset` extension namespace so chatbot + // extensions can read which dataset the user is editing. Cleared on unmount. + const { + state: { resource: datasetResource }, + fetchResource, + } = useSingleViewResource<{ + id: number; + table_name?: string; + schema?: string | null; + catalog?: string | null; + sql?: string | null; + is_sqllab_view?: boolean; + database?: { database_name?: string }; + }>('dataset', t('dataset'), noopErrorHandler); + + useEffect(() => { + const datasetId = Number(id); + if (!Number.isNaN(datasetId)) { + fetchResource(datasetId); + } + // `fetchResource` is stable (noopErrorHandler keeps its identity fixed); + // fetch only when the id changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + useEffect(() => { + if (!datasetResource) return undefined; + setCurrentDataset({ + datasetId: datasetResource.id, + datasetName: datasetResource.table_name ?? String(datasetResource.id), + schema: datasetResource.schema ?? null, + catalog: datasetResource.catalog ?? null, + databaseName: datasetResource.database?.database_name ?? null, + isVirtual: + Boolean(datasetResource.sql) || !!datasetResource.is_sqllab_view, + }); + return () => setCurrentDataset(undefined); + }, [datasetResource]); + const usageTab = ( {TRANSLATIONS.USAGE_TEXT} diff --git a/superset-frontend/src/features/roles/RoleListEditModal.test.tsx b/superset-frontend/src/features/roles/RoleListEditModal.test.tsx index 9072f991f213..9380d4c04a6e 100644 --- a/superset-frontend/src/features/roles/RoleListEditModal.test.tsx +++ b/superset-frontend/src/features/roles/RoleListEditModal.test.tsx @@ -252,9 +252,7 @@ describe('RoleListEditModal', () => { const mockGet = SupersetClient.get as jest.Mock; mockGet.mockImplementation(({ endpoint }) => { if ( - endpoint?.includes( - `/api/v1/security/roles/${mockRole.id}/permissions/`, - ) + endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`) ) { // Only return permission id=10, not id=20 return Promise.resolve({ @@ -298,9 +296,7 @@ describe('RoleListEditModal', () => { const mockGet = SupersetClient.get as jest.Mock; mockGet.mockImplementation(({ endpoint }) => { if ( - endpoint?.includes( - `/api/v1/security/roles/${mockRole.id}/permissions/`, - ) + endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`) ) { return Promise.reject(new Error('network error')); } @@ -371,7 +367,9 @@ describe('RoleListEditModal', () => { }; mockGet.mockImplementation(({ endpoint }) => { - if (endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)) { + if ( + endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`) + ) { return Promise.resolve({ json: { result: roleA.permission_ids.map(pid => ({ @@ -382,7 +380,9 @@ describe('RoleListEditModal', () => { }, }); } - if (endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)) { + if ( + endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`) + ) { return Promise.resolve({ json: { result: roleB.permission_ids.map(pid => ({ diff --git a/superset-frontend/src/middleware/loggerMiddleware.ts b/superset-frontend/src/middleware/loggerMiddleware.ts index a41d96a53e62..3182bd18771f 100644 --- a/superset-frontend/src/middleware/loggerMiddleware.ts +++ b/superset-frontend/src/middleware/loggerMiddleware.ts @@ -33,7 +33,12 @@ import { ensureAppRoot } from '../utils/pathUtils'; import type { DashboardInfo, DashboardLayoutState } from '../dashboard/types'; import type { QueryEditor } from '../SqlLab/types'; -type LogEventSource = 'dashboard' | 'embedded_dashboard' | 'explore' | 'sqlLab' | 'slice'; +type LogEventSource = + | 'dashboard' + | 'embedded_dashboard' + | 'explore' + | 'sqlLab' + | 'slice'; interface LogEventData { source?: LogEventSource; diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 4f30a552e94e..d70aa0f86f91 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -38,7 +38,9 @@ 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'; import { ScrollToTop } from './ScrollToTop'; @@ -112,6 +114,13 @@ const App = () => ( ))} + {/* + 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). + */} + {isFeatureEnabled(FeatureFlag.EnableExtensions) && } diff --git a/superset-frontend/src/views/contributions.ts b/superset-frontend/src/views/contributions.ts new file mode 100644 index 000000000000..ec075222b23a --- /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; diff --git a/superset/commands/extension/__init__.py b/superset/commands/extension/__init__.py new file mode 100644 index 000000000000..13a83393a912 --- /dev/null +++ b/superset/commands/extension/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset/commands/extension/settings/__init__.py b/superset/commands/extension/settings/__init__.py new file mode 100644 index 000000000000..13a83393a912 --- /dev/null +++ b/superset/commands/extension/settings/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset/commands/extension/settings/exceptions.py b/superset/commands/extension/settings/exceptions.py new file mode 100644 index 000000000000..e3ac3807cf74 --- /dev/null +++ b/superset/commands/extension/settings/exceptions.py @@ -0,0 +1,27 @@ +# 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. +from flask_babel import lazy_gettext as _ + +from superset.commands.exceptions import CommandInvalidError, UpdateFailedError + + +class ExtensionSettingsInvalidError(CommandInvalidError): + message = _("Extension settings parameters are invalid.") + + +class ExtensionSettingsUpdateFailedError(UpdateFailedError): + message = _("Extension settings could not be updated.") diff --git a/superset/commands/extension/settings/get.py b/superset/commands/extension/settings/get.py new file mode 100644 index 000000000000..401dbfb449ea --- /dev/null +++ b/superset/commands/extension/settings/get.py @@ -0,0 +1,29 @@ +# 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. +from typing import Any + +from superset.commands.base import BaseCommand +from superset.daos.extension import get_extension_settings + + +class GetExtensionSettingsCommand(BaseCommand): + def run(self) -> dict[str, Any]: + self.validate() + return get_extension_settings() + + def validate(self) -> None: + return None diff --git a/superset/commands/extension/settings/update.py b/superset/commands/extension/settings/update.py new file mode 100644 index 000000000000..ed3ce883f779 --- /dev/null +++ b/superset/commands/extension/settings/update.py @@ -0,0 +1,68 @@ +# 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 logging +from functools import partial +from typing import Any + +from superset.commands.base import BaseCommand +from superset.commands.extension.settings.exceptions import ( + ExtensionSettingsUpdateFailedError, +) +from superset.daos.extension import ( + ExtensionEnabledDAO, + ExtensionSettingsDAO, + get_extension_settings, +) +from superset.utils.decorators import on_error, transaction + +logger = logging.getLogger(__name__) + + +class UpdateExtensionSettingsCommand(BaseCommand): + """Apply a partial update to global extension admin settings. + + The body is the already-validated output of ``ExtensionSettingsPutSchema`` + and may contain: + * active_chatbot_id: str | None — empty string is normalised to None. + * enabled: dict[str, bool] — per-extension toggle map. + + Keys not present in the body are left untouched. + """ + + def __init__(self, body: dict[str, Any]): + self._body = body or {} + + @transaction( + on_error=partial(on_error, reraise=ExtensionSettingsUpdateFailedError), + ) + def run(self) -> dict[str, Any]: + if "active_chatbot_id" in self._body: + value = self._body["active_chatbot_id"] + active_chatbot_id = str(value) if value else None + ExtensionSettingsDAO.upsert_active_chatbot_id(active_chatbot_id) + + enabled = self._body.get("enabled") + if isinstance(enabled, dict): + for extension_id, value in enabled.items(): + ExtensionEnabledDAO.upsert_enabled_flag(extension_id, value) + + return get_extension_settings() + + def validate(self) -> None: + # Request-shape validation is handled declaratively by + # ExtensionSettingsPutSchema at the route boundary. + pass diff --git a/superset/daos/extension.py b/superset/daos/extension.py new file mode 100644 index 000000000000..2397e3626639 --- /dev/null +++ b/superset/daos/extension.py @@ -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. +from typing import Any + +from superset import db +from superset.daos.base import BaseDAO +from superset.extensions.models import ExtensionEnabled, ExtensionSettings + +# The global extension settings live in a single row; id is fixed so the row +# can be fetched and upserted without a secondary lookup. +SETTINGS_ROW_ID = 1 + + +class ExtensionSettingsDAO(BaseDAO[ExtensionSettings]): + """Persistence for the singleton global extension settings row. + + The row (id=1) holds global admin state such as the active chatbot id. + Writes go through a check-then-write upsert that is dialect-agnostic; + callers wrap the operation in ``@transaction`` so the read-then-write + window is serialised and committed atomically. + """ + + @staticmethod + def get_settings_row() -> ExtensionSettings | None: + return db.session.get(ExtensionSettings, SETTINGS_ROW_ID) + + @classmethod + def upsert_active_chatbot_id(cls, active_chatbot_id: str | None) -> None: + if row := cls.get_settings_row(): + row.active_chatbot_id = active_chatbot_id + else: + cls.create( + attributes={ + "id": SETTINGS_ROW_ID, + "active_chatbot_id": active_chatbot_id, + } + ) + + +class ExtensionEnabledDAO(BaseDAO[ExtensionEnabled]): + """Persistence for per-extension enabled flags.""" + + id_column_name = "extension_id" + + @classmethod + def get_enabled_map(cls) -> dict[str, bool]: + return {row.extension_id: row.enabled for row in cls.find_all()} + + @classmethod + def upsert_enabled_flag(cls, extension_id: str, enabled: bool) -> None: + if row := cls.find_by_id(extension_id): + row.enabled = enabled + else: + cls.create(attributes={"extension_id": extension_id, "enabled": enabled}) + + +def get_extension_settings() -> dict[str, Any]: + """Read-only view of the combined extension settings.""" + row = ExtensionSettingsDAO.get_settings_row() + return { + "active_chatbot_id": row.active_chatbot_id if row else None, + "enabled": ExtensionEnabledDAO.get_enabled_map(), + } diff --git a/superset/extensions/api.py b/superset/extensions/api.py index b1b5734979ee..ba54a49c55d7 100644 --- a/superset/extensions/api.py +++ b/superset/extensions/api.py @@ -15,34 +15,65 @@ # 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 +from zipfile import is_zipfile, ZipFile -from flask import send_file +from flask import current_app, request, send_file from flask.wrappers import Response -from flask_appbuilder.api import BaseApi, expose, protect, safe +from flask_appbuilder.api import expose, protect, safe +from marshmallow import ValidationError +from superset.commands.extension.settings.get import GetExtensionSettingsCommand +from superset.commands.extension.settings.update import ( + UpdateExtensionSettingsCommand, +) +from superset.extensions import security_manager +from superset.extensions.schemas import ExtensionSettingsPutSchema 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 +from superset.views.base_api import BaseSupersetApi +# 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_-]+$") -class ExtensionsRestApi(BaseApi): - allow_browser_login = True - resource_name = "extensions" +# Default 10 MB server-side upload limit; can be overridden via config. +_DEFAULT_MAX_UPLOAD_BYTES = 10 * 1024 * 1024 - def response(self, status_code: int, **kwargs: Any) -> Response: - """Helper method to create JSON responses.""" - from flask import jsonify - return jsonify(kwargs), status_code +def _validate_segment(value: str) -> bool: + """Return True if *value* is a safe publisher or name segment.""" + return bool(_SEGMENT_RE.match(value)) - def response_404(self) -> Response: - """Helper method to create 404 responses.""" - from flask import jsonify - return jsonify({"message": "Not found"}), 404 +class ExtensionsRestApi(BaseSupersetApi): + allow_browser_login = True + resource_name = "extensions" + # BaseSupersetApi already defaults csrf_exempt to False; kept explicit + # because these endpoints use cookie/session auth (allow_browser_login) + # and include state-changing routes (settings PUT, upload POST, delete). + csrf_exempt = False + 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", + ] @expose("/_info", methods=("GET",)) @protect() @@ -158,7 +189,8 @@ def get(self, publisher: str, name: str, **kwargs: Any) -> Response: 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) @@ -167,6 +199,259 @@ def get(self, publisher: str, name: str, **kwargs: Any) -> Response: 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.") + + 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.") + + 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}") + + # Validate the manifest id before using it as a filename component. + # The id is publisher.name (e.g. "acme.chatbot"); each segment must pass + # _validate_segment so a crafted bundle cannot write outside EXTENSIONS_PATH + # even though the admin is trusted — defence-in-depth against third-party + # bundles the admin did not author. + manifest_id: str = extension.manifest.id + id_parts = manifest_id.split(".", 1) + if len(id_parts) != 2 or not all( # noqa: PLR2004 + _validate_segment(p) for p in id_parts + ): + return self.response( + 400, + message=( + f"Invalid extension id '{manifest_id}' in manifest. " + "Expected '.' with alphanumeric, hyphen, " + "or underscore characters only." + ), + ) + + # 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 manifest_id in local_ids: + return self.response( + 409, + message=( + f"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 filename is built from the validated manifest id, not from the + # uploaded filename, so neither can escape EXTENSIONS_PATH. + dest_dir = Path(extensions_path) + dest_dir.mkdir(parents=True, exist_ok=True) + dest_file = dest_dir / f"{manifest_id}.supx" + + stream.seek(0) + dest_file.write_bytes(stream.read()) + + return self.response(201, result=build_extension_data(extension)) + + @protect() + @safe + @expose("//", 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.") + + 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) + 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",)) + def get_settings(self, **kwargs: Any) -> Response: + """Get global extension admin settings. + + No admin gate here by design: authenticated non-admin users need these + settings so the ChatbotMount can read active_chatbot_id on every page. + --- + get: + summary: Get extension admin settings (active chatbot, enabled flags). + responses: + 200: + description: Extension settings + """ + return self.response(200, result=GetExtensionSettingsCommand().run()) + + @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 + 400: + $ref: '#/components/responses/400' + 403: + $ref: '#/components/responses/403' + """ + if not security_manager.is_admin(): + return self.response(403, message="Admin access required.") + try: + body = ExtensionSettingsPutSchema().load(request.json or {}) + except ValidationError as error: + return self.response(400, message=error.messages) + return self.response(200, result=UpdateExtensionSettingsCommand(body).run()) + @protect() @safe @expose("///", methods=("GET",)) @@ -210,7 +495,8 @@ def content(self, publisher: str, name: str, file: str) -> Response: 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/superset/extensions/models.py b/superset/extensions/models.py new file mode 100644 index 000000000000..caf2479d5a32 --- /dev/null +++ b/superset/extensions/models.py @@ -0,0 +1,41 @@ +# 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 + +# Shared column length for extension/chatbot identifiers; reused by request +# validation so oversized keys are rejected with a 400 before hitting the DB. +EXTENSION_ID_MAX_LENGTH = 250 + + +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(EXTENSION_ID_MAX_LENGTH), nullable=True) + + +class ExtensionEnabled(Model): # pylint: disable=too-few-public-methods + """Per-extension enable/disable flag.""" + + __tablename__ = "extension_enabled" + extension_id = Column(String(EXTENSION_ID_MAX_LENGTH), primary_key=True) + enabled = Column(Boolean, nullable=False, default=True) diff --git a/superset/extensions/schemas.py b/superset/extensions/schemas.py new file mode 100644 index 000000000000..0742125fae58 --- /dev/null +++ b/superset/extensions/schemas.py @@ -0,0 +1,44 @@ +# 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. +"""Marshmallow schemas for the extensions REST API.""" + +from marshmallow import fields, Schema +from marshmallow.validate import Length + +from superset.extensions.models import EXTENSION_ID_MAX_LENGTH + + +class ExtensionSettingsPutSchema(Schema): + """Validate the partial update body for the extension settings PUT route. + + Both fields are optional so the update is a partial patch: keys absent from + the payload are left untouched. An empty-string ``active_chatbot_id`` is a + valid "clear" signal that the command normalises to ``None``. + """ + + active_chatbot_id = fields.String( + allow_none=True, + validate=Length(max=EXTENSION_ID_MAX_LENGTH), + metadata={"description": "Id of the chatbot to render, or null to clear."}, + ) + enabled = fields.Dict( + keys=fields.String(validate=Length(min=1, max=EXTENSION_ID_MAX_LENGTH)), + # Strict booleans: reject non-bool values (e.g. "yes") rather than + # coercing them, so a malformed toggle map is a 400, not a silent write. + values=fields.Boolean(truthy={True}, falsy={False}), + metadata={"description": "Per-extension enabled flags keyed by extension id."}, + ) diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py index 6332c97435d0..4a51ed1d288a 100644 --- a/superset/extensions/utils.py +++ b/superset/extensions/utils.py @@ -232,12 +232,18 @@ 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, "name": extension.name, "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 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 000000000000..a443b9de8b15 --- /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: 33d7e0e21daa +Create Date: 2026-05-25 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +revision = "b2c3d4e5f6a7" +down_revision = "33d7e0e21daa" + + +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/__init__.py b/superset/models/__init__.py index 750c61ccc23d..639e3c61fb9f 100644 --- a/superset/models/__init__.py +++ b/superset/models/__init__.py @@ -14,4 +14,6 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from superset.extensions import models as extensions_models # noqa: F401 + from . import core, dynamic_plugins, sql_lab, user_attributes # noqa: F401 diff --git a/superset/models/core.py b/superset/models/core.py index 0d22bd9b29bc..6ffa88c1e698 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -118,6 +118,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""" diff --git a/tests/unit_tests/extensions/test_api.py b/tests/unit_tests/extensions/test_api.py new file mode 100644 index 000000000000..a1fe9d7269c7 --- /dev/null +++ b/tests/unit_tests/extensions/test_api.py @@ -0,0 +1,417 @@ +# 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 + +import pytest + +from superset.extensions.api import _validate_segment + +# The extension routes are only registered when ENABLE_EXTENSIONS is on at +# app-init time, so the endpoint tests parametrize the app fixture to enable it +# (otherwise the route is absent and requests 404). +_ENABLE_EXTENSIONS = [{"FEATURE_FLAGS": {"ENABLE_EXTENSIONS": True}}] + +# --------------------------------------------------------------------------- +# _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 +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("app", _ENABLE_EXTENSIONS, indirect=True) +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_hostile_manifest_id_rejected( + self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path + ) -> None: + """A crafted manifest.id with path traversal must not escape EXTENSIONS_PATH.""" + 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("../../tmp/evil") + 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("../../tmp/evil") + resp = client.post( + "/api/v1/extensions/", + data={"bundle": (io.BytesIO(supx), "ext.supx")}, + content_type="multipart/form-data", + ) + assert resp.status_code == 400 + assert "Invalid extension id" 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// +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("app", _ENABLE_EXTENSIONS, indirect=True) +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 diff --git a/tests/unit_tests/extensions/test_settings.py b/tests/unit_tests/extensions/test_settings.py new file mode 100644 index 000000000000..78f58a82de5f --- /dev/null +++ b/tests/unit_tests/extensions/test_settings.py @@ -0,0 +1,371 @@ +# 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 extension settings persistence and the settings API endpoints. + +Persistence is exercised through the public Command + DAO layer: +``UpdateExtensionSettingsCommand`` / ``GetExtensionSettingsCommand`` and the +``ExtensionSettingsDAO`` / ``ExtensionEnabledDAO`` they delegate to. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +# --------------------------------------------------------------------------- +# Settings persistence (Command + DAO) — sqlite-backed round-trip tests +# --------------------------------------------------------------------------- + + +class TestGetExtensionSettings: + def test_returns_defaults_when_no_rows(self, app_context: Any) -> None: + from superset.commands.extension.settings.get import ( + GetExtensionSettingsCommand, + ) + + result = GetExtensionSettingsCommand().run() + assert result["active_chatbot_id"] is None + assert result["enabled"] == {} + + def test_round_trips_active_chatbot_id(self, app_context: Any) -> None: + from superset.commands.extension.settings.get import ( + GetExtensionSettingsCommand, + ) + from superset.commands.extension.settings.update import ( + UpdateExtensionSettingsCommand, + ) + + UpdateExtensionSettingsCommand({"active_chatbot_id": "acme.chatbot"}).run() + result = GetExtensionSettingsCommand().run() + assert result["active_chatbot_id"] == "acme.chatbot" + + def test_round_trips_enabled_flags(self, app_context: Any) -> None: + from superset.commands.extension.settings.get import ( + GetExtensionSettingsCommand, + ) + from superset.commands.extension.settings.update import ( + UpdateExtensionSettingsCommand, + ) + + UpdateExtensionSettingsCommand( + {"enabled": {"acme.chatbot": True, "acme.widget": False}} + ).run() + result = GetExtensionSettingsCommand().run() + assert result["enabled"]["acme.chatbot"] is True + assert result["enabled"]["acme.widget"] is False + + +class TestUpdateExtensionSettings: + def test_empty_string_active_chatbot_id_stored_as_none( + self, app_context: Any + ) -> None: + from superset.commands.extension.settings.get import ( + GetExtensionSettingsCommand, + ) + from superset.commands.extension.settings.update import ( + UpdateExtensionSettingsCommand, + ) + + # First set a value, then clear it via empty string. + UpdateExtensionSettingsCommand({"active_chatbot_id": "acme.chatbot"}).run() + UpdateExtensionSettingsCommand({"active_chatbot_id": ""}).run() + assert GetExtensionSettingsCommand().run()["active_chatbot_id"] is None + + def test_enabled_flags_are_persisted(self, app_context: Any) -> None: + from superset.commands.extension.settings.get import ( + GetExtensionSettingsCommand, + ) + from superset.commands.extension.settings.update import ( + UpdateExtensionSettingsCommand, + ) + + # The command trusts already-validated input (request-shape validation + # is the schema's job — see TestExtensionSettingsPutSchema), so a bool + # value is written through as-is. + UpdateExtensionSettingsCommand({"enabled": {"acme.persisted": False}}).run() + result = GetExtensionSettingsCommand().run() + assert result["enabled"]["acme.persisted"] is False + + def test_upsert_overwrites_existing_chatbot(self, app_context: Any) -> None: + from superset.commands.extension.settings.get import ( + GetExtensionSettingsCommand, + ) + from superset.commands.extension.settings.update import ( + UpdateExtensionSettingsCommand, + ) + + UpdateExtensionSettingsCommand({"active_chatbot_id": "acme.chatbot"}).run() + UpdateExtensionSettingsCommand({"active_chatbot_id": "vendor.bot"}).run() + assert GetExtensionSettingsCommand().run()["active_chatbot_id"] == "vendor.bot" + + def test_upsert_overwrites_existing_enabled_flag(self, app_context: Any) -> None: + from superset.commands.extension.settings.get import ( + GetExtensionSettingsCommand, + ) + from superset.commands.extension.settings.update import ( + UpdateExtensionSettingsCommand, + ) + + UpdateExtensionSettingsCommand({"enabled": {"acme.chatbot": True}}).run() + UpdateExtensionSettingsCommand({"enabled": {"acme.chatbot": False}}).run() + assert GetExtensionSettingsCommand().run()["enabled"]["acme.chatbot"] is False + + def test_partial_update_leaves_other_keys_intact(self, app_context: Any) -> None: + from superset.commands.extension.settings.get import ( + GetExtensionSettingsCommand, + ) + from superset.commands.extension.settings.update import ( + UpdateExtensionSettingsCommand, + ) + + UpdateExtensionSettingsCommand( + {"active_chatbot_id": "acme.chatbot", "enabled": {"acme.widget": True}} + ).run() + # Update only enabled — active_chatbot_id must survive. + UpdateExtensionSettingsCommand({"enabled": {"acme.widget": False}}).run() + result = GetExtensionSettingsCommand().run() + assert result["active_chatbot_id"] == "acme.chatbot" + assert result["enabled"]["acme.widget"] is False + + def test_returns_current_state(self, app_context: Any) -> None: + from superset.commands.extension.settings.update import ( + UpdateExtensionSettingsCommand, + ) + + result = UpdateExtensionSettingsCommand( + {"active_chatbot_id": "acme.chatbot"} + ).run() + assert result["active_chatbot_id"] == "acme.chatbot" + + +class TestExtensionSettingsPutSchema: + """Request-shape validation now lives in ExtensionSettingsPutSchema.""" + + def test_non_string_active_chatbot_id_raises(self, app_context: Any) -> None: + from marshmallow import ValidationError + + from superset.extensions.schemas import ExtensionSettingsPutSchema + + with pytest.raises(ValidationError): + ExtensionSettingsPutSchema().load({"active_chatbot_id": 123}) + + def test_oversized_active_chatbot_id_raises(self, app_context: Any) -> None: + from marshmallow import ValidationError + + from superset.extensions.models import EXTENSION_ID_MAX_LENGTH + from superset.extensions.schemas import ExtensionSettingsPutSchema + + with pytest.raises(ValidationError): + ExtensionSettingsPutSchema().load( + {"active_chatbot_id": "x" * (EXTENSION_ID_MAX_LENGTH + 1)} + ) + + def test_oversized_enabled_key_raises(self, app_context: Any) -> None: + from marshmallow import ValidationError + + from superset.extensions.models import EXTENSION_ID_MAX_LENGTH + from superset.extensions.schemas import ExtensionSettingsPutSchema + + with pytest.raises(ValidationError): + ExtensionSettingsPutSchema().load( + {"enabled": {"x" * (EXTENSION_ID_MAX_LENGTH + 1): True}} + ) + + def test_non_bool_enabled_value_raises(self, app_context: Any) -> None: + from marshmallow import ValidationError + + from superset.extensions.schemas import ExtensionSettingsPutSchema + + with pytest.raises(ValidationError): + ExtensionSettingsPutSchema().load({"enabled": {"acme.ext": "yes"}}) + + def test_null_active_chatbot_id_is_valid(self, app_context: Any) -> None: + from superset.extensions.schemas import ExtensionSettingsPutSchema + + loaded = ExtensionSettingsPutSchema().load({"active_chatbot_id": None}) + assert loaded["active_chatbot_id"] is None + + +# --------------------------------------------------------------------------- +# GET /api/v1/extensions/settings +# --------------------------------------------------------------------------- + +# The settings routes are only registered when ENABLE_EXTENSIONS is on at +# app-init time, so the endpoint tests parametrize the app fixture to enable it +# (otherwise the route is absent and requests 404). +_ENABLE_EXTENSIONS = [{"FEATURE_FLAGS": {"ENABLE_EXTENSIONS": True}}] + + +@pytest.mark.parametrize("app", _ENABLE_EXTENSIONS, indirect=True) +class TestGetSettingsEndpoint: + def test_authenticated_user_can_read( + self, client: Any, full_api_access: None, mocker: Any + ) -> None: + mocker.patch( + "superset.extensions.api.GetExtensionSettingsCommand.run", + return_value={"active_chatbot_id": None, "enabled": {}}, + ) + resp = client.get("/api/v1/extensions/settings") + assert resp.status_code == 200 + assert resp.json["result"]["active_chatbot_id"] is None + + def test_returns_active_chatbot_and_enabled_map( + self, client: Any, full_api_access: None, mocker: Any + ) -> None: + mocker.patch( + "superset.extensions.api.GetExtensionSettingsCommand.run", + return_value={ + "active_chatbot_id": "acme.chatbot", + "enabled": {"acme.chatbot": True}, + }, + ) + resp = client.get("/api/v1/extensions/settings") + assert resp.status_code == 200 + assert resp.json["result"]["active_chatbot_id"] == "acme.chatbot" + assert resp.json["result"]["enabled"]["acme.chatbot"] is True + + +# --------------------------------------------------------------------------- +# PUT /api/v1/extensions/settings +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("app", _ENABLE_EXTENSIONS, indirect=True) +class TestPutSettingsEndpoint: + 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.put( + "/api/v1/extensions/settings", + json={"active_chatbot_id": "acme.chatbot"}, + ) + assert resp.status_code == 403 + + def test_admin_can_update_active_chatbot( + 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.UpdateExtensionSettingsCommand.run", + return_value={"active_chatbot_id": "acme.chatbot", "enabled": {}}, + ) + resp = client.put( + "/api/v1/extensions/settings", + json={"active_chatbot_id": "acme.chatbot"}, + ) + assert resp.status_code == 200 + assert resp.json["result"]["active_chatbot_id"] == "acme.chatbot" + + def test_empty_body_is_accepted( + 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.UpdateExtensionSettingsCommand.run", + return_value={"active_chatbot_id": None, "enabled": {}}, + ) + resp = client.put("/api/v1/extensions/settings", json={}) + assert resp.status_code == 200 + + def test_non_object_body_rejected( + self, client: Any, full_api_access: None, mocker: Any + ) -> None: + mocker.patch( + "superset.extensions.api.security_manager.is_admin", return_value=True + ) + run = mocker.patch( + "superset.extensions.api.UpdateExtensionSettingsCommand.run", + ) + resp = client.put("/api/v1/extensions/settings", json=["not", "an", "object"]) + assert resp.status_code == 400 + run.assert_not_called() + + def test_non_string_active_chatbot_id_rejected( + self, client: Any, full_api_access: None, mocker: Any + ) -> None: + mocker.patch( + "superset.extensions.api.security_manager.is_admin", return_value=True + ) + run = mocker.patch( + "superset.extensions.api.UpdateExtensionSettingsCommand.run", + ) + # An int must be rejected with a 400, not silently coerced to null. + resp = client.put( + "/api/v1/extensions/settings", json={"active_chatbot_id": 123} + ) + assert resp.status_code == 400 + run.assert_not_called() + + def test_null_active_chatbot_id_is_accepted( + 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.UpdateExtensionSettingsCommand.run", + return_value={"active_chatbot_id": None, "enabled": {}}, + ) + resp = client.put( + "/api/v1/extensions/settings", json={"active_chatbot_id": None} + ) + assert resp.status_code == 200 + + def test_oversized_active_chatbot_id_rejected( + self, client: Any, full_api_access: None, mocker: Any + ) -> None: + from superset.extensions.models import EXTENSION_ID_MAX_LENGTH + + mocker.patch( + "superset.extensions.api.security_manager.is_admin", return_value=True + ) + run = mocker.patch( + "superset.extensions.api.UpdateExtensionSettingsCommand.run", + ) + resp = client.put( + "/api/v1/extensions/settings", + json={"active_chatbot_id": "x" * (EXTENSION_ID_MAX_LENGTH + 1)}, + ) + assert resp.status_code == 400 + run.assert_not_called() + + def test_oversized_enabled_key_rejected( + self, client: Any, full_api_access: None, mocker: Any + ) -> None: + from superset.extensions.models import EXTENSION_ID_MAX_LENGTH + + mocker.patch( + "superset.extensions.api.security_manager.is_admin", return_value=True + ) + run = mocker.patch( + "superset.extensions.api.UpdateExtensionSettingsCommand.run", + ) + resp = client.put( + "/api/v1/extensions/settings", + json={"enabled": {"x" * (EXTENSION_ID_MAX_LENGTH + 1): True}}, + ) + assert resp.status_code == 400 + run.assert_not_called() From c65c9523aaf8fcc02ea888af517b090bde5259ac Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:20:40 +0200 Subject: [PATCH 02/10] refactor(extensions): remove install/lifecycle/dependency machinery (#40916) --- .../src/superset_core/extensions/types.py | 4 - .../src/superset_extensions_cli/cli.py | 1 - .../tests/test_cli_build.py | 3 - .../superset-core/src/common/index.ts | 24 +- .../ChatbotMount/ChatbotMount.test.tsx | 47 ++- .../src/components/ChatbotMount/index.tsx | 22 +- .../src/core/chatbot/index.test.ts | 35 -- superset-frontend/src/core/chatbot/index.ts | 33 +- .../src/core/extensions/index.ts | 41 +- .../src/extensions/ExtensionsList.test.tsx | 319 --------------- .../src/extensions/ExtensionsList.tsx | 353 ---------------- .../src/extensions/ExtensionsLoader.test.ts | 59 +-- .../src/extensions/ExtensionsLoader.ts | 96 +---- superset-frontend/src/views/routes.tsx | 11 - .../commands/extension/settings/exceptions.py | 27 -- .../commands/extension/settings/update.py | 68 ---- superset/daos/extension.py | 39 +- superset/extensions/api.py | 268 +------------ superset/extensions/models.py | 13 +- superset/extensions/schemas.py | 44 -- superset/extensions/utils.py | 6 - superset/initialization/__init__.py | 12 - ...00_d1e2f3a4b5c6_drop_extension_enabled.py} | 37 +- superset/models/core.py | 16 - tests/unit_tests/extensions/test_api.py | 377 +----------------- tests/unit_tests/extensions/test_settings.py | 304 +------------- tests/unit_tests/extensions/test_types.py | 3 - 27 files changed, 130 insertions(+), 2132 deletions(-) delete mode 100644 superset-frontend/src/extensions/ExtensionsList.test.tsx delete mode 100644 superset-frontend/src/extensions/ExtensionsList.tsx delete mode 100644 superset/commands/extension/settings/exceptions.py delete mode 100644 superset/commands/extension/settings/update.py delete mode 100644 superset/extensions/schemas.py rename superset/{extensions/view.py => migrations/versions/2026-06-09_00-00_d1e2f3a4b5c6_drop_extension_enabled.py} (54%) diff --git a/superset-core/src/superset_core/extensions/types.py b/superset-core/src/superset_core/extensions/types.py index bfaeba5a43c5..6891c362d7fe 100644 --- a/superset-core/src/superset_core/extensions/types.py +++ b/superset-core/src/superset_core/extensions/types.py @@ -69,10 +69,6 @@ class BaseExtension(BaseModel): default=None, description="Extension description", ) - dependencies: list[str] = Field( - default_factory=list, - description="List of extension IDs this extension depends on", - ) permissions: list[str] = Field( default_factory=list, description="Permissions required by this extension", diff --git a/superset-extensions-cli/src/superset_extensions_cli/cli.py b/superset-extensions-cli/src/superset_extensions_cli/cli.py index c2adb5ea1e0d..2e4f2f42ef40 100644 --- a/superset-extensions-cli/src/superset_extensions_cli/cli.py +++ b/superset-extensions-cli/src/superset_extensions_cli/cli.py @@ -179,7 +179,6 @@ def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest: displayName=extension.displayName, version=extension.version, permissions=extension.permissions, - dependencies=extension.dependencies, frontend=frontend, backend=backend, ) diff --git a/superset-extensions-cli/tests/test_cli_build.py b/superset-extensions-cli/tests/test_cli_build.py index 6e2a5def2734..557938780fd0 100644 --- a/superset-extensions-cli/tests/test_cli_build.py +++ b/superset-extensions-cli/tests/test_cli_build.py @@ -283,7 +283,6 @@ def test_build_manifest_creates_correct_manifest_structure( "displayName": "Test Extension", "version": "1.0.0", "permissions": ["read_data"], - "dependencies": ["some_dep"], } extension_json = isolated_filesystem / "extension.json" extension_json.write_text(json.dumps(extension_data)) @@ -297,7 +296,6 @@ def test_build_manifest_creates_correct_manifest_structure( assert manifest.displayName == "Test Extension" assert manifest.version == "1.0.0" assert manifest.permissions == ["read_data"] - assert manifest.dependencies == ["some_dep"] # Verify frontend section assert manifest.frontend is not None @@ -330,7 +328,6 @@ def test_build_manifest_handles_minimal_extension(isolated_filesystem): assert manifest.displayName == "Minimal Extension" assert manifest.version == "0.1.0" assert manifest.permissions == [] - assert manifest.dependencies == [] # Default empty list assert manifest.frontend is None assert manifest.backend is None diff --git a/superset-frontend/packages/superset-core/src/common/index.ts b/superset-frontend/packages/superset-core/src/common/index.ts index 8f8f5463c00c..38cf4664d669 100644 --- a/superset-frontend/packages/superset-core/src/common/index.ts +++ b/superset-frontend/packages/superset-core/src/common/index.ts @@ -216,12 +216,9 @@ export declare interface Event { /** * Context handed to an extension's `activate` function. * - * The extension binds the lifetime of everything it registers to this object by - * pushing the returned {@link Disposable}s onto `subscriptions`. Because the - * context is owned by the extension for as long as it is active, registrations - * performed asynchronously (after an `await`, in a timer, or in an event - * callback) are tracked just the same as synchronous ones — the host disposes - * the whole `subscriptions` array on deactivation. + * `context.subscriptions` is provided for extensions to push their + * {@link Disposable}s into. The host provides the array but does not dispose + * it (lifecycle management is deferred). * * @example * ```typescript @@ -234,8 +231,8 @@ export declare interface Event { */ export interface ExtensionContext { /** - * Disposables to be cleaned up when the extension is deactivated. Push every - * {@link Disposable} returned by a `register*` call here. + * Disposables pushed by the extension. Provided for extensions to track + * their own registrations; the host does not dispose them. */ subscriptions: { dispose(): void }[]; } @@ -247,8 +244,7 @@ export interface ExtensionContext { * their registrations are tracked via `context.subscriptions` regardless of * whether they run synchronously or asynchronously. For backward compatibility, * a module may instead register its contributions as top-level side effects when - * the module is evaluated; such registrations are only tracked when performed - * synchronously during module evaluation. + * the module is evaluated. */ export interface ExtensionModule { /** @@ -256,10 +252,6 @@ export interface ExtensionModule { * host awaits it before considering the extension active. */ activate?(context: ExtensionContext): void | Promise; - /** - * Optional hook called before the host disposes `context.subscriptions`. - */ - deactivate?(): void | Promise; } /** @@ -268,12 +260,8 @@ export interface ExtensionModule { * by registering commands, views, menus, and editors as module-level side effects. */ export interface Extension { - /** List of other extensions that this extension depends on */ - dependencies: string[]; /** Human-readable description of the extension */ description: string; - /** List of other extensions that this extension depends on */ - extensionDependencies: string[]; /** Unique identifier for the extension */ id: string; /** Human-readable name of the extension */ diff --git a/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx index 35c08a5e9581..da19c7cca52f 100644 --- a/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx +++ b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx @@ -16,34 +16,41 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; import { render, screen } from 'spec/helpers/testing-library'; +import { SupersetClient } from '@superset-ui/core'; import { views } from 'src/core'; -import { setExtensionSettings } from 'src/core/extensions'; +import { loadExtensionSettings } from 'src/core/extensions'; import { CHATBOT_LOCATION } from 'src/views/contributions'; import ChatbotMount from '.'; const disposables: Array<{ dispose: () => void }> = []; -beforeEach(() => { - // The settings store is a module singleton; reset it so resolution starts - // from the empty default (no admin pin, all enabled) regardless of run order. - setExtensionSettings({ active_chatbot_id: null, enabled: {} }); +beforeEach(async () => { + // The settings store is a module singleton; reset it to the empty default + // (no admin pin) before each test by loading from a mocked API response. + jest.spyOn(SupersetClient, 'get').mockResolvedValue({ + json: { result: { active_chatbot_id: null } }, + } as any); + await loadExtensionSettings(); }); afterEach(() => { disposables.forEach(d => d.dispose()); disposables.length = 0; + jest.restoreAllMocks(); }); -test('renders nothing when no chatbot extension is registered', () => { +test('renders nothing when no chatbot extension is registered', async () => { render(); + // Wait a tick for the settings load to resolve; the corner must stay empty + // even after the gate opens (no chatbot registered → nothing to render). + await Promise.resolve(); 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'); +test('renders the registered chatbot inside the fixed mount slot', async () => { + const provider = () =>
My Chatbot Bubble
; disposables.push( views.registerView( { id: 'superset.chatbot', name: 'Superset Chatbot' }, @@ -54,14 +61,14 @@ test('renders the registered chatbot inside the fixed mount slot', () => { render(); - expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument(); + // findBy* awaits the re-render after the initial settings load resolves. + expect(await screen.findByTestId('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'); +test('renders only the first-to-register chatbot when several are installed', async () => { + const firstProvider = () =>
First Bubble
; + const secondProvider = () =>
Second Bubble
; disposables.push( views.registerView( { id: 'first.chatbot', name: 'First Chatbot' }, @@ -77,11 +84,11 @@ test('renders only the first-to-register chatbot when several are installed', () render(); - expect(screen.getByText('First Bubble')).toBeInTheDocument(); + expect(await screen.findByText('First Bubble')).toBeInTheDocument(); expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument(); }); -test('isolates a failing chatbot so it does not crash the host', () => { +test('isolates a failing chatbot so it does not crash the host', async () => { const FailingChatbot = () => { throw new Error('chatbot blew up'); }; @@ -89,15 +96,18 @@ test('isolates a failing chatbot so it does not crash the host', () => { 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()).not.toThrow(); + // The mount slot still renders post-gate (the boundary lives inside it); + // awaiting it confirms the provider was actually exercised and contained. + expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument(); }); -test('isolates a chatbot whose provider function itself throws', () => { +test('isolates a chatbot whose provider function itself throws', async () => { disposables.push( views.registerView( { id: 'superset.chatbot', name: 'Superset Chatbot' }, @@ -111,4 +121,5 @@ test('isolates a chatbot whose provider function itself throws', () => { // ChatbotRenderer wraps provider() in a component so ErrorBoundary catches // synchronous throws from the provider function, not just from its output. expect(() => render()).not.toThrow(); + expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument(); }); diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx b/superset-frontend/src/components/ChatbotMount/index.tsx index 0e07dff9a6a8..af213baa61e7 100644 --- a/superset-frontend/src/components/ChatbotMount/index.tsx +++ b/superset-frontend/src/components/ChatbotMount/index.tsx @@ -21,6 +21,7 @@ import { useEffect, useMemo, useRef, + useState, useSyncExternalStore, } from 'react'; import { t } from '@apache-superset/core/translation'; @@ -52,10 +53,15 @@ const ChatbotMount = () => { const theme = useTheme(); // Notify once per mount; a crash can re-render and would otherwise re-toast. const crashNotified = useRef(false); + // Defer chatbot resolution until the first settings load resolves. Otherwise + // the initial empty-default snapshot (no pin) would briefly resolve the + // first-registered chatbot even when the DB pins a different one, mounting + // the wrong provider until the async settings response arrives. + const [settingsLoaded, setSettingsLoaded] = useState(false); // The active chatbot is a function of two host-owned stores: the admin - // settings (active id + enabled map) and the view registry (which chatbots - // are registered). Both are read via useSyncExternalStore so this re-resolves + // settings (active chatbot id) and the view registry (which chatbots are + // registered). Both are read via useSyncExternalStore so this re-resolves // when either changes — no local copy of the settings state. const settings = useSyncExternalStore( subscribeToExtensionSettings, @@ -68,16 +74,20 @@ const ChatbotMount = () => { useEffect(() => { // Settings fetch failure is non-fatal: the store keeps its empty default, - // which getActiveChatbot treats as "all enabled, no admin pin". - loadExtensionSettings().catch(() => {}); + // which getActiveChatbot treats as "no admin pin" (falls back to the + // first-registered chatbot). Either way, unblock rendering once the request + // settles so a failed fetch never permanently hides the chatbot. + loadExtensionSettings() + .catch(() => {}) + .finally(() => setSettingsLoaded(true)); }, []); const activeChatbot = useMemo( - () => getActiveChatbot(settings.active_chatbot_id, settings.enabled), + () => getActiveChatbot(settings.active_chatbot_id), [settings, registryVersion], ); - if (!activeChatbot) { + if (!settingsLoaded || !activeChatbot) { return null; } diff --git a/superset-frontend/src/core/chatbot/index.test.ts b/superset-frontend/src/core/chatbot/index.test.ts index 80cd1d51e955..c9e12e6527d3 100644 --- a/superset-frontend/src/core/chatbot/index.test.ts +++ b/superset-frontend/src/core/chatbot/index.test.ts @@ -130,38 +130,3 @@ test('getActiveChatbot falls back to first-registered when pinned id is unknown' 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/core/chatbot/index.ts b/superset-frontend/src/core/chatbot/index.ts index 6ed14f388b68..029b0890e32f 100644 --- a/superset-frontend/src/core/chatbot/index.ts +++ b/superset-frontend/src/core/chatbot/index.ts @@ -48,40 +48,31 @@ export interface ActiveChatbot { * * 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. + * - If `adminSelectedId` matches a registered chatbot, that one wins. + * - Otherwise the first-registered chatbot is used as a fallback. + * The active chatbot pin is set only via the backend DB; when no pin is set + * (active_chatbot_id is null), the fallback is the first-registered chatbot. * - * @param adminSelectedId The id stored in the admin "Default chatbot" setting, if any. - * @param enabledMap Per-extension enabled flags from the admin settings API. + * @param adminSelectedId The id stored in the DB "Default chatbot" setting, if any. * @returns The active chatbot's id and provider, or `undefined` if none. */ export const getActiveChatbot = ( adminSelectedId?: string | null, - enabledMap?: Record, ): 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; - } - - // Mirror SIP §4.3's resolution table directly: when the admin pin names an - // enabled candidate, use it; otherwise use the first enabled candidate in - // registration order. `getRegisteredViewIds` and `getViewProvider` read the - // same synchronous registry maps, so a candidate id always has a live - // provider; the final guard is cheap defensiveness, not a fallback path. + // When the DB pin names a registered candidate, use it; otherwise fall back + // to the first registered chatbot in registration order. + // `getRegisteredViewIds` and `getViewProvider` read the same synchronous + // registry maps, so a candidate id always has a live provider; the final + // guard is cheap defensiveness, not a fallback path. const selectedId = - adminSelectedId && candidates.includes(adminSelectedId) + adminSelectedId && registeredIds.includes(adminSelectedId) ? adminSelectedId - : candidates[0]; + : registeredIds[0]; const provider = getViewProvider(CHATBOT_LOCATION, selectedId); return provider ? { id: selectedId, provider } : undefined; diff --git a/superset-frontend/src/core/extensions/index.ts b/superset-frontend/src/core/extensions/index.ts index 9a751259a837..2474e212c6bf 100644 --- a/superset-frontend/src/core/extensions/index.ts +++ b/superset-frontend/src/core/extensions/index.ts @@ -33,25 +33,24 @@ export const extensions: typeof extensionsApi = { /** * Deployment-wide extension admin settings. The keys are snake_case to match - * the `/api/v1/extensions/settings` wire shape this store loads and saves. + * the `/api/v1/extensions/settings` wire shape this store loads from. + * Settings are read-only from the frontend; the admin write path has been + * removed in favour of direct backend configuration. */ export type ExtensionSettings = { active_chatbot_id: string | null; - enabled: Record; }; const SETTINGS_ENDPOINT = '/api/v1/extensions/settings'; const EMPTY_SETTINGS: ExtensionSettings = { active_chatbot_id: null, - enabled: {}, }; /** - * Single module-level store for extension admin settings. Both the chatbot - * mount and the admin list read this one source via `useSyncExternalStore`, - * so a save on the admin page is reflected everywhere without a bespoke - * second notification channel. + * Single module-level store for extension admin settings. The chatbot mount + * reads this one source via `useSyncExternalStore` so it re-resolves when the + * store is updated — no bespoke second notification channel needed. */ let settings: ExtensionSettings = EMPTY_SETTINGS; const settingsListeners = new Set<() => void>(); @@ -73,8 +72,8 @@ export const subscribeToExtensionSettings = ( /** Current settings snapshot (for `useSyncExternalStore`). */ export const getExtensionSettingsSnapshot = (): ExtensionSettings => settings; -/** Replace the settings snapshot and notify subscribers. */ -export const setExtensionSettings = (next: ExtensionSettings): void => { +/** Replace the settings snapshot and notify subscribers. Module-private; only loadExtensionSettings should call this. */ +const applyExtensionSettings = (next: ExtensionSettings): void => { settings = next; emitSettingsChange(); }; @@ -86,28 +85,6 @@ export const setExtensionSettings = (next: ExtensionSettings): void => { */ export const loadExtensionSettings = async (): Promise => { const { json } = await SupersetClient.get({ endpoint: SETTINGS_ENDPOINT }); - setExtensionSettings(json.result ?? EMPTY_SETTINGS); + applyExtensionSettings(json.result ?? EMPTY_SETTINGS); return settings; }; - -/** - * Optimistically apply a partial settings update and persist it. On failure the - * previous snapshot is restored. Returns a promise that rejects on error so the - * caller can toast. - */ -export const saveExtensionSettings = async ( - patch: Partial, -): Promise => { - const previous = settings; - const next = { ...previous, ...patch }; - setExtensionSettings(next); - try { - await SupersetClient.put({ - endpoint: SETTINGS_ENDPOINT, - jsonPayload: next, - }); - } catch (error) { - setExtensionSettings(previous); - throw error; - } -}; diff --git a/superset-frontend/src/extensions/ExtensionsList.test.tsx b/superset-frontend/src/extensions/ExtensionsList.test.tsx deleted file mode 100644 index fbe581aced11..000000000000 --- a/superset-frontend/src/extensions/ExtensionsList.test.tsx +++ /dev/null @@ -1,319 +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. - */ -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'; - -// --------------------------------------------------------------------------- -// Module-level mocks -// --------------------------------------------------------------------------- - -jest.mock('src/views/CRUD/hooks', () => ({ - useListViewResource: jest.fn(), -})); - -jest.mock('src/components', () => ({ - ListView: ({ columns, data }: any) => ( - - - {(data ?? []).map((row: any) => - columns.map((col: any) => ( - - )), - )} - -
- {col.Cell - ? col.Cell({ row: { original: row } }) - : row[col.accessor]} -
- ), -})); - -// Stub SubMenu so tests aren't coupled to the navigation menu rendering chain. -jest.mock('src/features/home/SubMenu', () => ({ - __esModule: true, - default: ({ buttons }: any) => ( -
- {(buttons ?? []).map((btn: any, i: number) => ( - // eslint-disable-next-line react/no-array-index-key - - ))} -
- ), -})); - -// 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), -})); - -// Stable snapshot reference: useSyncExternalStore requires getSnapshot to -// return the same object until it actually changes, otherwise it re-renders -// infinitely. -const mockSettings = { active_chatbot_id: null, enabled: {} }; -jest.mock('src/core/extensions', () => ({ - getExtensionSettingsSnapshot: jest.fn(() => mockSettings), - setExtensionSettings: jest.fn(), - loadExtensionSettings: jest.fn(() => Promise.resolve()), - subscribeToExtensionSettings: jest.fn(() => () => undefined), -})); - -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(), -}; - -function renderList(props = {}) { - return render(, { - useRedux: true, - useQueryParams: true, - useRouter: true, - useTheme: true, - }); -} - -function uploadFile(input: HTMLInputElement, file: File) { - Object.defineProperty(input, 'files', { value: [file], configurable: true }); - fireEvent.change(input); -} - -// --------------------------------------------------------------------------- -// Setup / teardown -// --------------------------------------------------------------------------- - -beforeEach(() => { - jest.clearAllMocks(); - mockGet.mockResolvedValue({ - json: { result: { active_chatbot_id: null, enabled: {} } }, - }); - setupHook(); -}); - -// --------------------------------------------------------------------------- -// 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(() => { - expect(screen.getByText('chatbot')).toBeInTheDocument(); - expect(screen.getByText('widget')).toBeInTheDocument(); - }); -}); - -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')); - - 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' } }); - - 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('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('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 deleted file mode 100644 index 3d4a5d9bbfe1..000000000000 --- a/superset-frontend/src/extensions/ExtensionsList.tsx +++ /dev/null @@ -1,353 +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. - */ -import { t } from '@apache-superset/core/translation'; -import { SupersetClient } from '@superset-ui/core'; -import { - FunctionComponent, - useCallback, - useEffect, - useRef, - useState, - useMemo, - useSyncExternalStore, -} from 'react'; -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'; -import { ConfirmStatusChange, Tooltip } from '@superset-ui/core/components'; -import { Icons } from '@superset-ui/core/components/Icons'; -import { - getExtensionSettingsSnapshot, - loadExtensionSettings, - setExtensionSettings, - subscribeToExtensionSettings, -} from 'src/core/extensions'; -import { getRegisteredViewIds, subscribeToRegistry } from 'src/core/views'; - -const CHATBOT_LOCATION = 'superset.chatbot'; - -const PAGE_SIZE = 25; - -type Extension = { - id: string; - name: string; - publisher: string; - enabled: boolean; - deletable: boolean; -}; - -interface ExtensionsListProps { - addDangerToast: (msg: string) => void; - addSuccessToast: (msg: string) => void; -} - -const ExtensionsList: FunctionComponent = ({ - addDangerToast, - addSuccessToast, -}) => { - const fileInputRef = useRef(null); - const [uploading, setUploading] = useState(false); - const [chatbotExtensionIds, setChatbotExtensionIds] = useState>( - () => new Set(getRegisteredViewIds(CHATBOT_LOCATION)), - ); - - // The active chatbot lives in the host-owned settings store shared with the - // live ChatbotMount, so a change here is reflected there without a second - // notification channel. - const settings = useSyncExternalStore( - subscribeToExtensionSettings, - getExtensionSettingsSnapshot, - ); - const activeChatbotId = settings.active_chatbot_id; - - const { - state: { loading, resourceCount, resourceCollection }, - fetchData, - refreshData, - } = useListViewResource( - 'extensions', - t('Extensions'), - addDangerToast, - ); - - // Keep chatbotExtensionIds in sync with runtime view registrations - useEffect( - () => - subscribeToRegistry(() => { - setChatbotExtensionIds(new Set(getRegisteredViewIds(CHATBOT_LOCATION))); - }), - [], - ); - - // Load settings into the shared store on mount. - useEffect(() => { - // non-fatal: the store keeps its empty default on failure. - loadExtensionSettings().catch(() => {}); - }, []); - - const handleUploadClick = () => { - fileInputRef.current?.click(); - }; - - const handleFileChange = (e: React.ChangeEvent) => { - 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 = useCallback( - (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), - ), - ), - ); - }, - [addDangerToast, addSuccessToast, refreshData], - ); - - const handleSetDefaultChatbot = useCallback( - (extension: Extension) => { - const newId = activeChatbotId === extension.id ? null : extension.id; - SupersetClient.put({ - endpoint: '/api/v1/extensions/settings', - jsonPayload: { active_chatbot_id: newId }, - }).then( - () => { - // Reflect the change in the shared settings store; the component and - // the live ChatbotMount both re-resolve from it immediately. - setExtensionSettings({ - ...getExtensionSettingsSnapshot(), - active_chatbot_id: newId, - }); - addSuccessToast( - newId - ? t('%s set as default chatbot.', extension.name) - : t('Default chatbot cleared.'), - ); - }, - createErrorHandler(errMsg => - addDangerToast( - t('There was an issue updating chatbot settings: %s', errMsg), - ), - ), - ); - }, - [activeChatbotId, addDangerToast, addSuccessToast], - ); - - const columns = useMemo( - () => [ - { - Header: t('Name'), - accessor: 'name', - size: 'lg', - id: 'name', - Cell: ({ - row: { - original: { name }, - }, - }: any) => name, - }, - { - Header: t('Publisher'), - accessor: 'publisher', - id: 'publisher', - Cell: ({ - row: { - original: { publisher }, - }, - }: any) => publisher, - }, - { - Header: t('Actions'), - id: 'actions', - disableSortBy: true, - Cell: ({ row: { original } }: any) => { - const isChatbot = chatbotExtensionIds.has(original.id); - const isDefault = activeChatbotId === original.id; - return ( - <> - {isChatbot && ( - - handleSetDefaultChatbot(original)} - onKeyDown={(e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - handleSetDefaultChatbot(original); - } - }} - > - {isDefault ? ( - - ) : ( - - )} - - - )} - {original.deletable && ( - - {t('Are you sure you want to delete')}{' '} - {original.name}? - - } - onConfirm={() => handleDelete(original)} - > - {(confirmDelete: () => void) => ( - - { - if (e.key === 'Enter' || e.key === ' ') { - confirmDelete(); - } - }} - > - - - - )} - - )} - - ); - }, - }, - ], - [ - activeChatbotId, - chatbotExtensionIds, - handleSetDefaultChatbot, - handleDelete, - ], - ); - - const menuData: SubMenuProps = { - activeChild: 'Extensions', - name: t('Extensions'), - buttons: [ - { - name: ( - - - - ), - buttonStyle: 'link', - onClick: handleUploadClick, - loading: uploading, - }, - ], - }; - - return ( - <> - - - - columns={columns} - count={resourceCount} - data={resourceCollection} - initialSort={[{ id: 'name', desc: false }]} - pageSize={PAGE_SIZE} - fetchData={fetchData} - loading={loading} - addDangerToast={addDangerToast} - addSuccessToast={addSuccessToast} - refreshData={refreshData} - /> - - ); -}; - -export default withToasts(ExtensionsList); diff --git a/superset-frontend/src/extensions/ExtensionsLoader.test.ts b/superset-frontend/src/extensions/ExtensionsLoader.test.ts index c90fda5ef8cf..d5fc06c0d322 100644 --- a/superset-frontend/src/extensions/ExtensionsLoader.test.ts +++ b/superset-frontend/src/extensions/ExtensionsLoader.test.ts @@ -29,9 +29,7 @@ function createMockExtension(overrides: Partial = {}): Extension { name: 'Test Extension', description: 'A test extension', version: '1.0.0', - dependencies: [], remoteEntry: '', - extensionDependencies: [], ...overrides, }; } @@ -189,60 +187,19 @@ const remoteExtension = (overrides: Partial = {}) => ...overrides, }); -test('disposes synchronous activation-time registrations on deactivation', async () => { +test('runs activate(context) hook for modern-style extensions', async () => { const loader = ExtensionsLoader.getInstance(); - const dispose = jest.fn(); - // Legacy side-effect style: register synchronously during module evaluation. - const factory = () => { - window.superset.views.registerView( - { id: 'remote-ext.view', name: 'View' }, - 'sqllab.panels', - (() => null) as any, - ); - return undefined; - }; - const registerView = jest - .spyOn(window.superset.views, 'registerView') - .mockReturnValue({ dispose } as any); - const cleanup = mockRemoteModule('remote-ext', factory); - - await loader.initializeExtension(remoteExtension()); - loader.deactivateExtension('remote-ext'); - - expect(dispose).toHaveBeenCalledTimes(1); - - registerView.mockRestore(); - cleanup(); -}); - -test('tracks registrations made asynchronously inside activate(context)', async () => { - const loader = ExtensionsLoader.getInstance(); - const dispose = jest.fn(); - const registerView = jest - .spyOn(window.superset.views, 'registerView') - .mockReturnValue({ dispose } as any); - - // Modern style: register AFTER an await — the window.superset wrap is already - // gone by then, so this is only tracked because activate pushes to context. - const factory = () => ({ - activate: async (context: core.ExtensionContext) => { - await Promise.resolve(); - const disposable = window.superset.views.registerView( - { id: 'remote-ext.async-view', name: 'Async View' }, - 'sqllab.panels', - (() => null) as any, - ); - context.subscriptions.push(disposable); - }, - }); + const activate = jest.fn().mockResolvedValue(undefined); + const factory = () => ({ activate }); const cleanup = mockRemoteModule('remote-ext', factory); await loader.initializeExtension(remoteExtension()); - loader.deactivateExtension('remote-ext'); - expect(registerView).toHaveBeenCalledTimes(1); - expect(dispose).toHaveBeenCalledTimes(1); + expect(activate).toHaveBeenCalledTimes(1); + // The context object passed to activate must have a subscriptions array. + expect(activate).toHaveBeenCalledWith( + expect.objectContaining({ subscriptions: expect.any(Array) }), + ); - registerView.mockRestore(); cleanup(); }); diff --git a/superset-frontend/src/extensions/ExtensionsLoader.ts b/superset-frontend/src/extensions/ExtensionsLoader.ts index d54475ac6aec..fb2fbecbf322 100644 --- a/superset-frontend/src/extensions/ExtensionsLoader.ts +++ b/superset-frontend/src/extensions/ExtensionsLoader.ts @@ -43,9 +43,6 @@ class ExtensionsLoader { private initializationPromise: Promise | null = null; - /** Disposables registered by each extension via its context, keyed by extension id. */ - private extensionDisposables: Map = new Map(); - // eslint-disable-next-line no-useless-constructor private constructor() { // Private constructor for singleton pattern @@ -99,8 +96,7 @@ class ExtensionsLoader { public async initializeExtension(extension: Extension) { try { if (extension.remoteEntry) { - const subscriptions = await this.loadModule(extension); - this.extensionDisposables.set(extension.id, subscriptions); + await this.loadModule(extension); } this.extensionIndex.set(extension.id, extension); } catch (error) { @@ -114,35 +110,12 @@ class ExtensionsLoader { } } - /** - * Deactivates an extension by disposing all of its registered contributions - * and removing it from the index. - * - * Contributions are disposed from the extension's `context.subscriptions`, - * which it populates during `activate(context)`. This tracks registrations - * regardless of when they happen — synchronous or asynchronous — so long as - * the extension pushes each returned Disposable onto its context. Legacy - * extensions that register as top-level side effects are tracked only for the - * synchronous module-evaluation window (see `loadModule`). - */ - public deactivateExtension(id: string): void { - const subscriptions = this.extensionDisposables.get(id); - if (subscriptions) { - subscriptions.forEach(subscription => subscription.dispose()); - this.extensionDisposables.delete(id); - } - this.extensionIndex.delete(id); - } - /** * Loads a single extension module via webpack module federation and runs its - * `activate(context)` hook. Returns the Disposables the extension registered - * (its `context.subscriptions`) so the loader can dispose them on deactivation. + * `activate(context)` hook. * @param extension The extension to load. */ - private async loadModule( - extension: Extension, - ): Promise<{ dispose(): void }[]> { + private async loadModule(extension: Extension): Promise { const { remoteEntry, id } = extension; // Load the remote entry script @@ -188,71 +161,20 @@ class ExtensionsLoader { const factory = await container.get('./index'); - // The extension binds the lifetime of its registrations to this context by - // pushing the returned Disposables onto `subscriptions`. Because the context - // object outlives the synchronous module-evaluation window, registrations - // performed asynchronously inside `activate` (after an `await`, in a timer, - // or in an event callback) are tracked just like synchronous ones. + // `context.subscriptions` is provided for extensions to push their + // Disposables into. The host does not dispose them (lifecycle management is + // deferred); extensions own the array for as long as they are active. const context: ExtensionContext = { subscriptions: [] }; - // Backward-compatibility path: extensions that register contributions as - // top-level side effects (rather than via `activate(context)`) do not push - // to `context.subscriptions` themselves. Wrapping the registrars captures - // those disposables — but ONLY while they fire synchronously during module - // evaluation, since the wrap is removed immediately afterwards. Extensions - // that register asynchronously must use `activate(context)` to be tracked. - const originalSuperset = window.superset; - - const wrap = - ( - fn: (...args: TArgs) => { dispose(): void }, - ): ((...args: TArgs) => { dispose(): void }) => - (...args: TArgs) => { - const disposable = fn(...args); - context.subscriptions.push(disposable); - 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: wrap(originalSuperset.views.registerView), - }, - }; - - let module: ExtensionModule | undefined; - try { - // Evaluate the module factory. Legacy extensions fire their contribution - // registrations as a synchronous side effect here; modern extensions - // return a module exposing `activate`. - module = factory() as ExtensionModule | undefined; - } finally { - // Restore the real registrars before `activate` runs so that registrations - // are tracked via `context.subscriptions` (which the extension controls and - // which survives async boundaries) rather than via the synchronous wrap. - window.superset = originalSuperset; - } + // Evaluate the module factory. Extensions may register contributions as + // top-level side effects here, or return a module exposing `activate`. + const module = factory() as ExtensionModule | undefined; // Preferred path: hand the extension its context so it can track every // registration it makes, synchronous or asynchronous. if (typeof module?.activate === 'function') { await module.activate(context); } - - return context.subscriptions; } /** diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index 4f066e3ec2cb..dfa30687e5ff 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -128,10 +128,6 @@ const Tags = lazy( () => import(/* webpackChunkName: "Tags" */ 'src/pages/Tags'), ); -const Extensions = lazy( - () => import(/* webpackChunkName: "Tags" */ 'src/extensions/ExtensionsList'), -); - const RowLevelSecurityList = lazy( () => import( @@ -363,13 +359,6 @@ if (isAdmin) { Component: GroupsList, }, ); - - if (isFeatureEnabled(FeatureFlag.EnableExtensions)) { - routes.push({ - path: '/extensions/list/', - Component: Extensions, - }); - } } if (authRegistrationEnabled) { diff --git a/superset/commands/extension/settings/exceptions.py b/superset/commands/extension/settings/exceptions.py deleted file mode 100644 index e3ac3807cf74..000000000000 --- a/superset/commands/extension/settings/exceptions.py +++ /dev/null @@ -1,27 +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. -from flask_babel import lazy_gettext as _ - -from superset.commands.exceptions import CommandInvalidError, UpdateFailedError - - -class ExtensionSettingsInvalidError(CommandInvalidError): - message = _("Extension settings parameters are invalid.") - - -class ExtensionSettingsUpdateFailedError(UpdateFailedError): - message = _("Extension settings could not be updated.") diff --git a/superset/commands/extension/settings/update.py b/superset/commands/extension/settings/update.py deleted file mode 100644 index ed3ce883f779..000000000000 --- a/superset/commands/extension/settings/update.py +++ /dev/null @@ -1,68 +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. -import logging -from functools import partial -from typing import Any - -from superset.commands.base import BaseCommand -from superset.commands.extension.settings.exceptions import ( - ExtensionSettingsUpdateFailedError, -) -from superset.daos.extension import ( - ExtensionEnabledDAO, - ExtensionSettingsDAO, - get_extension_settings, -) -from superset.utils.decorators import on_error, transaction - -logger = logging.getLogger(__name__) - - -class UpdateExtensionSettingsCommand(BaseCommand): - """Apply a partial update to global extension admin settings. - - The body is the already-validated output of ``ExtensionSettingsPutSchema`` - and may contain: - * active_chatbot_id: str | None — empty string is normalised to None. - * enabled: dict[str, bool] — per-extension toggle map. - - Keys not present in the body are left untouched. - """ - - def __init__(self, body: dict[str, Any]): - self._body = body or {} - - @transaction( - on_error=partial(on_error, reraise=ExtensionSettingsUpdateFailedError), - ) - def run(self) -> dict[str, Any]: - if "active_chatbot_id" in self._body: - value = self._body["active_chatbot_id"] - active_chatbot_id = str(value) if value else None - ExtensionSettingsDAO.upsert_active_chatbot_id(active_chatbot_id) - - enabled = self._body.get("enabled") - if isinstance(enabled, dict): - for extension_id, value in enabled.items(): - ExtensionEnabledDAO.upsert_enabled_flag(extension_id, value) - - return get_extension_settings() - - def validate(self) -> None: - # Request-shape validation is handled declaratively by - # ExtensionSettingsPutSchema at the route boundary. - pass diff --git a/superset/daos/extension.py b/superset/daos/extension.py index 2397e3626639..10557cb87b67 100644 --- a/superset/daos/extension.py +++ b/superset/daos/extension.py @@ -18,10 +18,10 @@ from superset import db from superset.daos.base import BaseDAO -from superset.extensions.models import ExtensionEnabled, ExtensionSettings +from superset.extensions.models import ExtensionSettings # The global extension settings live in a single row; id is fixed so the row -# can be fetched and upserted without a secondary lookup. +# can be fetched without a secondary lookup. SETTINGS_ROW_ID = 1 @@ -29,49 +29,16 @@ class ExtensionSettingsDAO(BaseDAO[ExtensionSettings]): """Persistence for the singleton global extension settings row. The row (id=1) holds global admin state such as the active chatbot id. - Writes go through a check-then-write upsert that is dialect-agnostic; - callers wrap the operation in ``@transaction`` so the read-then-write - window is serialised and committed atomically. """ @staticmethod def get_settings_row() -> ExtensionSettings | None: return db.session.get(ExtensionSettings, SETTINGS_ROW_ID) - @classmethod - def upsert_active_chatbot_id(cls, active_chatbot_id: str | None) -> None: - if row := cls.get_settings_row(): - row.active_chatbot_id = active_chatbot_id - else: - cls.create( - attributes={ - "id": SETTINGS_ROW_ID, - "active_chatbot_id": active_chatbot_id, - } - ) - - -class ExtensionEnabledDAO(BaseDAO[ExtensionEnabled]): - """Persistence for per-extension enabled flags.""" - - id_column_name = "extension_id" - - @classmethod - def get_enabled_map(cls) -> dict[str, bool]: - return {row.extension_id: row.enabled for row in cls.find_all()} - - @classmethod - def upsert_enabled_flag(cls, extension_id: str, enabled: bool) -> None: - if row := cls.find_by_id(extension_id): - row.enabled = enabled - else: - cls.create(attributes={"extension_id": extension_id, "enabled": enabled}) - def get_extension_settings() -> dict[str, Any]: - """Read-only view of the combined extension settings.""" + """Read-only view of the extension settings.""" row = ExtensionSettingsDAO.get_settings_row() return { "active_chatbot_id": row.active_chatbot_id if row else None, - "enabled": ExtensionEnabledDAO.get_enabled_map(), } diff --git a/superset/extensions/api.py b/superset/extensions/api.py index ba54a49c55d7..cf08a9fbe204 100644 --- a/superset/extensions/api.py +++ b/superset/extensions/api.py @@ -17,28 +17,17 @@ import mimetypes import re from io import BytesIO -from pathlib import Path from typing import Any -from zipfile import is_zipfile, ZipFile -from flask import current_app, request, send_file +from flask import send_file from flask.wrappers import Response from flask_appbuilder.api import expose, protect, safe -from marshmallow import ValidationError from superset.commands.extension.settings.get import GetExtensionSettingsCommand -from superset.commands.extension.settings.update import ( - UpdateExtensionSettingsCommand, -) -from superset.extensions import security_manager -from superset.extensions.schemas import ExtensionSettingsPutSchema 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 from superset.views.base_api import BaseSupersetApi # Allowlist for publisher and name path parameters — alphanumeric, hyphens, @@ -46,9 +35,6 @@ # 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.""" @@ -58,21 +44,13 @@ def _validate_segment(value: str) -> bool: class ExtensionsRestApi(BaseSupersetApi): allow_browser_login = True resource_name = "extensions" - # BaseSupersetApi already defaults csrf_exempt to False; kept explicit - # because these endpoints use cookie/session auth (allow_browser_login) - # and include state-changing routes (settings PUT, upload POST, delete). - csrf_exempt = False 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", ] @expose("/_info", methods=("GET",)) @@ -103,13 +81,13 @@ def info(self, **kwargs: Any) -> Response: @safe @expose("/", methods=("GET",)) def get_list(self, **kwargs: Any) -> Response: - """List all enabled extensions. + """List all installed extensions. --- get_list: - summary: List all enabled extensions. + summary: List all installed extensions. responses: 200: - description: List of all enabled extensions + description: List of all installed extensions content: application/json: schema: @@ -199,205 +177,6 @@ def get(self, publisher: str, name: str, **kwargs: Any) -> Response: 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.") - - 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.") - - 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}") - - # Validate the manifest id before using it as a filename component. - # The id is publisher.name (e.g. "acme.chatbot"); each segment must pass - # _validate_segment so a crafted bundle cannot write outside EXTENSIONS_PATH - # even though the admin is trusted — defence-in-depth against third-party - # bundles the admin did not author. - manifest_id: str = extension.manifest.id - id_parts = manifest_id.split(".", 1) - if len(id_parts) != 2 or not all( # noqa: PLR2004 - _validate_segment(p) for p in id_parts - ): - return self.response( - 400, - message=( - f"Invalid extension id '{manifest_id}' in manifest. " - "Expected '.' with alphanumeric, hyphen, " - "or underscore characters only." - ), - ) - - # 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 manifest_id in local_ids: - return self.response( - 409, - message=( - f"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 filename is built from the validated manifest id, not from the - # uploaded filename, so neither can escape EXTENSIONS_PATH. - dest_dir = Path(extensions_path) - dest_dir.mkdir(parents=True, exist_ok=True) - dest_file = dest_dir / f"{manifest_id}.supx" - - stream.seek(0) - dest_file.write_bytes(stream.read()) - - return self.response(201, result=build_extension_data(extension)) - - @protect() - @safe - @expose("//", 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.") - - 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) - 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",)) @@ -408,50 +187,13 @@ def get_settings(self, **kwargs: Any) -> Response: settings so the ChatbotMount can read active_chatbot_id on every page. --- get: - summary: Get extension admin settings (active chatbot, enabled flags). + summary: Get extension admin settings (active chatbot id). responses: 200: description: Extension settings """ return self.response(200, result=GetExtensionSettingsCommand().run()) - @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 - 400: - $ref: '#/components/responses/400' - 403: - $ref: '#/components/responses/403' - """ - if not security_manager.is_admin(): - return self.response(403, message="Admin access required.") - try: - body = ExtensionSettingsPutSchema().load(request.json or {}) - except ValidationError as error: - return self.response(400, message=error.messages) - return self.response(200, result=UpdateExtensionSettingsCommand(body).run()) - @protect() @safe @expose("///", methods=("GET",)) diff --git a/superset/extensions/models.py b/superset/extensions/models.py index caf2479d5a32..700f5b7b6b5b 100644 --- a/superset/extensions/models.py +++ b/superset/extensions/models.py @@ -18,10 +18,9 @@ """SQLAlchemy models for extension settings persistence.""" from flask_appbuilder import Model -from sqlalchemy import Boolean, Column, Integer, String +from sqlalchemy import Column, Integer, String -# Shared column length for extension/chatbot identifiers; reused by request -# validation so oversized keys are rejected with a 400 before hitting the DB. +# Column length for extension/chatbot identifiers. EXTENSION_ID_MAX_LENGTH = 250 @@ -31,11 +30,3 @@ class ExtensionSettings(Model): # pylint: disable=too-few-public-methods __tablename__ = "extension_settings" id = Column(Integer, primary_key=True) active_chatbot_id = Column(String(EXTENSION_ID_MAX_LENGTH), nullable=True) - - -class ExtensionEnabled(Model): # pylint: disable=too-few-public-methods - """Per-extension enable/disable flag.""" - - __tablename__ = "extension_enabled" - extension_id = Column(String(EXTENSION_ID_MAX_LENGTH), primary_key=True) - enabled = Column(Boolean, nullable=False, default=True) diff --git a/superset/extensions/schemas.py b/superset/extensions/schemas.py deleted file mode 100644 index 0742125fae58..000000000000 --- a/superset/extensions/schemas.py +++ /dev/null @@ -1,44 +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. -"""Marshmallow schemas for the extensions REST API.""" - -from marshmallow import fields, Schema -from marshmallow.validate import Length - -from superset.extensions.models import EXTENSION_ID_MAX_LENGTH - - -class ExtensionSettingsPutSchema(Schema): - """Validate the partial update body for the extension settings PUT route. - - Both fields are optional so the update is a partial patch: keys absent from - the payload are left untouched. An empty-string ``active_chatbot_id`` is a - valid "clear" signal that the command normalises to ``None``. - """ - - active_chatbot_id = fields.String( - allow_none=True, - validate=Length(max=EXTENSION_ID_MAX_LENGTH), - metadata={"description": "Id of the chatbot to render, or null to clear."}, - ) - enabled = fields.Dict( - keys=fields.String(validate=Length(min=1, max=EXTENSION_ID_MAX_LENGTH)), - # Strict booleans: reject non-bool values (e.g. "yes") rather than - # coercing them, so a malformed toggle map is a 400, not a silent write. - values=fields.Boolean(truthy={True}, falsy={False}), - metadata={"description": "Per-extension enabled flags keyed by extension id."}, - ) diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py index aef26f756068..1995874c5467 100644 --- a/superset/extensions/utils.py +++ b/superset/extensions/utils.py @@ -236,18 +236,12 @@ 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, "name": extension.name, "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 diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 0b52b4a2452a..5245dedcfd7e 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -177,7 +177,6 @@ def init_views(self) -> None: from superset.explore.api import ExploreRestApi from superset.explore.form_data.api import ExploreFormDataRestApi from superset.explore.permalink.api import ExplorePermalinkRestApi - from superset.extensions.view import ExtensionsView from superset.importexport.api import ImportExportRestApi from superset.queries.api import QueryRestApi from superset.queries.saved_queries.api import SavedQueryRestApi @@ -418,17 +417,6 @@ def init_views(self) -> None: category_icon="", ) - appbuilder.add_view( - ExtensionsView, - "Extensions", - label=_("Extensions"), - category="Manage", - category_label=_("Manage"), - menu_cond=lambda: feature_flag_manager.is_feature_enabled( - "ENABLE_EXTENSIONS" - ), - ) - appbuilder.add_view( TaskModelView, "Tasks", diff --git a/superset/extensions/view.py b/superset/migrations/versions/2026-06-09_00-00_d1e2f3a4b5c6_drop_extension_enabled.py similarity index 54% rename from superset/extensions/view.py rename to superset/migrations/versions/2026-06-09_00-00_d1e2f3a4b5c6_drop_extension_enabled.py index b3699163b68c..3436e7c487a5 100644 --- a/superset/extensions/view.py +++ b/superset/migrations/versions/2026-06-09_00-00_d1e2f3a4b5c6_drop_extension_enabled.py @@ -14,21 +14,30 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from flask_appbuilder import expose -from flask_appbuilder.security.decorators import has_access, permission_name +"""Drop extension_enabled table (ExtensionEnabled model removed in chatbot SIP). -from superset.constants import MODEL_VIEW_RW_METHOD_PERMISSION_MAP -from superset.superset_typing import FlaskResponse -from superset.views.base import BaseSupersetView +Revision ID: d1e2f3a4b5c6 +Revises: b2c3d4e5f6a7 +Create Date: 2026-06-09 00:00:00.000000 +""" -class ExtensionsView(BaseSupersetView): - route_base = "/extensions" - class_permission_name = "Extensions" - method_permission_name = MODEL_VIEW_RW_METHOD_PERMISSION_MAP +import sqlalchemy as sa - @expose("/list/") - @has_access - @permission_name("read") - def list(self) -> FlaskResponse: - return super().render_app_template() +from superset.migrations.shared.utils import create_table, drop_table + +# revision identifiers, used by Alembic. +revision = "d1e2f3a4b5c6" +down_revision = "b2c3d4e5f6a7" + + +def upgrade() -> None: + drop_table("extension_enabled") + + +def downgrade() -> None: + create_table( + "extension_enabled", + sa.Column("extension_id", sa.String(250), primary_key=True), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + ) diff --git a/superset/models/core.py b/superset/models/core.py index 6ffa88c1e698..0d22bd9b29bc 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -118,22 +118,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""" diff --git a/tests/unit_tests/extensions/test_api.py b/tests/unit_tests/extensions/test_api.py index a1fe9d7269c7..54dfd17cb50f 100644 --- a/tests/unit_tests/extensions/test_api.py +++ b/tests/unit_tests/extensions/test_api.py @@ -15,27 +15,15 @@ # specific language governing permissions and limitations # under the License. -"""Unit tests for the extensions REST API (POST and DELETE endpoints).""" +"""Unit tests for the extensions REST API.""" from __future__ import annotations -import io -import zipfile -from pathlib import Path -from typing import Any -from unittest.mock import MagicMock - -import pytest - from superset.extensions.api import _validate_segment -# The extension routes are only registered when ENABLE_EXTENSIONS is on at -# app-init time, so the endpoint tests parametrize the app fixture to enable it -# (otherwise the route is absent and requests 404). -_ENABLE_EXTENSIONS = [{"FEATURE_FLAGS": {"ENABLE_EXTENSIONS": True}}] - # --------------------------------------------------------------------------- -# _validate_segment helper +# _validate_segment helper — used by GET /api/v1/extensions// +# and GET /api/v1/extensions/// # --------------------------------------------------------------------------- @@ -56,362 +44,3 @@ def test_validate_segment_rejects_traversal() -> None: 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 -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("app", _ENABLE_EXTENSIONS, indirect=True) -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_hostile_manifest_id_rejected( - self, client: Any, full_api_access: None, mocker: Any, tmp_path: Path - ) -> None: - """A crafted manifest.id with path traversal must not escape EXTENSIONS_PATH.""" - 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("../../tmp/evil") - 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("../../tmp/evil") - resp = client.post( - "/api/v1/extensions/", - data={"bundle": (io.BytesIO(supx), "ext.supx")}, - content_type="multipart/form-data", - ) - assert resp.status_code == 400 - assert "Invalid extension id" 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// -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("app", _ENABLE_EXTENSIONS, indirect=True) -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 diff --git a/tests/unit_tests/extensions/test_settings.py b/tests/unit_tests/extensions/test_settings.py index 78f58a82de5f..c32d38ccdd7d 100644 --- a/tests/unit_tests/extensions/test_settings.py +++ b/tests/unit_tests/extensions/test_settings.py @@ -15,11 +15,10 @@ # specific language governing permissions and limitations # under the License. -"""Unit tests for extension settings persistence and the settings API endpoints. +"""Unit tests for extension settings persistence and the settings API endpoint. Persistence is exercised through the public Command + DAO layer: -``UpdateExtensionSettingsCommand`` / ``GetExtensionSettingsCommand`` and the -``ExtensionSettingsDAO`` / ``ExtensionEnabledDAO`` they delegate to. +``GetExtensionSettingsCommand`` and ``ExtensionSettingsDAO``. """ from __future__ import annotations @@ -41,165 +40,6 @@ def test_returns_defaults_when_no_rows(self, app_context: Any) -> None: result = GetExtensionSettingsCommand().run() assert result["active_chatbot_id"] is None - assert result["enabled"] == {} - - def test_round_trips_active_chatbot_id(self, app_context: Any) -> None: - from superset.commands.extension.settings.get import ( - GetExtensionSettingsCommand, - ) - from superset.commands.extension.settings.update import ( - UpdateExtensionSettingsCommand, - ) - - UpdateExtensionSettingsCommand({"active_chatbot_id": "acme.chatbot"}).run() - result = GetExtensionSettingsCommand().run() - assert result["active_chatbot_id"] == "acme.chatbot" - - def test_round_trips_enabled_flags(self, app_context: Any) -> None: - from superset.commands.extension.settings.get import ( - GetExtensionSettingsCommand, - ) - from superset.commands.extension.settings.update import ( - UpdateExtensionSettingsCommand, - ) - - UpdateExtensionSettingsCommand( - {"enabled": {"acme.chatbot": True, "acme.widget": False}} - ).run() - result = GetExtensionSettingsCommand().run() - assert result["enabled"]["acme.chatbot"] is True - assert result["enabled"]["acme.widget"] is False - - -class TestUpdateExtensionSettings: - def test_empty_string_active_chatbot_id_stored_as_none( - self, app_context: Any - ) -> None: - from superset.commands.extension.settings.get import ( - GetExtensionSettingsCommand, - ) - from superset.commands.extension.settings.update import ( - UpdateExtensionSettingsCommand, - ) - - # First set a value, then clear it via empty string. - UpdateExtensionSettingsCommand({"active_chatbot_id": "acme.chatbot"}).run() - UpdateExtensionSettingsCommand({"active_chatbot_id": ""}).run() - assert GetExtensionSettingsCommand().run()["active_chatbot_id"] is None - - def test_enabled_flags_are_persisted(self, app_context: Any) -> None: - from superset.commands.extension.settings.get import ( - GetExtensionSettingsCommand, - ) - from superset.commands.extension.settings.update import ( - UpdateExtensionSettingsCommand, - ) - - # The command trusts already-validated input (request-shape validation - # is the schema's job — see TestExtensionSettingsPutSchema), so a bool - # value is written through as-is. - UpdateExtensionSettingsCommand({"enabled": {"acme.persisted": False}}).run() - result = GetExtensionSettingsCommand().run() - assert result["enabled"]["acme.persisted"] is False - - def test_upsert_overwrites_existing_chatbot(self, app_context: Any) -> None: - from superset.commands.extension.settings.get import ( - GetExtensionSettingsCommand, - ) - from superset.commands.extension.settings.update import ( - UpdateExtensionSettingsCommand, - ) - - UpdateExtensionSettingsCommand({"active_chatbot_id": "acme.chatbot"}).run() - UpdateExtensionSettingsCommand({"active_chatbot_id": "vendor.bot"}).run() - assert GetExtensionSettingsCommand().run()["active_chatbot_id"] == "vendor.bot" - - def test_upsert_overwrites_existing_enabled_flag(self, app_context: Any) -> None: - from superset.commands.extension.settings.get import ( - GetExtensionSettingsCommand, - ) - from superset.commands.extension.settings.update import ( - UpdateExtensionSettingsCommand, - ) - - UpdateExtensionSettingsCommand({"enabled": {"acme.chatbot": True}}).run() - UpdateExtensionSettingsCommand({"enabled": {"acme.chatbot": False}}).run() - assert GetExtensionSettingsCommand().run()["enabled"]["acme.chatbot"] is False - - def test_partial_update_leaves_other_keys_intact(self, app_context: Any) -> None: - from superset.commands.extension.settings.get import ( - GetExtensionSettingsCommand, - ) - from superset.commands.extension.settings.update import ( - UpdateExtensionSettingsCommand, - ) - - UpdateExtensionSettingsCommand( - {"active_chatbot_id": "acme.chatbot", "enabled": {"acme.widget": True}} - ).run() - # Update only enabled — active_chatbot_id must survive. - UpdateExtensionSettingsCommand({"enabled": {"acme.widget": False}}).run() - result = GetExtensionSettingsCommand().run() - assert result["active_chatbot_id"] == "acme.chatbot" - assert result["enabled"]["acme.widget"] is False - - def test_returns_current_state(self, app_context: Any) -> None: - from superset.commands.extension.settings.update import ( - UpdateExtensionSettingsCommand, - ) - - result = UpdateExtensionSettingsCommand( - {"active_chatbot_id": "acme.chatbot"} - ).run() - assert result["active_chatbot_id"] == "acme.chatbot" - - -class TestExtensionSettingsPutSchema: - """Request-shape validation now lives in ExtensionSettingsPutSchema.""" - - def test_non_string_active_chatbot_id_raises(self, app_context: Any) -> None: - from marshmallow import ValidationError - - from superset.extensions.schemas import ExtensionSettingsPutSchema - - with pytest.raises(ValidationError): - ExtensionSettingsPutSchema().load({"active_chatbot_id": 123}) - - def test_oversized_active_chatbot_id_raises(self, app_context: Any) -> None: - from marshmallow import ValidationError - - from superset.extensions.models import EXTENSION_ID_MAX_LENGTH - from superset.extensions.schemas import ExtensionSettingsPutSchema - - with pytest.raises(ValidationError): - ExtensionSettingsPutSchema().load( - {"active_chatbot_id": "x" * (EXTENSION_ID_MAX_LENGTH + 1)} - ) - - def test_oversized_enabled_key_raises(self, app_context: Any) -> None: - from marshmallow import ValidationError - - from superset.extensions.models import EXTENSION_ID_MAX_LENGTH - from superset.extensions.schemas import ExtensionSettingsPutSchema - - with pytest.raises(ValidationError): - ExtensionSettingsPutSchema().load( - {"enabled": {"x" * (EXTENSION_ID_MAX_LENGTH + 1): True}} - ) - - def test_non_bool_enabled_value_raises(self, app_context: Any) -> None: - from marshmallow import ValidationError - - from superset.extensions.schemas import ExtensionSettingsPutSchema - - with pytest.raises(ValidationError): - ExtensionSettingsPutSchema().load({"enabled": {"acme.ext": "yes"}}) - - def test_null_active_chatbot_id_is_valid(self, app_context: Any) -> None: - from superset.extensions.schemas import ExtensionSettingsPutSchema - - loaded = ExtensionSettingsPutSchema().load({"active_chatbot_id": None}) - assert loaded["active_chatbot_id"] is None # --------------------------------------------------------------------------- @@ -219,153 +59,19 @@ def test_authenticated_user_can_read( ) -> None: mocker.patch( "superset.extensions.api.GetExtensionSettingsCommand.run", - return_value={"active_chatbot_id": None, "enabled": {}}, + return_value={"active_chatbot_id": None}, ) resp = client.get("/api/v1/extensions/settings") assert resp.status_code == 200 assert resp.json["result"]["active_chatbot_id"] is None - def test_returns_active_chatbot_and_enabled_map( + def test_returns_active_chatbot_id( self, client: Any, full_api_access: None, mocker: Any ) -> None: mocker.patch( "superset.extensions.api.GetExtensionSettingsCommand.run", - return_value={ - "active_chatbot_id": "acme.chatbot", - "enabled": {"acme.chatbot": True}, - }, + return_value={"active_chatbot_id": "acme.chatbot"}, ) resp = client.get("/api/v1/extensions/settings") assert resp.status_code == 200 assert resp.json["result"]["active_chatbot_id"] == "acme.chatbot" - assert resp.json["result"]["enabled"]["acme.chatbot"] is True - - -# --------------------------------------------------------------------------- -# PUT /api/v1/extensions/settings -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize("app", _ENABLE_EXTENSIONS, indirect=True) -class TestPutSettingsEndpoint: - 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.put( - "/api/v1/extensions/settings", - json={"active_chatbot_id": "acme.chatbot"}, - ) - assert resp.status_code == 403 - - def test_admin_can_update_active_chatbot( - 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.UpdateExtensionSettingsCommand.run", - return_value={"active_chatbot_id": "acme.chatbot", "enabled": {}}, - ) - resp = client.put( - "/api/v1/extensions/settings", - json={"active_chatbot_id": "acme.chatbot"}, - ) - assert resp.status_code == 200 - assert resp.json["result"]["active_chatbot_id"] == "acme.chatbot" - - def test_empty_body_is_accepted( - 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.UpdateExtensionSettingsCommand.run", - return_value={"active_chatbot_id": None, "enabled": {}}, - ) - resp = client.put("/api/v1/extensions/settings", json={}) - assert resp.status_code == 200 - - def test_non_object_body_rejected( - self, client: Any, full_api_access: None, mocker: Any - ) -> None: - mocker.patch( - "superset.extensions.api.security_manager.is_admin", return_value=True - ) - run = mocker.patch( - "superset.extensions.api.UpdateExtensionSettingsCommand.run", - ) - resp = client.put("/api/v1/extensions/settings", json=["not", "an", "object"]) - assert resp.status_code == 400 - run.assert_not_called() - - def test_non_string_active_chatbot_id_rejected( - self, client: Any, full_api_access: None, mocker: Any - ) -> None: - mocker.patch( - "superset.extensions.api.security_manager.is_admin", return_value=True - ) - run = mocker.patch( - "superset.extensions.api.UpdateExtensionSettingsCommand.run", - ) - # An int must be rejected with a 400, not silently coerced to null. - resp = client.put( - "/api/v1/extensions/settings", json={"active_chatbot_id": 123} - ) - assert resp.status_code == 400 - run.assert_not_called() - - def test_null_active_chatbot_id_is_accepted( - 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.UpdateExtensionSettingsCommand.run", - return_value={"active_chatbot_id": None, "enabled": {}}, - ) - resp = client.put( - "/api/v1/extensions/settings", json={"active_chatbot_id": None} - ) - assert resp.status_code == 200 - - def test_oversized_active_chatbot_id_rejected( - self, client: Any, full_api_access: None, mocker: Any - ) -> None: - from superset.extensions.models import EXTENSION_ID_MAX_LENGTH - - mocker.patch( - "superset.extensions.api.security_manager.is_admin", return_value=True - ) - run = mocker.patch( - "superset.extensions.api.UpdateExtensionSettingsCommand.run", - ) - resp = client.put( - "/api/v1/extensions/settings", - json={"active_chatbot_id": "x" * (EXTENSION_ID_MAX_LENGTH + 1)}, - ) - assert resp.status_code == 400 - run.assert_not_called() - - def test_oversized_enabled_key_rejected( - self, client: Any, full_api_access: None, mocker: Any - ) -> None: - from superset.extensions.models import EXTENSION_ID_MAX_LENGTH - - mocker.patch( - "superset.extensions.api.security_manager.is_admin", return_value=True - ) - run = mocker.patch( - "superset.extensions.api.UpdateExtensionSettingsCommand.run", - ) - resp = client.put( - "/api/v1/extensions/settings", - json={"enabled": {"x" * (EXTENSION_ID_MAX_LENGTH + 1): True}}, - ) - assert resp.status_code == 400 - run.assert_not_called() diff --git a/tests/unit_tests/extensions/test_types.py b/tests/unit_tests/extensions/test_types.py index 52615a69ae77..db1c429236ab 100644 --- a/tests/unit_tests/extensions/test_types.py +++ b/tests/unit_tests/extensions/test_types.py @@ -44,7 +44,6 @@ def test_extension_config_minimal(): assert config.name == "my-extension" assert config.displayName == "My Extension" assert config.version == "0.0.0" - assert config.dependencies == [] assert config.permissions == [] assert config.backend is None @@ -59,7 +58,6 @@ def test_extension_config_full(): "version": "1.0.0", "license": "Apache-2.0", "description": "A query insights extension", - "dependencies": ["other-extension"], "permissions": ["can_read", "can_view"], "backend": { "files": ["backend/src/query_insights/**/*.py"], @@ -72,7 +70,6 @@ def test_extension_config_full(): assert config.version == "1.0.0" assert config.license == "Apache-2.0" assert config.description == "A query insights extension" - assert config.dependencies == ["other-extension"] assert config.permissions == ["can_read", "can_view"] assert config.backend is not None assert config.backend.files == ["backend/src/query_insights/**/*.py"] From 09c09f3f6bd0d1963ba7d0ee3070b126536735e2 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Wed, 10 Jun 2026 14:32:52 +0200 Subject: [PATCH 03/10] refactor: rename chatbot registration location --- .../packages/superset-core/src/views/index.ts | 4 ++-- .../ChatbotMount/ChatbotMount.test.tsx | 6 +++--- superset-frontend/src/core/chatbot/index.test.ts | 8 ++++---- superset-frontend/src/core/chatbot/index.ts | 4 ++-- superset-frontend/src/core/views/index.test.ts | 16 ++++++++-------- superset-frontend/src/views/contributions.ts | 2 +- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/superset-frontend/packages/superset-core/src/views/index.ts b/superset-frontend/packages/superset-core/src/views/index.ts index 8ba7e60f6af0..33f692920654 100644 --- a/superset-frontend/packages/superset-core/src/views/index.ts +++ b/superset-frontend/packages/superset-core/src/views/index.ts @@ -76,11 +76,11 @@ export interface View { * ); * ``` * - * @example Chatbot bubble (`superset.chatbot` — singleton, host renders one) + * @example Chatbot bubble (`core.chatbot` — singleton, host renders one) * ```typescript * views.registerView( * { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' }, - * 'superset.chatbot', + * 'core.chatbot', * () => , * ); * ``` diff --git a/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx index da19c7cca52f..25a053677155 100644 --- a/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx +++ b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx @@ -53,7 +53,7 @@ test('renders the registered chatbot inside the fixed mount slot', async () => { const provider = () =>
My Chatbot Bubble
; disposables.push( views.registerView( - { id: 'superset.chatbot', name: 'Superset Chatbot' }, + { id: 'core.chatbot', name: 'Superset Chatbot' }, CHATBOT_LOCATION, provider, ), @@ -94,7 +94,7 @@ test('isolates a failing chatbot so it does not crash the host', async () => { }; disposables.push( views.registerView( - { id: 'superset.chatbot', name: 'Superset Chatbot' }, + { id: 'core.chatbot', name: 'Superset Chatbot' }, CHATBOT_LOCATION, () => , ), @@ -110,7 +110,7 @@ test('isolates a failing chatbot so it does not crash the host', async () => { test('isolates a chatbot whose provider function itself throws', async () => { disposables.push( views.registerView( - { id: 'superset.chatbot', name: 'Superset Chatbot' }, + { id: 'core.chatbot', name: 'Superset Chatbot' }, CHATBOT_LOCATION, () => { throw new Error('provider blew up'); diff --git a/superset-frontend/src/core/chatbot/index.test.ts b/superset-frontend/src/core/chatbot/index.test.ts index c9e12e6527d3..aedbee95ed5e 100644 --- a/superset-frontend/src/core/chatbot/index.test.ts +++ b/superset-frontend/src/core/chatbot/index.test.ts @@ -36,14 +36,14 @@ test('getActiveChatbot resolves the single registered chatbot', () => { const provider = () => React.createElement('div', null, 'Chatbot'); disposables.push( views.registerView( - { id: 'superset.chatbot', name: 'Superset Chatbot' }, + { id: 'core.chatbot', name: 'Superset Chatbot' }, CHATBOT_LOCATION, provider, ), ); const active = getActiveChatbot(); - expect(active).toEqual({ id: 'superset.chatbot', provider }); + expect(active).toEqual({ id: 'core.chatbot', provider }); }); test('getActiveChatbot picks the first-to-register when multiple are installed', () => { @@ -83,12 +83,12 @@ test('getActiveChatbot ignores views registered at other locations', () => { 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' }, + { id: 'core.chatbot', name: 'Superset Chatbot' }, CHATBOT_LOCATION, provider, ); - expect(getActiveChatbot()?.id).toBe('superset.chatbot'); + expect(getActiveChatbot()?.id).toBe('core.chatbot'); disposable.dispose(); diff --git a/superset-frontend/src/core/chatbot/index.ts b/superset-frontend/src/core/chatbot/index.ts index 029b0890e32f..243c0820dbd6 100644 --- a/superset-frontend/src/core/chatbot/index.ts +++ b/superset-frontend/src/core/chatbot/index.ts @@ -17,10 +17,10 @@ * under the License. */ /** - * @fileoverview Host-internal resolver for the exclusive `superset.chatbot` + * @fileoverview Host-internal resolver for the exclusive `core.chatbot` * contribution area. * - * `superset.chatbot` is a singleton contribution area: multiple chatbot + * `core.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. * diff --git a/superset-frontend/src/core/views/index.test.ts b/superset-frontend/src/core/views/index.test.ts index 16413b109a08..1112beb64910 100644 --- a/superset-frontend/src/core/views/index.test.ts +++ b/superset-frontend/src/core/views/index.test.ts @@ -121,12 +121,12 @@ test('getViewProvider returns the registered provider for a matching location', disposables.push( views.registerView( { id: 'test.provider', name: 'Test Provider' }, - 'superset.chatbot', + 'core.chatbot', provider, ), ); - expect(getViewProvider('superset.chatbot', 'test.provider')).toBe(provider); + expect(getViewProvider('core.chatbot', 'test.provider')).toBe(provider); }); test('getViewProvider returns undefined when the location does not match', () => { @@ -140,11 +140,11 @@ test('getViewProvider returns undefined when the location does not match', () => ); // Registered, but at a different location. - expect(getViewProvider('superset.chatbot', 'test.provider')).toBeUndefined(); + expect(getViewProvider('core.chatbot', 'test.provider')).toBeUndefined(); }); test('getViewProvider returns undefined for an unknown id', () => { - expect(getViewProvider('superset.chatbot', 'nonexistent')).toBeUndefined(); + expect(getViewProvider('core.chatbot', 'nonexistent')).toBeUndefined(); }); test('getRegisteredViewIds returns ids in registration order', () => { @@ -152,22 +152,22 @@ test('getRegisteredViewIds returns ids in registration order', () => { disposables.push( views.registerView( { id: 'first.chatbot', name: 'First' }, - 'superset.chatbot', + 'core.chatbot', provider, ), views.registerView( { id: 'second.chatbot', name: 'Second' }, - 'superset.chatbot', + 'core.chatbot', provider, ), ); - expect(getRegisteredViewIds('superset.chatbot')).toEqual([ + expect(getRegisteredViewIds('core.chatbot')).toEqual([ 'first.chatbot', 'second.chatbot', ]); }); test('getRegisteredViewIds returns an empty array for an unused location', () => { - expect(getRegisteredViewIds('superset.chatbot')).toEqual([]); + expect(getRegisteredViewIds('core.chatbot')).toEqual([]); }); diff --git a/superset-frontend/src/views/contributions.ts b/superset-frontend/src/views/contributions.ts index ec075222b23a..12770b699bc7 100644 --- a/superset-frontend/src/views/contributions.ts +++ b/superset-frontend/src/views/contributions.ts @@ -24,7 +24,7 @@ */ export const AppViewLocations = { app: { - chatbot: 'superset.chatbot', + chatbot: 'core.chatbot', }, } as const; From f170dc1d9ef440aa371acea13559c998608263c3 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:32:03 +0200 Subject: [PATCH 04/10] refactor(chatbot): drop extension settings layer; resolve last-loaded chatbot (#40968) --- .../ChatbotMount/ChatbotMount.test.tsx | 41 ++++------ .../src/components/ChatbotMount/index.tsx | 46 ++--------- .../src/core/chatbot/index.test.ts | 55 +++---------- superset-frontend/src/core/chatbot/index.ts | 23 ++---- superset-frontend/src/core/dashboard/index.ts | 4 +- .../src/core/extensions/index.ts | 59 -------------- superset-frontend/src/views/App.tsx | 2 +- .../commands/extension/settings/__init__.py | 16 ---- superset/commands/extension/settings/get.py | 29 ------- superset/daos/extension.py | 44 ----------- superset/extensions/api.py | 19 ----- ...0_e2f3a4b5c6d7_drop_extension_settings.py} | 34 +++++--- superset/models/__init__.py | 2 - tests/unit_tests/extensions/test_settings.py | 77 ------------------- 14 files changed, 65 insertions(+), 386 deletions(-) delete mode 100644 superset/commands/extension/settings/__init__.py delete mode 100644 superset/commands/extension/settings/get.py delete mode 100644 superset/daos/extension.py rename superset/{extensions/models.py => migrations/versions/2026-06-10_00-00_e2f3a4b5c6d7_drop_extension_settings.py} (50%) delete mode 100644 tests/unit_tests/extensions/test_settings.py diff --git a/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx index 25a053677155..683e32b65921 100644 --- a/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx +++ b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx @@ -17,39 +17,24 @@ * under the License. */ import { render, screen } from 'spec/helpers/testing-library'; -import { SupersetClient } from '@superset-ui/core'; import { views } from 'src/core'; -import { loadExtensionSettings } from 'src/core/extensions'; import { CHATBOT_LOCATION } from 'src/views/contributions'; import ChatbotMount from '.'; const disposables: Array<{ dispose: () => void }> = []; -beforeEach(async () => { - // The settings store is a module singleton; reset it to the empty default - // (no admin pin) before each test by loading from a mocked API response. - jest.spyOn(SupersetClient, 'get').mockResolvedValue({ - json: { result: { active_chatbot_id: null } }, - } as any); - await loadExtensionSettings(); -}); - afterEach(() => { disposables.forEach(d => d.dispose()); disposables.length = 0; - jest.restoreAllMocks(); }); -test('renders nothing when no chatbot extension is registered', async () => { +test('renders nothing when no chatbot extension is registered', () => { render(); - // Wait a tick for the settings load to resolve; the corner must stay empty - // even after the gate opens (no chatbot registered → nothing to render). - await Promise.resolve(); expect(screen.queryByTestId('chatbot-mount')).not.toBeInTheDocument(); }); -test('renders the registered chatbot inside the fixed mount slot', async () => { +test('renders the registered chatbot inside the fixed mount slot', () => { const provider = () =>
My Chatbot Bubble
; disposables.push( views.registerView( @@ -61,12 +46,11 @@ test('renders the registered chatbot inside the fixed mount slot', async () => { render(); - // findBy* awaits the re-render after the initial settings load resolves. - expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument(); + 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', async () => { +test('renders the last-registered chatbot when several are installed', () => { const firstProvider = () =>
First Bubble
; const secondProvider = () =>
Second Bubble
; disposables.push( @@ -84,11 +68,12 @@ test('renders only the first-to-register chatbot when several are installed', as render(); - expect(await screen.findByText('First Bubble')).toBeInTheDocument(); - expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument(); + // Last-loaded wins: the second registration takes over the singleton bubble. + expect(screen.getByText('Second Bubble')).toBeInTheDocument(); + expect(screen.queryByText('First Bubble')).not.toBeInTheDocument(); }); -test('isolates a failing chatbot so it does not crash the host', async () => { +test('isolates a failing chatbot so it does not crash the host', () => { const FailingChatbot = () => { throw new Error('chatbot blew up'); }; @@ -102,12 +87,12 @@ test('isolates a failing chatbot so it does not crash the host', async () => { // The host-owned error boundary catches the failure; render does not throw. expect(() => render()).not.toThrow(); - // The mount slot still renders post-gate (the boundary lives inside it); - // awaiting it confirms the provider was actually exercised and contained. - expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument(); + // The mount slot still renders (the boundary lives inside it), confirming the + // provider was actually exercised and contained. + expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument(); }); -test('isolates a chatbot whose provider function itself throws', async () => { +test('isolates a chatbot whose provider function itself throws', () => { disposables.push( views.registerView( { id: 'core.chatbot', name: 'Superset Chatbot' }, @@ -121,5 +106,5 @@ test('isolates a chatbot whose provider function itself throws', async () => { // ChatbotRenderer wraps provider() in a component so ErrorBoundary catches // synchronous throws from the provider function, not just from its output. expect(() => render()).not.toThrow(); - expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument(); + expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument(); }); diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx b/superset-frontend/src/components/ChatbotMount/index.tsx index af213baa61e7..ddc430b563bf 100644 --- a/superset-frontend/src/components/ChatbotMount/index.tsx +++ b/superset-frontend/src/components/ChatbotMount/index.tsx @@ -18,10 +18,8 @@ */ import { type ReactElement, - useEffect, useMemo, useRef, - useState, useSyncExternalStore, } from 'react'; import { t } from '@apache-superset/core/translation'; @@ -32,11 +30,6 @@ import { addDangerToast } from 'src/components/MessageToasts/actions'; import { store } from 'src/views/store'; import { getActiveChatbot } from 'src/core/chatbot'; import { subscribeToRegistry, getRegistryVersion } from 'src/core/views'; -import { - getExtensionSettingsSnapshot, - loadExtensionSettings, - subscribeToExtensionSettings, -} from 'src/core/extensions'; const CHATBOT_EDGE_MARGIN = 24; @@ -53,41 +46,18 @@ const ChatbotMount = () => { const theme = useTheme(); // Notify once per mount; a crash can re-render and would otherwise re-toast. const crashNotified = useRef(false); - // Defer chatbot resolution until the first settings load resolves. Otherwise - // the initial empty-default snapshot (no pin) would briefly resolve the - // first-registered chatbot even when the DB pins a different one, mounting - // the wrong provider until the async settings response arrives. - const [settingsLoaded, setSettingsLoaded] = useState(false); - // The active chatbot is a function of two host-owned stores: the admin - // settings (active chatbot id) and the view registry (which chatbots are - // registered). Both are read via useSyncExternalStore so this re-resolves - // when either changes — no local copy of the settings state. - const settings = useSyncExternalStore( - subscribeToExtensionSettings, - getExtensionSettingsSnapshot, - ); + // The active chatbot is a function of the view registry alone: the + // most-recently-registered chatbot wins. Read via useSyncExternalStore so it + // re-resolves whenever a chatbot extension registers or disposes. const registryVersion = useSyncExternalStore( subscribeToRegistry, getRegistryVersion, ); - useEffect(() => { - // Settings fetch failure is non-fatal: the store keeps its empty default, - // which getActiveChatbot treats as "no admin pin" (falls back to the - // first-registered chatbot). Either way, unblock rendering once the request - // settles so a failed fetch never permanently hides the chatbot. - loadExtensionSettings() - .catch(() => {}) - .finally(() => setSettingsLoaded(true)); - }, []); - - const activeChatbot = useMemo( - () => getActiveChatbot(settings.active_chatbot_id), - [settings, registryVersion], - ); + const activeChatbot = useMemo(() => getActiveChatbot(), [registryVersion]); - if (!settingsLoaded || !activeChatbot) { + if (!activeChatbot) { return null; } @@ -105,9 +75,9 @@ const ChatbotMount = () => { { - // Fault isolation (SIP §4.5): contain the crash, log it, surface a - // one-time notification, and leave the corner empty rather than - // parking a persistent error card. + // Fault isolation: contain the crash, log it, surface a one-time + // notification, and leave the corner empty rather than parking a + // persistent error card. logging.error('[chatbot] provider crashed', error); if (!crashNotified.current) { crashNotified.current = true; diff --git a/superset-frontend/src/core/chatbot/index.test.ts b/superset-frontend/src/core/chatbot/index.test.ts index aedbee95ed5e..7b6eb00b0aca 100644 --- a/superset-frontend/src/core/chatbot/index.test.ts +++ b/superset-frontend/src/core/chatbot/index.test.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; +import { createElement } from 'react'; import { views } from 'src/core/views'; import { CHATBOT_LOCATION } from 'src/views/contributions'; import { getActiveChatbot } from './index'; @@ -33,7 +33,7 @@ test('getActiveChatbot returns undefined when no chatbot is registered', () => { }); test('getActiveChatbot resolves the single registered chatbot', () => { - const provider = () => React.createElement('div', null, 'Chatbot'); + const provider = () => createElement('div', null, 'Chatbot'); disposables.push( views.registerView( { id: 'core.chatbot', name: 'Superset Chatbot' }, @@ -46,9 +46,9 @@ test('getActiveChatbot resolves the single registered chatbot', () => { expect(active).toEqual({ id: 'core.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'); +test('getActiveChatbot picks the last-to-register when multiple are installed', () => { + const firstProvider = () => createElement('div', null, 'First'); + const secondProvider = () => createElement('div', null, 'Second'); disposables.push( views.registerView( { id: 'first.chatbot', name: 'First Chatbot' }, @@ -62,13 +62,14 @@ test('getActiveChatbot picks the first-to-register when multiple are installed', ), ); + // Last-loaded wins: the most-recently-registered chatbot takes the slot. const active = getActiveChatbot(); - expect(active?.id).toBe('first.chatbot'); - expect(active?.provider).toBe(firstProvider); + expect(active?.id).toBe('second.chatbot'); + expect(active?.provider).toBe(secondProvider); }); test('getActiveChatbot ignores views registered at other locations', () => { - const provider = () => React.createElement('div', null, 'Panel'); + const provider = () => createElement('div', null, 'Panel'); disposables.push( views.registerView( { id: 'some.panel', name: 'Some Panel' }, @@ -81,7 +82,7 @@ test('getActiveChatbot ignores views registered at other locations', () => { }); test('getActiveChatbot stops resolving a chatbot once it is disposed', () => { - const provider = () => React.createElement('div', null, 'Chatbot'); + const provider = () => createElement('div', null, 'Chatbot'); const disposable = views.registerView( { id: 'core.chatbot', name: 'Superset Chatbot' }, CHATBOT_LOCATION, @@ -94,39 +95,3 @@ 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'); -}); diff --git a/superset-frontend/src/core/chatbot/index.ts b/superset-frontend/src/core/chatbot/index.ts index 243c0820dbd6..eded52c85a3f 100644 --- a/superset-frontend/src/core/chatbot/index.ts +++ b/superset-frontend/src/core/chatbot/index.ts @@ -48,31 +48,22 @@ export interface ActiveChatbot { * * Selection policy: * - If no chatbot is registered, returns `undefined` — the corner stays empty. - * - If `adminSelectedId` matches a registered chatbot, that one wins. - * - Otherwise the first-registered chatbot is used as a fallback. - * The active chatbot pin is set only via the backend DB; when no pin is set - * (active_chatbot_id is null), the fallback is the first-registered chatbot. + * - Otherwise the most-recently-registered (last-loaded) chatbot wins. When a + * second chatbot extension loads, it takes over the singleton bubble. * - * @param adminSelectedId The id stored in the DB "Default chatbot" setting, if any. * @returns The active chatbot's id and provider, or `undefined` if none. */ -export const getActiveChatbot = ( - adminSelectedId?: string | null, -): ActiveChatbot | undefined => { +export const getActiveChatbot = (): ActiveChatbot | undefined => { const registeredIds = getRegisteredViewIds(CHATBOT_LOCATION); if (registeredIds.length === 0) { return undefined; } - // When the DB pin names a registered candidate, use it; otherwise fall back - // to the first registered chatbot in registration order. - // `getRegisteredViewIds` and `getViewProvider` read the same synchronous - // registry maps, so a candidate id always has a live provider; the final + // `getRegisteredViewIds` returns ids in registration order, so the last entry + // is the most-recently-loaded chatbot. `getViewProvider` reads the same + // synchronous registry maps, so the id always has a live provider; the final // guard is cheap defensiveness, not a fallback path. - const selectedId = - adminSelectedId && registeredIds.includes(adminSelectedId) - ? adminSelectedId - : registeredIds[0]; + const selectedId = registeredIds[registeredIds.length - 1]; const provider = getViewProvider(CHATBOT_LOCATION, selectedId); return provider ? { id: selectedId, provider } : undefined; diff --git a/superset-frontend/src/core/dashboard/index.ts b/superset-frontend/src/core/dashboard/index.ts index c912f8459169..89afa1050c13 100644 --- a/superset-frontend/src/core/dashboard/index.ts +++ b/superset-frontend/src/core/dashboard/index.ts @@ -56,8 +56,8 @@ function buildChartSummaries(state: RootState): ChartSummary[] { vizType: slice?.viz_type ?? '', datasourceId: slice?.datasource_id ?? null, datasourceName: slice?.datasource_name ?? null, - // Tab-accurate visibility is a deferred phase (SIP §10/§11); every chart - // on the dashboard is reported visible for now. + // Tab-accurate visibility is a deferred phase; every chart on the + // dashboard is reported visible for now. isVisible: true, }; }); diff --git a/superset-frontend/src/core/extensions/index.ts b/superset-frontend/src/core/extensions/index.ts index 2474e212c6bf..ae49a135aed8 100644 --- a/superset-frontend/src/core/extensions/index.ts +++ b/superset-frontend/src/core/extensions/index.ts @@ -17,7 +17,6 @@ * under the License. */ import { extensions as extensionsApi } from '@apache-superset/core'; -import { SupersetClient } from '@superset-ui/core'; import ExtensionsLoader from 'src/extensions/ExtensionsLoader'; const getExtension: typeof extensionsApi.getExtension = id => @@ -30,61 +29,3 @@ export const extensions: typeof extensionsApi = { getExtension, getAllExtensions, }; - -/** - * Deployment-wide extension admin settings. The keys are snake_case to match - * the `/api/v1/extensions/settings` wire shape this store loads from. - * Settings are read-only from the frontend; the admin write path has been - * removed in favour of direct backend configuration. - */ -export type ExtensionSettings = { - active_chatbot_id: string | null; -}; - -const SETTINGS_ENDPOINT = '/api/v1/extensions/settings'; - -const EMPTY_SETTINGS: ExtensionSettings = { - active_chatbot_id: null, -}; - -/** - * Single module-level store for extension admin settings. The chatbot mount - * reads this one source via `useSyncExternalStore` so it re-resolves when the - * store is updated — no bespoke second notification channel needed. - */ -let settings: ExtensionSettings = EMPTY_SETTINGS; -const settingsListeners = new Set<() => void>(); - -const emitSettingsChange = (): void => { - settingsListeners.forEach(fn => fn()); -}; - -/** Subscribe to settings changes (for `useSyncExternalStore`). */ -export const subscribeToExtensionSettings = ( - listener: () => void, -): (() => void) => { - settingsListeners.add(listener); - return () => { - settingsListeners.delete(listener); - }; -}; - -/** Current settings snapshot (for `useSyncExternalStore`). */ -export const getExtensionSettingsSnapshot = (): ExtensionSettings => settings; - -/** Replace the settings snapshot and notify subscribers. Module-private; only loadExtensionSettings should call this. */ -const applyExtensionSettings = (next: ExtensionSettings): void => { - settings = next; - emitSettingsChange(); -}; - -/** - * Fetch settings from the server into the store. Resolves to the loaded value; - * on failure the store is left untouched and the error is rethrown so callers - * can surface it. - */ -export const loadExtensionSettings = async (): Promise => { - const { json } = await SupersetClient.get({ endpoint: SETTINGS_ENDPOINT }); - applyExtensionSettings(json.result ?? EMPTY_SETTINGS); - return settings; -}; diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index d70aa0f86f91..0ea9014f5759 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -118,7 +118,7 @@ const App = () => ( 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). + across route changes. */} {isFeatureEnabled(FeatureFlag.EnableExtensions) && }
diff --git a/superset/commands/extension/settings/__init__.py b/superset/commands/extension/settings/__init__.py deleted file mode 100644 index 13a83393a912..000000000000 --- a/superset/commands/extension/settings/__init__.py +++ /dev/null @@ -1,16 +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. diff --git a/superset/commands/extension/settings/get.py b/superset/commands/extension/settings/get.py deleted file mode 100644 index 401dbfb449ea..000000000000 --- a/superset/commands/extension/settings/get.py +++ /dev/null @@ -1,29 +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. -from typing import Any - -from superset.commands.base import BaseCommand -from superset.daos.extension import get_extension_settings - - -class GetExtensionSettingsCommand(BaseCommand): - def run(self) -> dict[str, Any]: - self.validate() - return get_extension_settings() - - def validate(self) -> None: - return None diff --git a/superset/daos/extension.py b/superset/daos/extension.py deleted file mode 100644 index 10557cb87b67..000000000000 --- a/superset/daos/extension.py +++ /dev/null @@ -1,44 +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. -from typing import Any - -from superset import db -from superset.daos.base import BaseDAO -from superset.extensions.models import ExtensionSettings - -# The global extension settings live in a single row; id is fixed so the row -# can be fetched without a secondary lookup. -SETTINGS_ROW_ID = 1 - - -class ExtensionSettingsDAO(BaseDAO[ExtensionSettings]): - """Persistence for the singleton global extension settings row. - - The row (id=1) holds global admin state such as the active chatbot id. - """ - - @staticmethod - def get_settings_row() -> ExtensionSettings | None: - return db.session.get(ExtensionSettings, SETTINGS_ROW_ID) - - -def get_extension_settings() -> dict[str, Any]: - """Read-only view of the extension settings.""" - row = ExtensionSettingsDAO.get_settings_row() - return { - "active_chatbot_id": row.active_chatbot_id if row else None, - } diff --git a/superset/extensions/api.py b/superset/extensions/api.py index cf08a9fbe204..68ca938af1c6 100644 --- a/superset/extensions/api.py +++ b/superset/extensions/api.py @@ -23,7 +23,6 @@ from flask.wrappers import Response from flask_appbuilder.api import expose, protect, safe -from superset.commands.extension.settings.get import GetExtensionSettingsCommand from superset.extensions.utils import ( build_extension_data, get_extensions, @@ -50,7 +49,6 @@ class ExtensionsRestApi(BaseSupersetApi): "can_get", "can_content", "can_info", - "can_get_settings", ] @expose("/_info", methods=("GET",)) @@ -177,23 +175,6 @@ def get(self, publisher: str, name: str, **kwargs: Any) -> Response: 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. - - No admin gate here by design: authenticated non-admin users need these - settings so the ChatbotMount can read active_chatbot_id on every page. - --- - get: - summary: Get extension admin settings (active chatbot id). - responses: - 200: - description: Extension settings - """ - return self.response(200, result=GetExtensionSettingsCommand().run()) - @protect() @safe @expose("///", methods=("GET",)) diff --git a/superset/extensions/models.py b/superset/migrations/versions/2026-06-10_00-00_e2f3a4b5c6d7_drop_extension_settings.py similarity index 50% rename from superset/extensions/models.py rename to superset/migrations/versions/2026-06-10_00-00_e2f3a4b5c6d7_drop_extension_settings.py index 700f5b7b6b5b..6a0c3f140774 100644 --- a/superset/extensions/models.py +++ b/superset/migrations/versions/2026-06-10_00-00_e2f3a4b5c6d7_drop_extension_settings.py @@ -14,19 +14,33 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +"""Drop extension_settings table (ExtensionSettings model removed in chatbot SIP). -"""SQLAlchemy models for extension settings persistence.""" +The active chatbot is now resolved purely from the view registry (last-loaded +wins), so the admin-pin settings table is no longer read or written. -from flask_appbuilder import Model -from sqlalchemy import Column, Integer, String +Revision ID: e2f3a4b5c6d7 +Revises: d1e2f3a4b5c6 +Create Date: 2026-06-10 00:00:00.000000 -# Column length for extension/chatbot identifiers. -EXTENSION_ID_MAX_LENGTH = 250 +""" +import sqlalchemy as sa -class ExtensionSettings(Model): # pylint: disable=too-few-public-methods - """Global admin settings for extensions (singleton row, id=1).""" +from superset.migrations.shared.utils import create_table, drop_table - __tablename__ = "extension_settings" - id = Column(Integer, primary_key=True) - active_chatbot_id = Column(String(EXTENSION_ID_MAX_LENGTH), nullable=True) +# revision identifiers, used by Alembic. +revision = "e2f3a4b5c6d7" +down_revision = "d1e2f3a4b5c6" + + +def upgrade() -> None: + drop_table("extension_settings") + + +def downgrade() -> None: + create_table( + "extension_settings", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("active_chatbot_id", sa.String(250), nullable=True), + ) diff --git a/superset/models/__init__.py b/superset/models/__init__.py index 639e3c61fb9f..750c61ccc23d 100644 --- a/superset/models/__init__.py +++ b/superset/models/__init__.py @@ -14,6 +14,4 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from superset.extensions import models as extensions_models # noqa: F401 - from . import core, dynamic_plugins, sql_lab, user_attributes # noqa: F401 diff --git a/tests/unit_tests/extensions/test_settings.py b/tests/unit_tests/extensions/test_settings.py deleted file mode 100644 index c32d38ccdd7d..000000000000 --- a/tests/unit_tests/extensions/test_settings.py +++ /dev/null @@ -1,77 +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. - -"""Unit tests for extension settings persistence and the settings API endpoint. - -Persistence is exercised through the public Command + DAO layer: -``GetExtensionSettingsCommand`` and ``ExtensionSettingsDAO``. -""" - -from __future__ import annotations - -from typing import Any - -import pytest - -# --------------------------------------------------------------------------- -# Settings persistence (Command + DAO) — sqlite-backed round-trip tests -# --------------------------------------------------------------------------- - - -class TestGetExtensionSettings: - def test_returns_defaults_when_no_rows(self, app_context: Any) -> None: - from superset.commands.extension.settings.get import ( - GetExtensionSettingsCommand, - ) - - result = GetExtensionSettingsCommand().run() - assert result["active_chatbot_id"] is None - - -# --------------------------------------------------------------------------- -# GET /api/v1/extensions/settings -# --------------------------------------------------------------------------- - -# The settings routes are only registered when ENABLE_EXTENSIONS is on at -# app-init time, so the endpoint tests parametrize the app fixture to enable it -# (otherwise the route is absent and requests 404). -_ENABLE_EXTENSIONS = [{"FEATURE_FLAGS": {"ENABLE_EXTENSIONS": True}}] - - -@pytest.mark.parametrize("app", _ENABLE_EXTENSIONS, indirect=True) -class TestGetSettingsEndpoint: - def test_authenticated_user_can_read( - self, client: Any, full_api_access: None, mocker: Any - ) -> None: - mocker.patch( - "superset.extensions.api.GetExtensionSettingsCommand.run", - return_value={"active_chatbot_id": None}, - ) - resp = client.get("/api/v1/extensions/settings") - assert resp.status_code == 200 - assert resp.json["result"]["active_chatbot_id"] is None - - def test_returns_active_chatbot_id( - self, client: Any, full_api_access: None, mocker: Any - ) -> None: - mocker.patch( - "superset.extensions.api.GetExtensionSettingsCommand.run", - return_value={"active_chatbot_id": "acme.chatbot"}, - ) - resp = client.get("/api/v1/extensions/settings") - assert resp.status_code == 200 - assert resp.json["result"]["active_chatbot_id"] == "acme.chatbot" From 568337f37058dcee38bcf20a0864ee9f8ada0fd6 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:54:13 +0200 Subject: [PATCH 05/10] feat(extensions): add dedicated chat contribution type (#41000) --- .../packages/superset-core/package.json | 4 + .../packages/superset-core/src/chat/index.ts | 185 ++++++++++ .../superset-core/src/contributions/index.ts | 16 +- .../packages/superset-core/src/index.ts | 1 + .../packages/superset-core/src/views/index.ts | 9 - .../components/ChatMount/ChatMount.test.tsx | 287 +++++++++++++++ .../src/components/ChatMount/index.tsx | 149 ++++++++ .../ChatbotMount/ChatbotMount.test.tsx | 110 ------ .../src/components/ChatbotMount/index.tsx | 94 ----- superset-frontend/src/core/chat/index.test.ts | 331 ++++++++++++++++++ superset-frontend/src/core/chat/index.ts | 238 +++++++++++++ .../src/core/chatbot/index.test.ts | 97 ----- superset-frontend/src/core/chatbot/index.ts | 70 ---- superset-frontend/src/core/index.ts | 1 + superset-frontend/src/core/utils.ts | 29 +- .../src/core/views/index.test.ts | 63 +--- superset-frontend/src/core/views/index.ts | 46 --- .../src/extensions/ExtensionsStartup.test.tsx | 1 + .../src/extensions/ExtensionsStartup.tsx | 6 +- .../src/extensions/supersetGlobal.ts | 2 + superset-frontend/src/views/App.tsx | 10 +- superset-frontend/src/views/contributions.ts | 31 -- 22 files changed, 1245 insertions(+), 535 deletions(-) create mode 100644 superset-frontend/packages/superset-core/src/chat/index.ts create mode 100644 superset-frontend/src/components/ChatMount/ChatMount.test.tsx create mode 100644 superset-frontend/src/components/ChatMount/index.tsx delete mode 100644 superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx delete mode 100644 superset-frontend/src/components/ChatbotMount/index.tsx create mode 100644 superset-frontend/src/core/chat/index.test.ts create mode 100644 superset-frontend/src/core/chat/index.ts delete mode 100644 superset-frontend/src/core/chatbot/index.test.ts delete mode 100644 superset-frontend/src/core/chatbot/index.ts delete mode 100644 superset-frontend/src/views/contributions.ts diff --git a/superset-frontend/packages/superset-core/package.json b/superset-frontend/packages/superset-core/package.json index 0df87979acaf..2866673fe5b6 100644 --- a/superset-frontend/packages/superset-core/package.json +++ b/superset-frontend/packages/superset-core/package.json @@ -18,6 +18,10 @@ "types": "./lib/authentication/index.d.ts", "default": "./lib/authentication/index.js" }, + "./chat": { + "types": "./lib/chat/index.d.ts", + "default": "./lib/chat/index.js" + }, "./dashboard": { "types": "./lib/dashboard/index.d.ts", "default": "./lib/dashboard/index.js" diff --git a/superset-frontend/packages/superset-core/src/chat/index.ts b/superset-frontend/packages/superset-core/src/chat/index.ts new file mode 100644 index 000000000000..612b3d289106 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/chat/index.ts @@ -0,0 +1,185 @@ +/** + * 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 Chat contribution API for Superset extensions. + * + * Chat is a dedicated contribution type (not a view): an extension registers + * a chat via {@link registerChat} and the host owns where and how it is + * mounted. The host applies singleton resolution — multiple chat extensions + * may register, but exactly one is active at a time. + * + * @example + * ```typescript + * import { chat } from '@apache-superset/core'; + * + * chat.registerChat( + * { id: 'acme.chat', name: 'Acme Chat' }, + * () => , + * () => , + * ); + * ``` + */ + +import { ReactElement } from 'react'; +import type { Disposable, Event } from '../common'; + +export interface Chat { + /** The unique identifier for the chat. */ + id: string; + /** The display name of the chat. */ + name: string; + /** Optional description of the chat, for display in contribution manifests. */ + description?: string; +} + +export type ChatMode = 'floating' | 'panel'; + +/** + * Registers a chat provider. The host applies singleton resolution — only one + * chat is active at a time: the most recently registered chat wins, and + * disposing it restores the previously registered one. Re-registering an id + * replaces that registration in place. + * + * When a registration with a different id takes over the active slot (or the + * active chat is disposed), the host closes the panel first, firing + * {@link onDidClose}; an in-place same-id replacement keeps the open state. + * + * Disposing the returned Disposable unregisters the chat. + * + * @param chat The chat descriptor (id, name). + * @param trigger A function returning the collapsed bubble element. Owned by + * the extension — dynamic state such as unread counts and badges lives here. + * Hidden by the host when in panel mode. + * @param panel A function returning the chat panel element. Mounted by the + * host as a floating overlay in 'floating' mode, or docked at the side of + * the viewport in 'panel' mode (the reference host docks a fixed-width + * overlay at the right edge; hosts may integrate a true layout slot + * instead). Same component in both modes. + * @returns A Disposable that unregisters the chat when disposed. + * + * @example + * ```typescript + * chat.registerChat( + * { id: 'acme.chat', name: 'Acme Chat' }, + * () => , + * () => , + * ); + * ``` + */ +export declare function registerChat( + chat: Chat, + trigger: () => ReactElement, + panel: () => ReactElement, +): Disposable; + +/** + * Returns the active chat descriptor. + * + * @returns A copy of the active Chat descriptor, or undefined if none is + * registered. Mutating the returned object has no effect on the registry. + */ +export declare function getChat(): Chat | undefined; + +/** + * Event fired when a chat is registered. + */ +export declare const onDidRegisterChat: Event; + +/** + * Event fired when a chat is unregistered. + */ +export declare const onDidUnregisterChat: Event; + +/** + * Opens the active chat's panel. + * + * Acts on whichever chat is active, regardless of which extension calls it. + * No-op when no chat is registered or the panel is already open. + */ +export declare function open(): void; + +/** + * Closes the active chat's panel. + * + * Acts on whichever chat is active, regardless of which extension calls it. + * No-op when the panel is not open. + */ +export declare function close(): void; + +/** + * Returns whether the active chat's panel is currently open. + * + * @returns True if the chat panel is open. + */ +export declare function isOpen(): boolean; + +/** + * Event fired when the chat panel opens, with the descriptor of the chat + * whose panel opened. Listen to this rather than assuming your own chat is + * the one affected — another registration may hold the active slot. + */ +export declare const onDidOpen: Event; + +/** + * Event fired when the chat panel closes, with the descriptor of the chat + * whose panel closed. Also fired when the host closes the panel itself, e.g. + * because the active chat was disposed or displaced by a different chat. + */ +export declare const onDidClose: Event; + +/** + * Returns the current display mode. + * + * @returns The current ChatMode. + */ +export declare function getMode(): ChatMode; + +/** + * Sets the display mode. + * + * The mode is host-global and applies to whichever chat is active, regardless + * of which extension calls it. Hosts may also change the mode through their + * own controls — use onDidChangeMode to observe all changes rather than + * assuming the last setMode() call won. + * + * @param mode The display mode to switch to. + */ +export declare function setMode(mode: ChatMode): void; + +/** + * Event fired when the display mode changes, whether triggered by an + * extension via setMode() or by host-provided controls. + */ +export declare const onDidChangeMode: Event; + +/** + * Event fired when the panel is resized in panel mode. + * + * The host owns the resizer handle and drag interaction; a host without a + * resizer never fires this event. (The reference host mounts the panel at a + * fixed width and does not provide a resizer, so subscribers receive no + * events there.) Listen to this event to adapt internal layout to the + * available width; do not rely on it firing. + */ +export declare const onDidResizePanel: Event<{ width: number }>; + +// TODO: client actions API — tool availability functions will be added here +// once the client_actions SIP is finalized. The chat namespace is the +// intended integration point between the two SIPs. diff --git a/superset-frontend/packages/superset-core/src/contributions/index.ts b/superset-frontend/packages/superset-core/src/contributions/index.ts index 86cf42d1959b..f24ae36dba0a 100644 --- a/superset-frontend/packages/superset-core/src/contributions/index.ts +++ b/superset-frontend/packages/superset-core/src/contributions/index.ts @@ -23,9 +23,10 @@ * 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. + * menus, editors, chat) and re-exported here for the manifest schema. */ +import { Chat } from '../chat'; import { Command } from '../commands'; import { View } from '../views'; import { Menu } from '../menus'; @@ -43,9 +44,6 @@ export type SqlLabLocation = | 'results' | 'queryHistory'; -/** Valid locations within the app shell (persist across all routes). */ -export type AppLocation = 'chatbot'; - /** * Nested structure for view contributions by scope and location. * @example @@ -58,7 +56,6 @@ export type AppLocation = 'chatbot'; */ export interface ViewContributions { sqllab?: Partial>; - app?: Partial>; } /** @@ -75,7 +72,8 @@ export interface MenuContributions { } /** - * Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module. + * Aggregates all contributions (commands, menus, views, editors, and chat) + * provided by an extension or module. */ export interface Contributions { /** List of commands. */ @@ -86,4 +84,10 @@ export interface Contributions { views: ViewContributions; /** List of editors. */ editors?: Editor[]; + /** + * The chat contributed by the extension — at most one per extension, since + * the host applies singleton resolution and renders exactly one active + * chat at a time. + */ + chat?: Chat; } diff --git a/superset-frontend/packages/superset-core/src/index.ts b/superset-frontend/packages/superset-core/src/index.ts index 79c699caff43..417a8af2e828 100644 --- a/superset-frontend/packages/superset-core/src/index.ts +++ b/superset-frontend/packages/superset-core/src/index.ts @@ -18,6 +18,7 @@ */ export * as common from './common'; export * as authentication from './authentication'; +export * as chat from './chat'; export * as commands from './commands'; export * as dashboard from './dashboard'; export * as dataset from './dataset'; diff --git a/superset-frontend/packages/superset-core/src/views/index.ts b/superset-frontend/packages/superset-core/src/views/index.ts index 33f692920654..32c27a179124 100644 --- a/superset-frontend/packages/superset-core/src/views/index.ts +++ b/superset-frontend/packages/superset-core/src/views/index.ts @@ -75,15 +75,6 @@ export interface View { * () => , * ); * ``` - * - * @example Chatbot bubble (`core.chatbot` — singleton, host renders one) - * ```typescript - * views.registerView( - * { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' }, - * 'core.chatbot', - * () => , - * ); - * ``` */ export declare function registerView( view: View, diff --git a/superset-frontend/src/components/ChatMount/ChatMount.test.tsx b/superset-frontend/src/components/ChatMount/ChatMount.test.tsx new file mode 100644 index 000000000000..069768310ee3 --- /dev/null +++ b/superset-frontend/src/components/ChatMount/ChatMount.test.tsx @@ -0,0 +1,287 @@ +/** + * 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 { act, render, screen } from 'spec/helpers/testing-library'; +import { chat } from 'src/core/chat'; +import ChatMount from '.'; + +const disposables: Array<{ dispose: () => void }> = []; + +afterEach(() => { + act(() => { + disposables.forEach(d => d.dispose()); + disposables.length = 0; + // Reset host-owned state shared across tests in this module. + chat.close(); + chat.setMode('floating'); + }); +}); + +test('renders nothing when no chat extension is registered', () => { + render(); + + expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument(); +}); + +test('renders the trigger bubble of the registered chat', () => { + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ), + ); + + render(); + + expect(screen.getByTestId('chat-mount')).toBeInTheDocument(); + expect(screen.getByText('Acme Bubble')).toBeInTheDocument(); + // The panel stays unmounted until the chat is opened. + expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument(); +}); + +test('mounts the panel when the chat opens and unmounts it on close', () => { + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ), + ); + + render(); + + act(() => chat.open()); + + expect(screen.getByText('Acme Panel')).toBeInTheDocument(); + // In floating mode the trigger stays mounted alongside the open panel. + expect(screen.getByText('Acme Bubble')).toBeInTheDocument(); + + act(() => chat.close()); + + expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument(); +}); + +test('renders the last-registered chat when several are installed', () => { + disposables.push( + chat.registerChat( + { id: 'first.chat', name: 'First Chat' }, + () =>
First Bubble
, + () =>
First Panel
, + ), + chat.registerChat( + { id: 'second.chat', name: 'Second Chat' }, + () =>
Second Bubble
, + () =>
Second Panel
, + ), + ); + + render(); + + // Last-loaded wins: the second registration takes over the singleton slot. + expect(screen.getByText('Second Bubble')).toBeInTheDocument(); + expect(screen.queryByText('First Bubble')).not.toBeInTheDocument(); +}); + +test('reacts to a chat registering after the initial render', () => { + render(); + + expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument(); + + act(() => { + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ), + ); + }); + + expect(screen.getByText('Acme Bubble')).toBeInTheDocument(); +}); + +test('a takeover mounts the incoming chat closed', () => { + disposables.push( + chat.registerChat( + { id: 'first.chat', name: 'First Chat' }, + () =>
First Bubble
, + () =>
First Panel
, + ), + ); + + render(); + act(() => chat.open()); + expect(screen.getByText('First Panel')).toBeInTheDocument(); + + act(() => { + disposables.push( + chat.registerChat( + { id: 'second.chat', name: 'Second Chat' }, + () =>
Second Bubble
, + () =>
Second Panel
, + ), + ); + }); + + // The displaced chat's open state must not leak into the winner. + expect(screen.getByText('Second Bubble')).toBeInTheDocument(); + expect(screen.queryByText('Second Panel')).not.toBeInTheDocument(); + expect(screen.queryByText('First Panel')).not.toBeInTheDocument(); +}); + +test('panel mode docks the open panel and hides the trigger', () => { + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ), + ); + + render(); + + act(() => { + chat.setMode('panel'); + chat.open(); + }); + + expect(screen.getByText('Acme Panel')).toBeInTheDocument(); + expect(screen.queryByText('Acme Bubble')).not.toBeInTheDocument(); + + act(() => chat.close()); + + // A closed chat in panel mode renders nothing — the trigger is hidden too. + expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument(); +}); + +test('a crashing panel does not take the trigger down with it', () => { + const FailingPanel = () => { + throw new Error('panel blew up'); + }; + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () => , + ), + ); + + render(); + act(() => chat.open()); + + // The panel's boundary contains the crash; the trigger keeps rendering so + // the user is not stranded without a way back. + expect(screen.queryByText('panel blew up')).not.toBeInTheDocument(); + expect(screen.getByText('Acme Bubble')).toBeInTheDocument(); +}); + +test('isolates a failing trigger so it does not crash the host', () => { + const FailingTrigger = () => { + throw new Error('chat blew up'); + }; + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ), + ); + + // The host-owned error boundary catches the failure; render does not throw. + expect(() => render()).not.toThrow(); + // The mount slot still renders (the boundary lives inside it), confirming + // the provider was actually exercised and contained. + expect(screen.getByTestId('chat-mount')).toBeInTheDocument(); +}); + +test('isolates a chat whose provider function itself throws', () => { + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => { + throw new Error('provider blew up'); + }, + () =>
Acme Panel
, + ), + ); + + // ChatRenderer wraps provider() in a component so ErrorBoundary catches + // synchronous throws from the provider function, not just from its output. + expect(() => render()).not.toThrow(); + expect(screen.getByTestId('chat-mount')).toBeInTheDocument(); +}); + +test('recovers from a crashed chat when a different chat takes over', () => { + const FailingTrigger = () => { + throw new Error('first chat blew up'); + }; + disposables.push( + chat.registerChat( + { id: 'first.chat', name: 'First Chat' }, + () => , + () =>
First Panel
, + ), + ); + + render(); + expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument(); + + act(() => { + disposables.push( + chat.registerChat( + { id: 'second.chat', name: 'Second Chat' }, + () =>
Second Bubble
, + () =>
Second Panel
, + ), + ); + }); + + // The boundary is keyed per registration, so the latched crash from the + // first chat does not blank the second one. + expect(screen.getByText('Second Bubble')).toBeInTheDocument(); +}); + +test('recovers when a crashed chat re-registers a fixed version under the same id', () => { + const FailingTrigger = () => { + throw new Error('broken release'); + }; + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ), + ); + + render(); + expect(screen.queryByText('Fixed Bubble')).not.toBeInTheDocument(); + + act(() => { + disposables.push( + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () =>
Fixed Bubble
, + () =>
Acme Panel
, + ), + ); + }); + + // Same id, new registrationId: the remounted boundary renders the fix. + expect(screen.getByText('Fixed Bubble')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/components/ChatMount/index.tsx b/superset-frontend/src/components/ChatMount/index.tsx new file mode 100644 index 000000000000..4f9c96287381 --- /dev/null +++ b/superset-frontend/src/components/ChatMount/index.tsx @@ -0,0 +1,149 @@ +/** + * 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 { type ReactElement, useRef, useSyncExternalStore } from 'react'; +import { t } from '@apache-superset/core/translation'; +import { logging } from '@apache-superset/core/utils'; +import { css, useTheme } from '@apache-superset/core/theme'; +import { ErrorBoundary } from 'src/components/ErrorBoundary'; +import { addDangerToast } from 'src/components/MessageToasts/actions'; +import { store } from 'src/views/store'; +import { getChatSnapshot, subscribeToChatState } from 'src/core/chat'; + +const CHAT_EDGE_MARGIN = 24; +const PANEL_MODE_WIDTH = 400; + +/** + * Wraps a chat provider in a React component so that ErrorBoundary can catch + * synchronous throws from the provider function itself. Calling `provider()` + * inline (e.g. `{activeChat.panel()}`) would throw outside React's render + * boundary and crash the host. + */ +const ChatRenderer = ({ provider }: { provider: () => ReactElement }) => + provider(); + +const ChatMount = () => { + const theme = useTheme(); + // Notify at most once per registration; a crash can re-render and would + // otherwise re-toast, while a replacement (new registrationId) deserves a + // fresh notification if it crashes too. + const crashNotifiedFor = useRef(null); + + // The active chat, the open state, and the display mode are read from one + // immutable registry snapshot so a render never mixes state from two + // different store versions (the tearing useSyncExternalStore prevents). + const { + open: panelOpen, + mode, + active, + } = useSyncExternalStore(subscribeToChatState, getChatSnapshot); + + if (!active) { + return null; + } + + const { registrationId } = active; + + const onProviderError = (error: Error) => { + // Fault isolation: contain the crash, log it, surface a one-time + // notification, and leave the slot empty rather than parking a + // persistent error card. + logging.error('[chat] provider crashed', error); + if (crashNotifiedFor.current !== registrationId) { + crashNotifiedFor.current = registrationId; + store.dispatch(addDangerToast(t('The chat failed to load.'))); + } + }; + + if (mode === 'panel') { + // Panel mode hides the trigger and docks the panel to the right edge. + // Interim approximation of the "layout slot between header and footer" + // from the chat API contract — the dock overlays the page until the host + // grows a real layout slot and resizer chrome. + if (!panelOpen) { + return null; + } + return ( +
+ + + +
+ ); + } + + return ( +
+ {/* + Each provider gets its own boundary so a crashing panel cannot take + the trigger down with it (the trigger is the user's only way back). + Keyed by registrationId: Superset's ErrorBoundary latches its error + state, so a takeover, fallback, or same-id re-registration must + remount the boundary to recover. + */} + {panelOpen && ( + + + + )} + + + +
+ ); +}; + +export default ChatMount; diff --git a/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx b/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx deleted file mode 100644 index 683e32b65921..000000000000 --- a/superset-frontend/src/components/ChatbotMount/ChatbotMount.test.tsx +++ /dev/null @@ -1,110 +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. - */ -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(); - - expect(screen.queryByTestId('chatbot-mount')).not.toBeInTheDocument(); -}); - -test('renders the registered chatbot inside the fixed mount slot', () => { - const provider = () =>
My Chatbot Bubble
; - disposables.push( - views.registerView( - { id: 'core.chatbot', name: 'Superset Chatbot' }, - CHATBOT_LOCATION, - provider, - ), - ); - - render(); - - expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument(); - expect(screen.getByText('My Chatbot Bubble')).toBeInTheDocument(); -}); - -test('renders the last-registered chatbot when several are installed', () => { - const firstProvider = () =>
First Bubble
; - const secondProvider = () =>
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(); - - // Last-loaded wins: the second registration takes over the singleton bubble. - expect(screen.getByText('Second Bubble')).toBeInTheDocument(); - expect(screen.queryByText('First 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: 'core.chatbot', name: 'Superset Chatbot' }, - CHATBOT_LOCATION, - () => , - ), - ); - - // The host-owned error boundary catches the failure; render does not throw. - expect(() => render()).not.toThrow(); - // The mount slot still renders (the boundary lives inside it), confirming the - // provider was actually exercised and contained. - expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument(); -}); - -test('isolates a chatbot whose provider function itself throws', () => { - disposables.push( - views.registerView( - { id: 'core.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()).not.toThrow(); - expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument(); -}); diff --git a/superset-frontend/src/components/ChatbotMount/index.tsx b/superset-frontend/src/components/ChatbotMount/index.tsx deleted file mode 100644 index ddc430b563bf..000000000000 --- a/superset-frontend/src/components/ChatbotMount/index.tsx +++ /dev/null @@ -1,94 +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. - */ -import { - type ReactElement, - useMemo, - useRef, - useSyncExternalStore, -} from 'react'; -import { t } from '@apache-superset/core/translation'; -import { logging } from '@apache-superset/core/utils'; -import { css, useTheme } from '@apache-superset/core/theme'; -import { ErrorBoundary } from 'src/components/ErrorBoundary'; -import { addDangerToast } from 'src/components/MessageToasts/actions'; -import { store } from 'src/views/store'; -import { getActiveChatbot } from 'src/core/chatbot'; -import { subscribeToRegistry, getRegistryVersion } from 'src/core/views'; - -const CHATBOT_EDGE_MARGIN = 24; - -/** - * 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(); - // Notify once per mount; a crash can re-render and would otherwise re-toast. - const crashNotified = useRef(false); - - // The active chatbot is a function of the view registry alone: the - // most-recently-registered chatbot wins. Read via useSyncExternalStore so it - // re-resolves whenever a chatbot extension registers or disposes. - const registryVersion = useSyncExternalStore( - subscribeToRegistry, - getRegistryVersion, - ); - - const activeChatbot = useMemo(() => getActiveChatbot(), [registryVersion]); - - if (!activeChatbot) { - return null; - } - - return ( -
- { - // Fault isolation: contain the crash, log it, surface a one-time - // notification, and leave the corner empty rather than parking a - // persistent error card. - logging.error('[chatbot] provider crashed', error); - if (!crashNotified.current) { - crashNotified.current = true; - store.dispatch(addDangerToast(t('The chatbot failed to load.'))); - } - }} - > - - -
- ); -}; - -export default ChatbotMount; diff --git a/superset-frontend/src/core/chat/index.test.ts b/superset-frontend/src/core/chat/index.test.ts new file mode 100644 index 000000000000..b764699dc0ea --- /dev/null +++ b/superset-frontend/src/core/chat/index.test.ts @@ -0,0 +1,331 @@ +/** + * 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 { createElement } from 'react'; +import { chat, getActiveChat, getChatSnapshot } from './index'; + +const disposables: Array<{ dispose: () => void }> = []; + +const trigger = () => createElement('button', null, 'Bubble'); +const panel = () => createElement('div', null, 'Panel'); + +afterEach(() => { + disposables.forEach(d => d.dispose()); + disposables.length = 0; + // Reset host-owned state shared across tests in this module. + chat.close(); + chat.setMode('floating'); +}); + +test('getChat returns undefined when no chat is registered', () => { + expect(chat.getChat()).toBeUndefined(); + expect(getActiveChat()).toBeUndefined(); +}); + +test('registerChat resolves the registered chat with its providers', () => { + const descriptor = { id: 'acme.chat', name: 'Acme Chat' }; + disposables.push(chat.registerChat(descriptor, trigger, panel)); + + expect(chat.getChat()).toEqual(descriptor); + expect(getActiveChat()).toMatchObject({ chat: descriptor, trigger, panel }); +}); + +test('getChat returns a copy that cannot mutate the registry', () => { + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme Chat' }, trigger, panel), + ); + + const copy = chat.getChat(); + copy!.name = 'Hijacked'; + + expect(chat.getChat()?.name).toBe('Acme Chat'); +}); + +test('the last-registered chat wins when multiple are installed', () => { + disposables.push( + chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel), + chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel), + ); + + expect(chat.getChat()?.id).toBe('second.chat'); +}); + +test('disposing the active chat falls back to the previous registration', () => { + disposables.push( + chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel), + ); + const second = chat.registerChat( + { id: 'second.chat', name: 'Second' }, + trigger, + panel, + ); + + expect(chat.getChat()?.id).toBe('second.chat'); + + second.dispose(); + + expect(chat.getChat()?.id).toBe('first.chat'); +}); + +test('re-registering an id replaces the previous registration', () => { + const stalePanel = () => createElement('div', null, 'Stale'); + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, stalePanel), + chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel), + ); + + expect(chat.getChat()?.name).toBe('Acme v2'); + expect(getActiveChat()?.panel).toBe(panel); +}); + +test('each registration gets a distinct registrationId, including same-id replacements', () => { + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel), + ); + const first = getActiveChat()?.registrationId; + + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel), + ); + const second = getActiveChat()?.registrationId; + + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(second).not.toBe(first); +}); + +test('disposing a registration twice unregisters only once', () => { + const unregistered = jest.fn(); + disposables.push(chat.onDidUnregisterChat(unregistered)); + + const registration = chat.registerChat( + { id: 'acme.chat', name: 'Acme' }, + trigger, + panel, + ); + registration.dispose(); + registration.dispose(); + + expect(unregistered).toHaveBeenCalledTimes(1); + expect(chat.getChat()).toBeUndefined(); +}); + +test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => { + const registered = jest.fn(); + const unregistered = jest.fn(); + disposables.push( + chat.onDidRegisterChat(registered), + chat.onDidUnregisterChat(unregistered), + ); + + const descriptor = { id: 'acme.chat', name: 'Acme' }; + const registration = chat.registerChat(descriptor, trigger, panel); + + expect(registered).toHaveBeenCalledWith(descriptor); + expect(unregistered).not.toHaveBeenCalled(); + + registration.dispose(); + + expect(unregistered).toHaveBeenCalledWith(descriptor); +}); + +test('a disposed event subscription stops receiving notifications', () => { + const registered = jest.fn(); + const subscription = chat.onDidRegisterChat(registered); + subscription.dispose(); + + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel), + ); + + expect(registered).not.toHaveBeenCalled(); +}); + +test('open and close toggle the panel and fire once with the active descriptor', () => { + const opened = jest.fn(); + const closed = jest.fn(); + disposables.push(chat.onDidOpen(opened), chat.onDidClose(closed)); + + const descriptor = { id: 'acme.chat', name: 'Acme' }; + disposables.push(chat.registerChat(descriptor, trigger, panel)); + + expect(chat.isOpen()).toBe(false); + + chat.open(); + // Opening an already-open panel is a no-op and must not re-fire. + chat.open(); + + expect(chat.isOpen()).toBe(true); + expect(opened).toHaveBeenCalledTimes(1); + expect(opened).toHaveBeenCalledWith(descriptor); + + chat.close(); + chat.close(); + + expect(chat.isOpen()).toBe(false); + expect(closed).toHaveBeenCalledTimes(1); + expect(closed).toHaveBeenCalledWith(descriptor); +}); + +test('open is a no-op while no chat is registered', () => { + const opened = jest.fn(); + disposables.push(chat.onDidOpen(opened)); + + chat.open(); + + expect(chat.isOpen()).toBe(false); + expect(opened).not.toHaveBeenCalled(); + + // A registration arriving later therefore starts closed. + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel), + ); + expect(chat.isOpen()).toBe(false); +}); + +test('a takeover by a different id closes the displaced chat panel', () => { + const closed = jest.fn(); + disposables.push(chat.onDidClose(closed)); + + const first = { id: 'first.chat', name: 'First' }; + disposables.push(chat.registerChat(first, trigger, panel)); + chat.open(); + + disposables.push( + chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel), + ); + + // The incoming chat must not mount into an open state it never requested. + expect(chat.isOpen()).toBe(false); + expect(closed).toHaveBeenCalledTimes(1); + expect(closed).toHaveBeenCalledWith(first); +}); + +test('a same-id replacement keeps the open state', () => { + const closed = jest.fn(); + disposables.push(chat.onDidClose(closed)); + + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel), + ); + chat.open(); + + // Upgrade in place: same id, new providers. + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel), + ); + + expect(chat.isOpen()).toBe(true); + expect(closed).not.toHaveBeenCalled(); +}); + +test('disposing the active chat while open closes it; the fallback starts closed', () => { + const closed = jest.fn(); + disposables.push(chat.onDidClose(closed)); + + disposables.push( + chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel), + ); + const second = { id: 'second.chat', name: 'Second' }; + const registration = chat.registerChat(second, trigger, panel); + chat.open(); + + registration.dispose(); + + expect(chat.getChat()?.id).toBe('first.chat'); + expect(chat.isOpen()).toBe(false); + expect(closed).toHaveBeenCalledTimes(1); + expect(closed).toHaveBeenCalledWith(second); +}); + +test('disposing an inactive registration leaves the open state untouched', () => { + const closed = jest.fn(); + disposables.push(chat.onDidClose(closed)); + + const inactive = chat.registerChat( + { id: 'first.chat', name: 'First' }, + trigger, + panel, + ); + disposables.push( + chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel), + ); + chat.open(); + + inactive.dispose(); + + expect(chat.isOpen()).toBe(true); + expect(closed).not.toHaveBeenCalled(); +}); + +test('disposing the last chat while open resets the open state', () => { + const registration = chat.registerChat( + { id: 'acme.chat', name: 'Acme' }, + trigger, + panel, + ); + chat.open(); + expect(chat.isOpen()).toBe(true); + + registration.dispose(); + + expect(chat.isOpen()).toBe(false); + + // A registration arriving much later must not inherit a stale open state. + disposables.push( + chat.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel), + ); + expect(chat.isOpen()).toBe(false); +}); + +test('mode defaults to floating and setMode fires only on change', () => { + const modeChanged = jest.fn(); + disposables.push(chat.onDidChangeMode(modeChanged)); + + expect(chat.getMode()).toBe('floating'); + + // Setting the current mode is a no-op. + chat.setMode('floating'); + expect(modeChanged).not.toHaveBeenCalled(); + + chat.setMode('panel'); + expect(chat.getMode()).toBe('panel'); + expect(modeChanged).toHaveBeenCalledWith('panel'); +}); + +test('the snapshot is immutable per version and consistent with the registry', () => { + const before = getChatSnapshot(); + + disposables.push( + chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel), + ); + chat.open(); + + const after = getChatSnapshot(); + // Unchanged references for old snapshots; a new object per change. + expect(after).not.toBe(before); + expect(before.active).toBeUndefined(); + expect(after).toMatchObject({ + open: true, + mode: 'floating', + active: getActiveChat(), + }); + expect(after.version).toBeGreaterThan(before.version); + // Stable reference between changes. + expect(getChatSnapshot()).toBe(after); +}); diff --git a/superset-frontend/src/core/chat/index.ts b/superset-frontend/src/core/chat/index.ts new file mode 100644 index 000000000000..fea100e3ec1e --- /dev/null +++ b/superset-frontend/src/core/chat/index.ts @@ -0,0 +1,238 @@ +/** + * 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 implementation of the `chat` contribution type. + * + * Chat is a dedicated contribution type, not a view: extensions register via + * the public `chat.registerChat()` and the host owns mounting, open/close + * state, and the display mode. Multiple chat extensions may register, but the + * host applies singleton resolution — the most-recently-registered chat is + * active; disposing it falls back to the previous one. + * + * Open-state policy across active-chat transitions: when the active chat's + * identity changes — a takeover by a different id, disposal falling back to a + * different id, or disposal of the last chat — the panel is closed (firing + * `onDidClose`) so the incoming chat never mounts into an open state it did + * not request. A same-id re-registration is an upgrade in place and keeps the + * open state. + * + * The public namespace (`chat`) is exposed to extensions on + * `window.superset`; the other exports are host-internal accessors for + * ChatMount and are NOT part of the public `@apache-superset/core` API. + */ + +import { ReactElement } from 'react'; +import type { chat as chatApi } from '@apache-superset/core'; +import { Disposable } from '../models'; +import { createEmitter, createEventEmitter } from '../utils'; + +type Chat = chatApi.Chat; +type ChatMode = chatApi.ChatMode; + +/** A registered chat: its descriptor plus the host-mountable providers. */ +export interface RegisteredChat { + /** The chat descriptor passed to `registerChat`. */ + chat: Chat; + /** Renders the collapsed bubble. Hidden by the host in panel mode. */ + trigger: () => ReactElement; + /** Renders the chat panel, mounted per the current {@link ChatMode}. */ + panel: () => ReactElement; + /** + * Unique per registration (a same-id re-registration gets a new one). The + * host UI keys mounts and fault containment on it, so a replacement resets + * crashed error boundaries instead of inheriting their latched state. + */ + registrationId: number; +} + +/** + * Immutable snapshot of the whole chat state, rebuilt on every change. + * Returned by reference from `getChatSnapshot` so `useSyncExternalStore` + * consumers read registrations, open state, and mode from one consistent + * object instead of tearing across separate live reads. + */ +export interface ChatSnapshot { + /** Monotonic change counter, useful as a memo/effect dependency. */ + version: number; + /** Whether the active chat's panel is open. */ + open: boolean; + /** The current display mode. */ + mode: ChatMode; + /** The active registration, or undefined when none is registered. */ + active: RegisteredChat | undefined; +} + +/** Registration order is the singleton-resolution order: last entry wins. */ +const registrations: RegisteredChat[] = []; + +let panelOpen = false; +let nextRegistrationId = 1; + +const registerEmitter = createEventEmitter(); +const unregisterEmitter = createEventEmitter(); +const openEmitter = createEventEmitter(); +const closeEmitter = createEventEmitter(); +const resizePanelEmitter = createEventEmitter<{ width: number }>(); +const modeEmitter = createEmitter('floating'); + +/** + * Host-internal: resolves the active chat with its providers. + * The most-recently-registered chat wins; when it is disposed the previous + * registration takes over the slot again. + */ +export const getActiveChat = (): RegisteredChat | undefined => + registrations[registrations.length - 1]; + +let snapshot: ChatSnapshot = { + version: 0, + open: false, + mode: modeEmitter.getCurrent(), + active: undefined, +}; + +const stateSubscribers = new Set<() => void>(); + +const notifyState = () => { + snapshot = { + version: snapshot.version + 1, + open: panelOpen, + mode: modeEmitter.getCurrent(), + active: getActiveChat(), + }; + stateSubscribers.forEach(fn => fn()); +}; + +export const subscribeToChatState = (listener: () => void): (() => void) => { + stateSubscribers.add(listener); + return () => { + stateSubscribers.delete(listener); + }; +}; + +export const getChatSnapshot = (): ChatSnapshot => snapshot; + +/** Closes the panel and fires `onDidClose` with the chat that was closed. */ +const closePanel = (closedChat: Chat) => { + panelOpen = false; + closeEmitter.fire(closedChat); +}; + +const registerChat: typeof chatApi.registerChat = ( + chat: Chat, + trigger: () => ReactElement, + panel: () => ReactElement, +): Disposable => { + const previousActive = getActiveChat(); + + // Re-registering an id replaces the previous entry and moves it to the + // most-recent position, mirroring the view registry's same-id semantics. + const existingIndex = registrations.findIndex(r => r.chat.id === chat.id); + if (existingIndex !== -1) { + registrations.splice(existingIndex, 1); + } + + const entry: RegisteredChat = { + chat, + trigger, + panel, + registrationId: nextRegistrationId, + }; + nextRegistrationId += 1; + registrations.push(entry); + registerEmitter.fire(chat); + + // A takeover by a different id closes the displaced chat's panel so the + // incoming chat never mounts already-open; a same-id replacement is an + // upgrade in place and keeps the open state. + if (panelOpen && previousActive && previousActive.chat.id !== chat.id) { + closePanel(previousActive.chat); + } + notifyState(); + + return new Disposable(() => { + const index = registrations.indexOf(entry); + if (index === -1) { + // Already removed — replaced by a same-id registration or disposed twice. + return; + } + const wasActive = getActiveChat() === entry; + registrations.splice(index, 1); + unregisterEmitter.fire(chat); + // Disposing the active chat closes its panel; the fallback chat (if any) + // starts closed. Disposing an inactive registration leaves the open + // state of the active chat untouched. + if (panelOpen && wasActive) { + closePanel(chat); + } + notifyState(); + }); +}; + +const getChat: typeof chatApi.getChat = (): Chat | undefined => { + const active = getActiveChat(); + // Copy so extensions cannot mutate another extension's descriptor. + return active ? { ...active.chat } : undefined; +}; + +const open: typeof chatApi.open = (): void => { + const active = getActiveChat(); + // Open state only exists while a chat is registered; opening an empty slot + // would otherwise leak `open` into a future, unrelated registration. + if (panelOpen || !active) return; + panelOpen = true; + openEmitter.fire(active.chat); + notifyState(); +}; + +const close: typeof chatApi.close = (): void => { + const active = getActiveChat(); + if (!panelOpen || !active) return; + closePanel(active.chat); + notifyState(); +}; + +const isOpen: typeof chatApi.isOpen = (): boolean => panelOpen; + +const getMode: typeof chatApi.getMode = (): ChatMode => + modeEmitter.getCurrent(); + +const setMode: typeof chatApi.setMode = (mode: ChatMode): void => { + if (mode === modeEmitter.getCurrent()) return; + modeEmitter.fire(mode); + notifyState(); +}; + +export const chat: typeof chatApi = { + registerChat, + getChat, + onDidRegisterChat: registerEmitter.event, + onDidUnregisterChat: unregisterEmitter.event, + open, + close, + isOpen, + onDidOpen: openEmitter.event, + onDidClose: closeEmitter.event, + getMode, + setMode, + onDidChangeMode: modeEmitter.event, + // The host fires this from its panel resizer; until that chrome exists the + // event is exposed but never fires. + onDidResizePanel: resizePanelEmitter.event, +}; diff --git a/superset-frontend/src/core/chatbot/index.test.ts b/superset-frontend/src/core/chatbot/index.test.ts deleted file mode 100644 index 7b6eb00b0aca..000000000000 --- a/superset-frontend/src/core/chatbot/index.test.ts +++ /dev/null @@ -1,97 +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. - */ -import { createElement } 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 = () => createElement('div', null, 'Chatbot'); - disposables.push( - views.registerView( - { id: 'core.chatbot', name: 'Superset Chatbot' }, - CHATBOT_LOCATION, - provider, - ), - ); - - const active = getActiveChatbot(); - expect(active).toEqual({ id: 'core.chatbot', provider }); -}); - -test('getActiveChatbot picks the last-to-register when multiple are installed', () => { - const firstProvider = () => createElement('div', null, 'First'); - const secondProvider = () => 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, - ), - ); - - // Last-loaded wins: the most-recently-registered chatbot takes the slot. - const active = getActiveChatbot(); - expect(active?.id).toBe('second.chatbot'); - expect(active?.provider).toBe(secondProvider); -}); - -test('getActiveChatbot ignores views registered at other locations', () => { - const provider = () => 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 = () => createElement('div', null, 'Chatbot'); - const disposable = views.registerView( - { id: 'core.chatbot', name: 'Superset Chatbot' }, - CHATBOT_LOCATION, - provider, - ); - - expect(getActiveChatbot()?.id).toBe('core.chatbot'); - - disposable.dispose(); - - expect(getActiveChatbot()).toBeUndefined(); -}); diff --git a/superset-frontend/src/core/chatbot/index.ts b/superset-frontend/src/core/chatbot/index.ts deleted file mode 100644 index eded52c85a3f..000000000000 --- a/superset-frontend/src/core/chatbot/index.ts +++ /dev/null @@ -1,70 +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 Host-internal resolver for the exclusive `core.chatbot` - * contribution area. - * - * `core.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. - * - Otherwise the most-recently-registered (last-loaded) chatbot wins. When a - * second chatbot extension loads, it takes over the singleton bubble. - * - * @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; - } - - // `getRegisteredViewIds` returns ids in registration order, so the last entry - // is the most-recently-loaded chatbot. `getViewProvider` reads the same - // synchronous registry maps, so the id always has a live provider; the final - // guard is cheap defensiveness, not a fallback path. - const selectedId = registeredIds[registeredIds.length - 1]; - - const provider = getViewProvider(CHATBOT_LOCATION, selectedId); - return provider ? { id: selectedId, provider } : undefined; -}; diff --git a/superset-frontend/src/core/index.ts b/superset-frontend/src/core/index.ts index d259597457c3..dfe304eee54c 100644 --- a/superset-frontend/src/core/index.ts +++ b/superset-frontend/src/core/index.ts @@ -27,6 +27,7 @@ export const core: typeof coreType = { }; export * from './authentication'; +export * from './chat'; export * from './commands'; export * from './dashboard'; export * from './dataset'; diff --git a/superset-frontend/src/core/utils.ts b/superset-frontend/src/core/utils.ts index fe54011caece..c6c9474dfd7c 100644 --- a/superset-frontend/src/core/utils.ts +++ b/superset-frontend/src/core/utils.ts @@ -51,9 +51,20 @@ export interface Emitter { getCurrent: () => T; } -export function createEmitter(initial: T): Emitter { +/** + * A current-value-less event emitter for pure notifications (e.g. "a chat + * was registered", "the panel opened") where a "latest value" reading makes + * no sense. {@link createEmitter} layers value tracking on top of this. + */ +export interface EventEmitter { + /** Subscribe to events; conforms to the public `Event` shape. */ + event: EventSubscriber; + /** Notify all current subscribers with `value`. */ + fire: (value: T) => void; +} + +export function createEventEmitter(): EventEmitter { const listeners = new Set<(e: T) => void>(); - let current = initial; return { event: (listener, thisArgs) => { @@ -64,9 +75,21 @@ export function createEmitter(initial: T): Emitter { }); }, fire: value => { - current = value; listeners.forEach(fn => fn(value)); }, + }; +} + +export function createEmitter(initial: T): Emitter { + const { event, fire } = createEventEmitter(); + let current = initial; + + return { + event, + fire: value => { + current = value; + fire(value); + }, getCurrent: () => current, }; } diff --git a/superset-frontend/src/core/views/index.test.ts b/superset-frontend/src/core/views/index.test.ts index 1112beb64910..d98a05b7f4f4 100644 --- a/superset-frontend/src/core/views/index.test.ts +++ b/superset-frontend/src/core/views/index.test.ts @@ -17,12 +17,7 @@ * under the License. */ import React from 'react'; -import { - views, - resolveView, - getViewProvider, - getRegisteredViewIds, -} from './index'; +import { views, resolveView } from './index'; const disposables: Array<{ dispose: () => void }> = []; @@ -115,59 +110,3 @@ 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' }, - 'core.chatbot', - provider, - ), - ); - - expect(getViewProvider('core.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('core.chatbot', 'test.provider')).toBeUndefined(); -}); - -test('getViewProvider returns undefined for an unknown id', () => { - expect(getViewProvider('core.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' }, - 'core.chatbot', - provider, - ), - views.registerView( - { id: 'second.chatbot', name: 'Second' }, - 'core.chatbot', - provider, - ), - ); - - expect(getRegisteredViewIds('core.chatbot')).toEqual([ - 'first.chatbot', - 'second.chatbot', - ]); -}); - -test('getRegisteredViewIds returns an empty array for an unused location', () => { - expect(getRegisteredViewIds('core.chatbot')).toEqual([]); -}); diff --git a/superset-frontend/src/core/views/index.ts b/superset-frontend/src/core/views/index.ts index bc3bea3588ff..8f726432cce9 100644 --- a/superset-frontend/src/core/views/index.ts +++ b/superset-frontend/src/core/views/index.ts @@ -39,27 +39,6 @@ const viewRegistry: Map< const locationIndex: Map> = 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 notifyRegistry = () => { - registryVersion += 1; - registrySubscribers.forEach(fn => fn()); -}; - -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, @@ -78,13 +57,10 @@ const registerView: typeof viewsApi.registerView = ( ids.add(id); locationIndex.set(location, ids); - notifyRegistry(); - return new Disposable(() => { const registeredLocation = viewRegistry.get(id)?.location ?? location; viewRegistry.delete(id); locationIndex.get(registeredLocation)?.delete(id); - notifyRegistry(); }); }; @@ -107,28 +83,6 @@ 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, diff --git a/superset-frontend/src/extensions/ExtensionsStartup.test.tsx b/superset-frontend/src/extensions/ExtensionsStartup.test.tsx index bdc646423bfa..59ec6100bfe9 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.test.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.test.tsx @@ -97,6 +97,7 @@ test('sets up global superset object when user is logged in', async () => { // Verify the global superset object is set up expect((window as any).superset).toBeDefined(); expect((window as any).superset.authentication).toBeDefined(); + expect((window as any).superset.chat).toBeDefined(); expect((window as any).superset.core).toBeDefined(); expect((window as any).superset.commands).toBeDefined(); expect((window as any).superset.extensions).toBeDefined(); diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx index 87abcd4192e7..703f80f92350 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -22,6 +22,7 @@ import { logging } from '@apache-superset/core/utils'; import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import { authentication, + chat, core, commands, dashboard, @@ -90,6 +91,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ // as that would leak un-contracted symbols onto window.superset. window.superset = { authentication, + chat, core, commands, dashboard, @@ -104,8 +106,8 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ }; // Render the host immediately; extension bundles load in the background. - // ChatbotMount re-resolves reactively once the chatbot extension registers - // (via subscribeToRegistry / getRegistryVersion), so the bubble appears + // ChatMount re-resolves reactively once a chat extension registers (via + // subscribeToChatState / getChatSnapshot), so the bubble appears // without blocking the UI. setInitialized(true); diff --git a/superset-frontend/src/extensions/supersetGlobal.ts b/superset-frontend/src/extensions/supersetGlobal.ts index 70e5786e9999..cd2993491a13 100644 --- a/superset-frontend/src/extensions/supersetGlobal.ts +++ b/superset-frontend/src/extensions/supersetGlobal.ts @@ -28,6 +28,7 @@ import type { authentication, + chat, commands, core, dashboard, @@ -45,6 +46,7 @@ import type { export interface SupersetGlobal { authentication: typeof authentication; core: typeof core; + chat: typeof chat; commands: typeof commands; dashboard: typeof dashboard; dataset: typeof dataset; diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 0ea9014f5759..6a5c08672ee5 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -40,7 +40,7 @@ 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 ChatMount from 'src/components/ChatMount'; import { RootContextProviders } from './RootContextProviders'; import { ScrollToTop } from './ScrollToTop'; @@ -115,12 +115,12 @@ const App = () => ( ))} {/* - 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 + The singleton chat slot. Rendered as a sibling of the route + Switch — inside ExtensionsStartup so chat extensions have been + loaded and registered, but outside the Switch so the chat persists across route changes. */} - {isFeatureEnabled(FeatureFlag.EnableExtensions) && } + {isFeatureEnabled(FeatureFlag.EnableExtensions) && }
diff --git a/superset-frontend/src/views/contributions.ts b/superset-frontend/src/views/contributions.ts deleted file mode 100644 index 12770b699bc7..000000000000 --- a/superset-frontend/src/views/contributions.ts +++ /dev/null @@ -1,31 +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. - */ -/** - * 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: 'core.chatbot', - }, -} as const; - -export const CHATBOT_LOCATION = AppViewLocations.app.chatbot; From a1eba0f9a18adf9f4e868fb1855127565d959df0 Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" <70410625+michael-s-molina@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:58:21 -0300 Subject: [PATCH 06/10] chore: Cleanup changes in chat feature branch (#41008) --- .../src/superset_core/extensions/types.py | 4 + .../src/superset_extensions_cli/cli.py | 1 + .../tests/test_cli_build.py | 3 + .../superset-core/src/common/index.ts | 2 + .../src/components/Select/Select.tsx | 6 +- .../tests/dashboard/clear-all-filters.spec.ts | 5 +- superset-frontend/src/core/sqlLab/index.ts | 22 +----- .../src/core/sqlLab/sqlLab.test.ts | 39 ---------- superset-frontend/src/core/utils.ts | 73 ------------------- .../features/roles/RoleListEditModal.test.tsx | 16 ++-- .../src/middleware/loggerMiddleware.ts | 7 +- 11 files changed, 29 insertions(+), 149 deletions(-) diff --git a/superset-core/src/superset_core/extensions/types.py b/superset-core/src/superset_core/extensions/types.py index 6891c362d7fe..bfaeba5a43c5 100644 --- a/superset-core/src/superset_core/extensions/types.py +++ b/superset-core/src/superset_core/extensions/types.py @@ -69,6 +69,10 @@ class BaseExtension(BaseModel): default=None, description="Extension description", ) + dependencies: list[str] = Field( + default_factory=list, + description="List of extension IDs this extension depends on", + ) permissions: list[str] = Field( default_factory=list, description="Permissions required by this extension", diff --git a/superset-extensions-cli/src/superset_extensions_cli/cli.py b/superset-extensions-cli/src/superset_extensions_cli/cli.py index 2e4f2f42ef40..c2adb5ea1e0d 100644 --- a/superset-extensions-cli/src/superset_extensions_cli/cli.py +++ b/superset-extensions-cli/src/superset_extensions_cli/cli.py @@ -179,6 +179,7 @@ def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest: displayName=extension.displayName, version=extension.version, permissions=extension.permissions, + dependencies=extension.dependencies, frontend=frontend, backend=backend, ) diff --git a/superset-extensions-cli/tests/test_cli_build.py b/superset-extensions-cli/tests/test_cli_build.py index 557938780fd0..6e2a5def2734 100644 --- a/superset-extensions-cli/tests/test_cli_build.py +++ b/superset-extensions-cli/tests/test_cli_build.py @@ -283,6 +283,7 @@ def test_build_manifest_creates_correct_manifest_structure( "displayName": "Test Extension", "version": "1.0.0", "permissions": ["read_data"], + "dependencies": ["some_dep"], } extension_json = isolated_filesystem / "extension.json" extension_json.write_text(json.dumps(extension_data)) @@ -296,6 +297,7 @@ def test_build_manifest_creates_correct_manifest_structure( assert manifest.displayName == "Test Extension" assert manifest.version == "1.0.0" assert manifest.permissions == ["read_data"] + assert manifest.dependencies == ["some_dep"] # Verify frontend section assert manifest.frontend is not None @@ -328,6 +330,7 @@ def test_build_manifest_handles_minimal_extension(isolated_filesystem): assert manifest.displayName == "Minimal Extension" assert manifest.version == "0.1.0" assert manifest.permissions == [] + assert manifest.dependencies == [] # Default empty list assert manifest.frontend is None assert manifest.backend is None diff --git a/superset-frontend/packages/superset-core/src/common/index.ts b/superset-frontend/packages/superset-core/src/common/index.ts index 38cf4664d669..8b513f348ac8 100644 --- a/superset-frontend/packages/superset-core/src/common/index.ts +++ b/superset-frontend/packages/superset-core/src/common/index.ts @@ -260,6 +260,8 @@ export interface ExtensionModule { * by registering commands, views, menus, and editors as module-level side effects. */ export interface Extension { + /** List of other extensions that this extension depends on */ + dependencies: string[]; /** Human-readable description of the extension */ description: string; /** Unique identifier for the extension */ diff --git a/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx b/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx index 971bcd45cd86..bab0e9a73949 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Select/Select.tsx @@ -519,8 +519,7 @@ const Select = forwardRef( handleSelectAll(); }} > - {t('Select all')}{' '} - {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`} + {t('Select all')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`} ), diff --git a/superset-frontend/playwright/tests/dashboard/clear-all-filters.spec.ts b/superset-frontend/playwright/tests/dashboard/clear-all-filters.spec.ts index 6bb30c3094b4..4cd0c56f4b30 100644 --- a/superset-frontend/playwright/tests/dashboard/clear-all-filters.spec.ts +++ b/superset-frontend/playwright/tests/dashboard/clear-all-filters.spec.ts @@ -182,7 +182,10 @@ testWithAssets( // Now track POST /api/v1/chart/data requests around Clear All const postsAfterClearAll: string[] = []; const handler = (req: any) => { - if (req.url().includes('/api/v1/chart/data') && req.method() === 'POST') { + if ( + req.url().includes('/api/v1/chart/data') && + req.method() === 'POST' + ) { postsAfterClearAll.push(req.url()); } }; diff --git a/superset-frontend/src/core/sqlLab/index.ts b/superset-frontend/src/core/sqlLab/index.ts index 17174f5a005b..b14be7efd07b 100644 --- a/superset-frontend/src/core/sqlLab/index.ts +++ b/superset-frontend/src/core/sqlLab/index.ts @@ -56,7 +56,6 @@ import { QueryResultContext, QueryErrorResultContext, } from './models'; -import { navigation } from '../navigation'; const { CTASMethod } = sqlLabApi; @@ -302,15 +301,8 @@ function createQueryErrorContext( ); } -const getCurrentTab: typeof sqlLabApi.getCurrentTab = () => { - // Guard on the page type so the tab does not leak onto non-editor surfaces. - // The SQL Lab Redux slice persists after navigating away, so without this - // guard `getCurrentTab()` would keep returning the last editor's tab on, e.g., - // a dashboard or list page. Mirrors the page-type guards on - // `explore.getCurrentChart()` / `dashboard.getCurrentDashboard()`. - if (navigation.getPageType() !== 'sqllab') return undefined; - return getTab(activeEditorId()); -}; +const getCurrentTab: typeof sqlLabApi.getCurrentTab = () => + getTab(activeEditorId()); const getActivePanel: typeof sqlLabApi.getActivePanel = () => { const { activeSouthPaneTab } = getSqlLabState(); @@ -460,14 +452,8 @@ const onDidChangeActiveTab: typeof sqlLabApi.onDidChangeActiveTab = ( createActionListener( globalPredicate(SET_ACTIVE_QUERY_EDITOR), listener, - // Resolve the now-active tab the same way `getCurrentTab()` does (via the - // active-editor / tabHistory state) rather than from the raw action payload. - // The action's `queryEditor` carries the base editor without `unsavedQueryEditor` - // merged, so its `dbId` can still be undefined at this point, which made - // `getTab(action.queryEditor.id)` return undefined and silently swallow the - // event. Reading the resolved active tab keeps this event consistent with the - // getter and fires on every tab switch. - () => getCurrentTab() ?? null, + (action: { type: string; queryEditor: { id: string } }) => + getTab(action.queryEditor.id), thisArgs, ); diff --git a/superset-frontend/src/core/sqlLab/sqlLab.test.ts b/superset-frontend/src/core/sqlLab/sqlLab.test.ts index 2b696353e8f0..5b7356c939b4 100644 --- a/superset-frontend/src/core/sqlLab/sqlLab.test.ts +++ b/superset-frontend/src/core/sqlLab/sqlLab.test.ts @@ -119,13 +119,6 @@ jest.mock('src/views/store', () => ({ setupStore: jest.fn(), })); -// The sqlLab namespace guards `getCurrentTab()` on the page type. These tests -// exercise the editor surface, so report 'sqllab'. Per-test overrides (e.g. to -// assert the off-surface guard) can change the return value. -jest.mock('../navigation', () => ({ - navigation: { getPageType: jest.fn(() => 'sqllab') }, -})); - // Module under test — imported after mocks // eslint-disable-next-line import/first import { sqlLab } from '.'; @@ -395,31 +388,6 @@ test('onDidChangeActiveTab fires with Tab on SET_ACTIVE_QUERY_EDITOR', () => { disposable.dispose(); }); -test('onDidChangeActiveTab carries the newly-activated tab when switching away', () => { - // Switching from the first editor to a second one must report the second tab, - // not the first. Regression guard: resolving the tab from the live active - // editor (via getCurrentTab) instead of the raw action payload. - mockStore.dispatch({ - type: ADD_QUERY_EDITOR, - queryEditor: makeSecondEditor(), - }); - - const listener = jest.fn(); - const disposable = sqlLab.onDidChangeActiveTab(listener); - - mockStore.dispatch({ - type: SET_ACTIVE_QUERY_EDITOR, - queryEditor: { id: 'editor-2' }, - }); - - expect(listener).toHaveBeenCalledTimes(1); - const tab = listener.mock.calls[0][0]; - expect(tab.id).toBe('editor-2'); - expect(tab.databaseId).toBe(2); - - disposable.dispose(); -}); - test('onDidCreateTab fires with Tab on ADD_QUERY_EDITOR', () => { const listener = jest.fn(); const disposable = sqlLab.onDidCreateTab(listener); @@ -567,13 +535,6 @@ test('getCurrentTab returns the active tab with correct properties', () => { expect(tab!.schema).toBe('public'); }); -test('getCurrentTab returns undefined when not on the SQL Lab editor surface', () => { - const { navigation } = jest.requireMock('../navigation'); - (navigation.getPageType as jest.Mock).mockReturnValueOnce('dashboard'); - - expect(sqlLab.getCurrentTab()).toBeUndefined(); -}); - test('getActivePanel returns the active south pane tab', () => { const panel = sqlLab.getActivePanel(); expect(panel.id).toBe('Results'); diff --git a/superset-frontend/src/core/utils.ts b/superset-frontend/src/core/utils.ts index c6c9474dfd7c..1e4dded93c35 100644 --- a/superset-frontend/src/core/utils.ts +++ b/superset-frontend/src/core/utils.ts @@ -20,79 +20,6 @@ import type { common as core } from '@apache-superset/core'; import { AnyAction } from 'redux'; import { listenerMiddleware, RootState, store } from 'src/views/store'; import { AnyListenerPredicate } from '@reduxjs/toolkit'; -import { Disposable } from './models'; - -/** - * A typed event subscription matching the public `Event` contract. - * Calling it with a listener (and optional `this` arg) subscribes and returns - * a {@link Disposable} that unsubscribes. - */ -export type EventSubscriber = ( - listener: (e: T) => void, - thisArgs?: unknown, -) => Disposable; - -/** - * A minimal host-internal event emitter shared by the producer-backed - * namespaces (dataset, navigation, settings, view registry). Each of those - * needs the same "publish a value and fan it out to subscribers" primitive; - * this collapses the duplicated Set + bind + Disposable boilerplate into one - * place. - * - * `event` is exposed to extensions as the namespace's `onDidChange*`; `fire` - * and `getCurrent` stay host-internal. - */ -export interface Emitter { - /** Subscribe to changes; conforms to the public `Event` shape. */ - event: EventSubscriber; - /** Notify all current subscribers with `value`. */ - fire: (value: T) => void; - /** The most recently fired value (or the initial value). */ - getCurrent: () => T; -} - -/** - * A current-value-less event emitter for pure notifications (e.g. "a chat - * was registered", "the panel opened") where a "latest value" reading makes - * no sense. {@link createEmitter} layers value tracking on top of this. - */ -export interface EventEmitter { - /** Subscribe to events; conforms to the public `Event` shape. */ - event: EventSubscriber; - /** Notify all current subscribers with `value`. */ - fire: (value: T) => void; -} - -export function createEventEmitter(): EventEmitter { - const listeners = new Set<(e: T) => void>(); - - return { - event: (listener, thisArgs) => { - const bound = thisArgs ? listener.bind(thisArgs) : listener; - listeners.add(bound); - return new Disposable(() => { - listeners.delete(bound); - }); - }, - fire: value => { - listeners.forEach(fn => fn(value)); - }, - }; -} - -export function createEmitter(initial: T): Emitter { - const { event, fire } = createEventEmitter(); - let current = initial; - - return { - event, - fire: value => { - current = value; - fire(value); - }, - getCurrent: () => current, - }; -} export function createActionListener( predicate: AnyListenerPredicate, diff --git a/superset-frontend/src/features/roles/RoleListEditModal.test.tsx b/superset-frontend/src/features/roles/RoleListEditModal.test.tsx index 9380d4c04a6e..9072f991f213 100644 --- a/superset-frontend/src/features/roles/RoleListEditModal.test.tsx +++ b/superset-frontend/src/features/roles/RoleListEditModal.test.tsx @@ -252,7 +252,9 @@ describe('RoleListEditModal', () => { const mockGet = SupersetClient.get as jest.Mock; mockGet.mockImplementation(({ endpoint }) => { if ( - endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`) + endpoint?.includes( + `/api/v1/security/roles/${mockRole.id}/permissions/`, + ) ) { // Only return permission id=10, not id=20 return Promise.resolve({ @@ -296,7 +298,9 @@ describe('RoleListEditModal', () => { const mockGet = SupersetClient.get as jest.Mock; mockGet.mockImplementation(({ endpoint }) => { if ( - endpoint?.includes(`/api/v1/security/roles/${mockRole.id}/permissions/`) + endpoint?.includes( + `/api/v1/security/roles/${mockRole.id}/permissions/`, + ) ) { return Promise.reject(new Error('network error')); } @@ -367,9 +371,7 @@ describe('RoleListEditModal', () => { }; mockGet.mockImplementation(({ endpoint }) => { - if ( - endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`) - ) { + if (endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)) { return Promise.resolve({ json: { result: roleA.permission_ids.map(pid => ({ @@ -380,9 +382,7 @@ describe('RoleListEditModal', () => { }, }); } - if ( - endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`) - ) { + if (endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)) { return Promise.resolve({ json: { result: roleB.permission_ids.map(pid => ({ diff --git a/superset-frontend/src/middleware/loggerMiddleware.ts b/superset-frontend/src/middleware/loggerMiddleware.ts index 3182bd18771f..a41d96a53e62 100644 --- a/superset-frontend/src/middleware/loggerMiddleware.ts +++ b/superset-frontend/src/middleware/loggerMiddleware.ts @@ -33,12 +33,7 @@ import { ensureAppRoot } from '../utils/pathUtils'; import type { DashboardInfo, DashboardLayoutState } from '../dashboard/types'; import type { QueryEditor } from '../SqlLab/types'; -type LogEventSource = - | 'dashboard' - | 'embedded_dashboard' - | 'explore' - | 'sqlLab' - | 'slice'; +type LogEventSource = 'dashboard' | 'embedded_dashboard' | 'explore' | 'sqlLab' | 'slice'; interface LogEventData { source?: LogEventSource; From 715c07b5c7ba6459adb71e66c9cd4d4b8b95373f Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Mon, 15 Jun 2026 15:27:24 +0200 Subject: [PATCH 07/10] chore(extensions): remove out-of-scope scaffolding from chat SIP branch --- .../packages/superset-core/package.json | 12 - .../superset-core/src/common/index.ts | 2 - .../superset-core/src/dashboard/index.ts | 114 --------- .../superset-core/src/dataset/index.ts | 73 ------ .../superset-core/src/explore/index.ts | 75 ------ .../packages/superset-core/src/index.ts | 3 - .../superset-core/src/navigation/index.ts | 24 +- .../packages/superset-core/src/views/index.ts | 8 +- .../src/core/dashboard/index.test.ts | 220 ------------------ superset-frontend/src/core/dashboard/index.ts | 123 ---------- superset-frontend/src/core/dataset/index.ts | 63 ----- .../src/core/explore/index.test.ts | 157 ------------- superset-frontend/src/core/explore/index.ts | 92 -------- superset-frontend/src/core/index.ts | 3 - .../src/extensions/ExtensionsStartup.tsx | 6 - .../src/extensions/supersetGlobal.ts | 6 - .../EditDataset/EditDataset.test.tsx | 6 - .../datasets/AddDataset/EditDataset/index.tsx | 49 ---- ...-00_b2c3d4e5f6a7_add_extension_settings.py | 47 ---- ...-00_d1e2f3a4b5c6_drop_extension_enabled.py | 43 ---- ...00_e2f3a4b5c6d7_drop_extension_settings.py | 46 ---- 21 files changed, 12 insertions(+), 1160 deletions(-) delete mode 100644 superset-frontend/packages/superset-core/src/dashboard/index.ts delete mode 100644 superset-frontend/packages/superset-core/src/dataset/index.ts delete mode 100644 superset-frontend/packages/superset-core/src/explore/index.ts delete mode 100644 superset-frontend/src/core/dashboard/index.test.ts delete mode 100644 superset-frontend/src/core/dashboard/index.ts delete mode 100644 superset-frontend/src/core/dataset/index.ts delete mode 100644 superset-frontend/src/core/explore/index.test.ts delete mode 100644 superset-frontend/src/core/explore/index.ts delete mode 100644 superset/migrations/versions/2026-05-25_00-00_b2c3d4e5f6a7_add_extension_settings.py delete mode 100644 superset/migrations/versions/2026-06-09_00-00_d1e2f3a4b5c6_drop_extension_enabled.py delete mode 100644 superset/migrations/versions/2026-06-10_00-00_e2f3a4b5c6d7_drop_extension_settings.py diff --git a/superset-frontend/packages/superset-core/package.json b/superset-frontend/packages/superset-core/package.json index 2866673fe5b6..887a4acd750b 100644 --- a/superset-frontend/packages/superset-core/package.json +++ b/superset-frontend/packages/superset-core/package.json @@ -22,18 +22,6 @@ "types": "./lib/chat/index.d.ts", "default": "./lib/chat/index.js" }, - "./dashboard": { - "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" - }, "./navigation": { "types": "./lib/navigation/index.d.ts", "default": "./lib/navigation/index.js" diff --git a/superset-frontend/packages/superset-core/src/common/index.ts b/superset-frontend/packages/superset-core/src/common/index.ts index 8b513f348ac8..38cf4664d669 100644 --- a/superset-frontend/packages/superset-core/src/common/index.ts +++ b/superset-frontend/packages/superset-core/src/common/index.ts @@ -260,8 +260,6 @@ export interface ExtensionModule { * by registering commands, views, menus, and editors as module-level side effects. */ export interface Extension { - /** List of other extensions that this extension depends on */ - dependencies: string[]; /** Human-readable description of the extension */ description: string; /** Unique identifier for the extension */ diff --git a/superset-frontend/packages/superset-core/src/dashboard/index.ts b/superset-frontend/packages/superset-core/src/dashboard/index.ts deleted file mode 100644 index 10f94f5ab8c2..000000000000 --- a/superset-frontend/packages/superset-core/src/dashboard/index.ts +++ /dev/null @@ -1,114 +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 Dashboard namespace for Superset extensions (P3). - * - * Exposes dashboard identity and filter state as a stable semantic API. - * Extensions must not depend on the Redux dashboard slice structure directly. - */ - -import { Event } from '../common'; - -/** - * A single native filter's current selected value(s). - * The value type is intentionally kept as `unknown` because filter values - * are heterogeneous (date ranges, string lists, numbers, etc.). - */ -export interface FilterValue { - /** The filter's stable id. */ - filterId: string; - /** Display label of the filter. */ - label: string; - /** Currently applied value, or `null` when the filter is cleared. */ - value: unknown; -} - -/** - * Summary of a single chart on the active dashboard. - * - * Exposes the identity, viz type, datasource, and current visibility of a - * chart so extensions can answer both "which charts are visible?" and - * "find the chart named X" without additional lookups. - */ -export interface ChartSummary { - /** Numeric chart (slice) id. */ - chartId: number; - /** Display name of the chart. */ - chartName: string; - /** Visualization type key (e.g. `'echarts_timeseries_bar'`). */ - vizType: string; - /** Datasource id, or `null` when not resolvable. */ - datasourceId: number | null; - /** Datasource name, or `null` when not resolvable. */ - datasourceName: string | null; - /** Whether the chart is currently visible (e.g. on the active tab). */ - isVisible: boolean; -} - -/** - * Normalized dashboard context exposed to extensions on the Dashboard page. - */ -export interface DashboardContext { - /** Numeric dashboard id. */ - dashboardId: number; - /** Display title of the dashboard. */ - title: string; - /** - * Active native filter values keyed by filter id. - * Only includes filters that have a value applied. - */ - filters: FilterValue[]; - /** - * Summaries of the dashboard's charts, including per-chart visibility. - * - * Optional: the contract is declared so extensions can compile against the - * stable shape, but population is delivered in a later phase (see - * CHATBOT_SIP.md §10/§11). The host returns an empty array until then. - */ - charts?: ChartSummary[]; -} - -/** - * Returns the normalized dashboard context for the page currently being viewed, - * or `undefined` when the user is not on a Dashboard page. - * - * @example - * ```typescript - * const dash = dashboard.getCurrentDashboard(); - * if (dash) { - * console.log(dash.title, dash.filters); - * } - * ``` - */ -export declare function getCurrentDashboard(): DashboardContext | undefined; - -/** - * Event fired when the dashboard identity or its active filter values change. - * Fired on native filter value changes and on navigation to a different dashboard. - * - * @example - * ```typescript - * const sub = dashboard.onDidChangeDashboard(dash => { - * chatbot.updateContext({ dashboard: dash }); - * }); - * sub.dispose(); - * ``` - */ -export declare const onDidChangeDashboard: Event; 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 ea3fafa4fdba..000000000000 --- 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; diff --git a/superset-frontend/packages/superset-core/src/explore/index.ts b/superset-frontend/packages/superset-core/src/explore/index.ts deleted file mode 100644 index 162d1b2e6f77..000000000000 --- a/superset-frontend/packages/superset-core/src/explore/index.ts +++ /dev/null @@ -1,75 +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 Explore namespace for Superset extensions (P3). - * - * Exposes the current chart/explore context as a stable semantic API. - * Normalized over Explore Redux state — extensions must not depend on - * the Redux slice structure directly. - */ - -import { Event } from '../common'; - -/** - * Normalized chart context exposed to extensions during an Explore session. - * Covers saved chart identity and transient editing context; excludes raw - * form-data internals and datasource-implementation details. - */ -export interface ChartContext { - /** The saved chart id, or `null` when the chart has not been persisted. */ - chartId: number | null; - /** Display name of the saved chart, or `null` for a new/unsaved chart. */ - chartName: string | null; - /** The visualization type currently selected in the editor. */ - vizType: string; - /** Id of the datasource backing the chart (physical or virtual dataset). */ - datasourceId: number | null; - /** Human-readable datasource name. */ - datasourceName: string | null; -} - -/** - * Returns the normalized chart context for the active Explore session, or - * `undefined` when the user is not on the Explore page. - * - * @example - * ```typescript - * const chart = explore.getCurrentChart(); - * if (chart) { - * console.log(chart.vizType, chart.chartName); - * } - * ``` - */ -export declare function getCurrentChart(): ChartContext | undefined; - -/** - * Event fired when the chart context changes within the active Explore session - * (e.g. when the viz type, datasource, or saved name changes). - * Not fired during route changes — subscribe to `navigation.onDidChangePage` for those. - * - * @example - * ```typescript - * const sub = explore.onDidChangeChart(chart => { - * chatbot.updateContext({ chart }); - * }); - * sub.dispose(); - * ``` - */ -export declare const onDidChangeChart: Event; diff --git a/superset-frontend/packages/superset-core/src/index.ts b/superset-frontend/packages/superset-core/src/index.ts index 417a8af2e828..be41ac88a196 100644 --- a/superset-frontend/packages/superset-core/src/index.ts +++ b/superset-frontend/packages/superset-core/src/index.ts @@ -20,10 +20,7 @@ export * as common from './common'; export * as authentication from './authentication'; export * as chat from './chat'; 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'; export * as menus from './menus'; export * as navigation from './navigation'; diff --git a/superset-frontend/packages/superset-core/src/navigation/index.ts b/superset-frontend/packages/superset-core/src/navigation/index.ts index 0c8863a8b3d8..6e46d62bc9ca 100644 --- a/superset-frontend/packages/superset-core/src/navigation/index.ts +++ b/superset-frontend/packages/superset-core/src/navigation/index.ts @@ -18,12 +18,12 @@ */ /** - * @fileoverview Navigation namespace for Superset extensions (P3). + * @fileoverview Navigation namespace for Superset extensions. * * Exposes the current application surface so extensions can react to route * changes without polling. Entity-level context (chart, dashboard, dataset) - * is intentionally not included here — use the surface-specific namespace - * (`explore`, `dashboard`, `dataset`) to retrieve entity payloads. + * is intentionally not included here — surface-specific namespaces that + * resolve entity payloads are introduced in later phases. */ import { Event } from '../common'; @@ -32,13 +32,12 @@ import { Event } from '../common'; * The set of top-level application surfaces. * * `'explore'`, `'dashboard'` and `'dataset'` are the single-entity - * editing/viewing surfaces where `explore.getCurrentChart()` / - * `dashboard.getCurrentDashboard()` / `dataset.getCurrentDataset()` resolve to a - * concrete entity. `'chart_list'`, `'dashboard_list'` and `'dataset_list'` are - * the browse/list surfaces, distinct from those because no single entity is - * active. `'sqllab'` is the SQL editor where `sqlLab.getCurrentTab()` resolves; - * `'query_history'` and `'saved_queries'` are the related SQL Lab browse pages, - * which are not the editor. `'other'` covers any route not explicitly enumerated. + * editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and + * `'dataset_list'` are the browse/list surfaces, distinct from those because no + * single entity is active. `'sqllab'` is the SQL editor where + * `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'` + * are the related SQL Lab browse pages, which are not the editor. `'other'` + * covers any route not explicitly enumerated. */ export type PageType = | 'dashboard' @@ -60,7 +59,7 @@ export type PageType = * ```typescript * const pageType = navigation.getPageType(); * if (pageType === 'dashboard') { - * const ctx = dashboard.getCurrentDashboard(); + * // react to being on a dashboard surface * } * ``` */ @@ -68,13 +67,12 @@ export declare function getPageType(): PageType; /** * Event fired whenever the user navigates to a different surface. - * Use the surface-specific namespace to read entity context after the event. * * @example * ```typescript * const sub = navigation.onDidChangePage(pageType => { * if (pageType === 'dashboard') { - * const ctx = dashboard.getCurrentDashboard(); + * // react to navigating onto a dashboard surface * } * }); * // later: diff --git a/superset-frontend/packages/superset-core/src/views/index.ts b/superset-frontend/packages/superset-core/src/views/index.ts index 32c27a179124..c8ac971b2933 100644 --- a/superset-frontend/packages/superset-core/src/views/index.ts +++ b/superset-frontend/packages/superset-core/src/views/index.ts @@ -48,12 +48,6 @@ export interface View { name: string; /** Optional description of the view, for display in contribution manifests. */ description?: string; - /** - * Optional icon identifier for the view, used in admin pickers and manifest - * listings. Static — set once at registerView() time. - * Dynamic icon states (e.g. notification badge) are the extension's concern. - */ - icon?: string; } /** @@ -62,7 +56,7 @@ export interface View { * The view provider function is called when the UI renders the location, * and should return a React element to display. * - * @param view The view descriptor (id, name, and optional icon/description). + * @param view The view descriptor (id, name, and optional description). * @param location The location where this view should appear (e.g. "sqllab.panels"). * @param provider A function that returns the React element to render. * @returns A Disposable that unregisters the view when disposed. diff --git a/superset-frontend/src/core/dashboard/index.test.ts b/superset-frontend/src/core/dashboard/index.test.ts deleted file mode 100644 index e8b7d6acbc6b..000000000000 --- a/superset-frontend/src/core/dashboard/index.test.ts +++ /dev/null @@ -1,220 +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. - */ - -// --------------------------------------------------------------------------- -// 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; - -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; - sliceEntities: unknown; - dashboardLayout: unknown; - }> = {}, -) { - return { - dashboardInfo: { id: 1, dashboard_title: 'Sales', slug: 'sales' }, - nativeFilters: { filters: { 'filter-1': { name: 'Region' } } }, - dataMask: { 'filter-1': { filterState: { value: ['West'] } } }, - sliceEntities: { slices: {} }, - dashboardLayout: { present: {} }, - ...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'] }], - // No charts on the (empty) layout fixture. - charts: [], - }); -}); - -test('getCurrentDashboard reports charts placed on the dashboard layout', () => { - mockState = makeState({ - sliceEntities: { - slices: { - 42: { - slice_name: 'Revenue by Region', - viz_type: 'echarts_timeseries_bar', - datasource_id: 7, - datasource_name: 'cleaned_sales', - }, - }, - }, - dashboardLayout: { - present: { - 'CHART-abc': { id: 'CHART-abc', type: 'CHART', meta: { chartId: 42 } }, - // A chart id with no matching slice entity still appears, with blanks. - 'CHART-def': { id: 'CHART-def', type: 'CHART', meta: { chartId: 99 } }, - // Non-chart components are ignored. - 'TAB-xyz': { id: 'TAB-xyz', type: 'TAB', meta: {} }, - }, - }, - }); - - expect(dashboard.getCurrentDashboard()?.charts).toEqual([ - { - chartId: 42, - chartName: 'Revenue by Region', - vizType: 'echarts_timeseries_bar', - datasourceId: 7, - datasourceName: 'cleaned_sales', - isVisible: true, - }, - { - chartId: 99, - chartName: '', - vizType: '', - datasourceId: null, - datasourceName: null, - isVisible: true, - }, - ]); -}); - -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 deleted file mode 100644 index 89afa1050c13..000000000000 --- a/superset-frontend/src/core/dashboard/index.ts +++ /dev/null @@ -1,123 +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 `dashboard` namespace. - * - * Wraps Redux dashboardInfo and dataMask state and normalizes them into the - * stable `DashboardContext` contract. Extensions must not depend on the Redux - * slice structure directly. - */ - -import type { dashboard as dashboardApi } from '@apache-superset/core'; -import type { DataMaskStateWithId } from '@superset-ui/core'; -import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate'; -import { - UPDATE_DATA_MASK, - SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE, -} from 'src/dataMask/actions'; -import { store, RootState } from 'src/views/store'; -import { AnyListenerPredicate } from '@reduxjs/toolkit'; -import getChartIdsFromLayout from 'src/dashboard/util/getChartIdsFromLayout'; -import { createActionListener } from '../utils'; -import { navigation } from '../navigation'; - -type DashboardContext = dashboardApi.DashboardContext; -type FilterValue = dashboardApi.FilterValue; -type ChartSummary = NonNullable[number]; - -function buildChartSummaries(state: RootState): ChartSummary[] { - const slices = state.sliceEntities?.slices ?? {}; - const layout = state.dashboardLayout?.present ?? {}; - - // Only charts actually placed on the dashboard layout — `slices` can also - // hold entities that are not on the current dashboard. - return getChartIdsFromLayout(layout).map(chartId => { - const slice = slices[chartId]; - return { - chartId, - chartName: slice?.slice_name ?? '', - vizType: slice?.viz_type ?? '', - datasourceId: slice?.datasource_id ?? null, - datasourceName: slice?.datasource_name ?? null, - // Tab-accurate visibility is a deferred phase; every chart on the - // dashboard is reported visible for now. - isVisible: true, - }; - }); -} - -function buildDashboardContext(): DashboardContext | undefined { - if (navigation.getPageType() !== 'dashboard') return undefined; - // `store.getState()` is already typed as RootState, so the slices below are - // read with their real types — the host owns this normalization and must - // stay type-safe against slice reshapes. - const state = store.getState(); - const info = state.dashboardInfo; - if (!info?.id) return undefined; - - const nativeFilters = state.nativeFilters?.filters ?? {}; - const dataMask: DataMaskStateWithId = state.dataMask ?? {}; - - const filters: FilterValue[] = Object.entries(dataMask) - .filter(([id, mask]) => { - if (!(id in nativeFilters)) return false; - const value = mask?.filterState?.value; - return value !== null && value !== undefined; - }) - .map(([id, mask]) => { - const raw = mask.filterState?.value; - return { - filterId: id, - label: nativeFilters[id]?.name ?? id, - value: Array.isArray(raw) ? [...raw] : raw, - }; - }); - - return { - dashboardId: info.id, - title: info.dashboard_title ?? info.slug ?? String(info.id), - filters, - charts: buildChartSummaries(state), - }; -} - -const dashboardChangePredicate: AnyListenerPredicate = action => - action.type === HYDRATE_DASHBOARD || - action.type === UPDATE_DATA_MASK || - action.type === SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE; - -const getCurrentDashboard: typeof dashboardApi.getCurrentDashboard = () => - buildDashboardContext(); - -const onDidChangeDashboard: typeof dashboardApi.onDidChangeDashboard = ( - listener: (ctx: DashboardContext) => void, - thisArgs?: any, -) => - createActionListener( - dashboardChangePredicate, - listener, - () => buildDashboardContext() ?? null, - thisArgs, - ); - -export const dashboard: typeof dashboardApi = { - getCurrentDashboard, - onDidChangeDashboard, -}; diff --git a/superset-frontend/src/core/dataset/index.ts b/superset-frontend/src/core/dataset/index.ts deleted file mode 100644 index e0196fe2817d..000000000000 --- a/superset-frontend/src/core/dataset/index.ts +++ /dev/null @@ -1,63 +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 { createEmitter } from '../utils'; - -type DatasetContext = datasetApi.DatasetContext; - -const emitter = createEmitter(undefined); - -/** - * 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 => { - emitter.fire(ctx); -}; - -const getCurrentDataset: typeof datasetApi.getCurrentDataset = () => { - const current = emitter.getCurrent(); - return current ? { ...current } : undefined; -}; - -const onDidChangeDataset: typeof datasetApi.onDidChangeDataset = ( - listener: (ctx: DatasetContext) => void, - thisArgs?: unknown, -) => { - const bound = thisArgs ? listener.bind(thisArgs) : listener; - // The public contract only emits a concrete context; skip `undefined` clears - // so subscribers are never handed an empty value. - return emitter.event(ctx => { - if (ctx) bound(ctx); - }); -}; - -export const dataset: typeof datasetApi = { - getCurrentDataset, - onDidChangeDataset, -}; diff --git a/superset-frontend/src/core/explore/index.test.ts b/superset-frontend/src/core/explore/index.test.ts deleted file mode 100644 index 744478bc9565..000000000000 --- a/superset-frontend/src/core/explore/index.test.ts +++ /dev/null @@ -1,157 +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. - */ - -// --------------------------------------------------------------------------- -// 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; - -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 deleted file mode 100644 index 9539e1be313f..000000000000 --- a/superset-frontend/src/core/explore/index.ts +++ /dev/null @@ -1,92 +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 `explore` namespace. - * - * Wraps Redux explore state and normalizes it into the stable `ChartContext` - * contract. Extensions must not depend on the Redux slice structure directly. - */ - -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'; -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; - // `store.getState()` is already RootState; read the typed `explore` slice - // directly rather than casting it away. - const state = store.getState(); - const exploreState = state.explore; - if (!exploreState) return undefined; - - const { slice, datasource, controls } = exploreState; - const vizType: string = - (controls?.viz_type?.value as string) ?? - exploreState.form_data?.viz_type ?? - ''; - - return { - chartId: slice?.slice_id ?? null, - chartName: exploreState.sliceName ?? slice?.slice_name ?? null, - vizType, - datasourceId: datasource?.id ?? null, - datasourceName: - datasource?.table_name ?? datasource?.datasource_name ?? null, - }; -} - -const exploreChangePredicate: AnyListenerPredicate = action => - action.type === HYDRATE_EXPLORE || - action.type === SET_FORM_DATA || - action.type === UPDATE_CHART_TITLE || - action.type === SET_DATASOURCE || - action.type === CREATE_NEW_SLICE || - action.type === SLICE_UPDATED; - -const getCurrentChart: typeof exploreApi.getCurrentChart = () => - buildChartContext(); - -const onDidChangeChart: typeof exploreApi.onDidChangeChart = ( - listener: (ctx: ChartContext) => void, - thisArgs?: any, -) => - createActionListener( - exploreChangePredicate, - listener, - () => buildChartContext() ?? null, - thisArgs, - ); - -export const explore: typeof exploreApi = { - getCurrentChart, - onDidChangeChart, -}; diff --git a/superset-frontend/src/core/index.ts b/superset-frontend/src/core/index.ts index dfe304eee54c..ce5b7f4d5052 100644 --- a/superset-frontend/src/core/index.ts +++ b/superset-frontend/src/core/index.ts @@ -29,10 +29,7 @@ export const core: typeof coreType = { export * from './authentication'; export * from './chat'; export * from './commands'; -export * from './dashboard'; -export * from './dataset'; export * from './editors'; -export * from './explore'; export * from './extensions'; export * from './menus'; export * from './models'; diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx index 703f80f92350..36f6059cfcda 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -25,10 +25,7 @@ import { chat, core, commands, - dashboard, - dataset, editors, - explore, extensions, menus, navigation, @@ -94,10 +91,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ chat, core, commands, - dashboard, - dataset, editors, - explore, extensions, menus, navigation, diff --git a/superset-frontend/src/extensions/supersetGlobal.ts b/superset-frontend/src/extensions/supersetGlobal.ts index cd2993491a13..2025c0d08dd0 100644 --- a/superset-frontend/src/extensions/supersetGlobal.ts +++ b/superset-frontend/src/extensions/supersetGlobal.ts @@ -31,10 +31,7 @@ import type { chat, commands, core, - dashboard, - dataset, editors, - explore, extensions, menus, navigation, @@ -48,10 +45,7 @@ export interface SupersetGlobal { core: typeof core; chat: typeof chat; commands: typeof commands; - dashboard: typeof dashboard; - dataset: typeof dataset; editors: typeof editors; - explore: typeof explore; extensions: typeof extensions; menus: typeof menus; navigation: typeof navigation; diff --git a/superset-frontend/src/features/datasets/AddDataset/EditDataset/EditDataset.test.tsx b/superset-frontend/src/features/datasets/AddDataset/EditDataset/EditDataset.test.tsx index e854dc6e34e1..184e15d11c00 100644 --- a/superset-frontend/src/features/datasets/AddDataset/EditDataset/EditDataset.test.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/EditDataset/EditDataset.test.tsx @@ -21,18 +21,12 @@ import { render, screen } from 'spec/helpers/testing-library'; import EditDataset from './index'; const DATASET_ENDPOINT = 'glob:*api/v1/dataset/1/related_objects'; -// EditPage also fetches the dataset entity itself to publish the `dataset` -// extension-namespace context (setCurrentDataset). -const DATASET_RESOURCE_ENDPOINT = 'glob:*api/v1/dataset/1'; const mockedProps = { id: '1', }; fetchMock.get(DATASET_ENDPOINT, { charts: { results: [], count: 2 } }); -fetchMock.get(DATASET_RESOURCE_ENDPOINT, { - result: { id: 1, table_name: 'test_table', schema: 'public' }, -}); test('should render edit dataset view with tabs', async () => { render(); diff --git a/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx b/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx index b47e783a8b56..e0438cee0bd6 100644 --- a/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx @@ -16,12 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -import { useEffect } from 'react'; import { t } from '@apache-superset/core/translation'; import { styled } from '@apache-superset/core/theme'; import useGetDatasetRelatedCounts from 'src/features/datasets/hooks/useGetDatasetRelatedCounts'; -import { useSingleViewResource } from 'src/views/CRUD/hooks'; -import { setCurrentDataset } from 'src/core/dataset'; import { Badge } from '@superset-ui/core/components'; import Tabs from '@superset-ui/core/components/Tabs'; @@ -50,13 +47,6 @@ interface EditPageProps { id: string; } -// Stable no-op error handler so `useSingleViewResource`'s `fetchResource` -// keeps a stable identity across renders (it lists the handler in its deps). -// An inline handler would change every render and re-trigger the fetch effect, -// causing an update loop. Fetch failure is non-fatal here — the dataset -// context simply stays empty. -const noopErrorHandler = () => {}; - const TRANSLATIONS = { USAGE_TEXT: t('Usage'), COLUMNS_TEXT: t('Columns'), @@ -72,45 +62,6 @@ const TABS_KEYS = { const EditPage = ({ id }: EditPageProps) => { const { usageCount } = useGetDatasetRelatedCounts(id); - // Publish the focused dataset to the `dataset` extension namespace so chatbot - // extensions can read which dataset the user is editing. Cleared on unmount. - const { - state: { resource: datasetResource }, - fetchResource, - } = useSingleViewResource<{ - id: number; - table_name?: string; - schema?: string | null; - catalog?: string | null; - sql?: string | null; - is_sqllab_view?: boolean; - database?: { database_name?: string }; - }>('dataset', t('dataset'), noopErrorHandler); - - useEffect(() => { - const datasetId = Number(id); - if (!Number.isNaN(datasetId)) { - fetchResource(datasetId); - } - // `fetchResource` is stable (noopErrorHandler keeps its identity fixed); - // fetch only when the id changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id]); - - useEffect(() => { - if (!datasetResource) return undefined; - setCurrentDataset({ - datasetId: datasetResource.id, - datasetName: datasetResource.table_name ?? String(datasetResource.id), - schema: datasetResource.schema ?? null, - catalog: datasetResource.catalog ?? null, - databaseName: datasetResource.database?.database_name ?? null, - isVirtual: - Boolean(datasetResource.sql) || !!datasetResource.is_sqllab_view, - }); - return () => setCurrentDataset(undefined); - }, [datasetResource]); - const usageTab = ( {TRANSLATIONS.USAGE_TEXT} 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 deleted file mode 100644 index a443b9de8b15..000000000000 --- a/superset/migrations/versions/2026-05-25_00-00_b2c3d4e5f6a7_add_extension_settings.py +++ /dev/null @@ -1,47 +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. -"""Add extension_settings table for chatbot admin selection and enable/disable. - -Revision ID: b2c3d4e5f6a7 -Revises: 33d7e0e21daa -Create Date: 2026-05-25 00:00:00.000000 - -""" - -import sqlalchemy as sa -from alembic import op - -revision = "b2c3d4e5f6a7" -down_revision = "33d7e0e21daa" - - -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/migrations/versions/2026-06-09_00-00_d1e2f3a4b5c6_drop_extension_enabled.py b/superset/migrations/versions/2026-06-09_00-00_d1e2f3a4b5c6_drop_extension_enabled.py deleted file mode 100644 index 3436e7c487a5..000000000000 --- a/superset/migrations/versions/2026-06-09_00-00_d1e2f3a4b5c6_drop_extension_enabled.py +++ /dev/null @@ -1,43 +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. -"""Drop extension_enabled table (ExtensionEnabled model removed in chatbot SIP). - -Revision ID: d1e2f3a4b5c6 -Revises: b2c3d4e5f6a7 -Create Date: 2026-06-09 00:00:00.000000 - -""" - -import sqlalchemy as sa - -from superset.migrations.shared.utils import create_table, drop_table - -# revision identifiers, used by Alembic. -revision = "d1e2f3a4b5c6" -down_revision = "b2c3d4e5f6a7" - - -def upgrade() -> None: - drop_table("extension_enabled") - - -def downgrade() -> None: - create_table( - "extension_enabled", - sa.Column("extension_id", sa.String(250), primary_key=True), - sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()), - ) diff --git a/superset/migrations/versions/2026-06-10_00-00_e2f3a4b5c6d7_drop_extension_settings.py b/superset/migrations/versions/2026-06-10_00-00_e2f3a4b5c6d7_drop_extension_settings.py deleted file mode 100644 index 6a0c3f140774..000000000000 --- a/superset/migrations/versions/2026-06-10_00-00_e2f3a4b5c6d7_drop_extension_settings.py +++ /dev/null @@ -1,46 +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. -"""Drop extension_settings table (ExtensionSettings model removed in chatbot SIP). - -The active chatbot is now resolved purely from the view registry (last-loaded -wins), so the admin-pin settings table is no longer read or written. - -Revision ID: e2f3a4b5c6d7 -Revises: d1e2f3a4b5c6 -Create Date: 2026-06-10 00:00:00.000000 - -""" - -import sqlalchemy as sa - -from superset.migrations.shared.utils import create_table, drop_table - -# revision identifiers, used by Alembic. -revision = "e2f3a4b5c6d7" -down_revision = "d1e2f3a4b5c6" - - -def upgrade() -> None: - drop_table("extension_settings") - - -def downgrade() -> None: - create_table( - "extension_settings", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("active_chatbot_id", sa.String(250), nullable=True), - ) From 5c1609e3f973561271cbb53714e38250bc1396db Mon Sep 17 00:00:00 2001 From: Enzo Martellucci Date: Mon, 15 Jun 2026 17:46:42 +0200 Subject: [PATCH 08/10] chore(extensions): align naming conventions --- .../packages/superset-core/src/chat/index.ts | 33 ++++++------ .../superset-core/src/navigation/index.ts | 23 ++++----- .../components/ChatMount/ChatMount.test.tsx | 4 +- superset-frontend/src/core/chat/index.test.ts | 20 +++----- superset-frontend/src/core/chat/index.ts | 42 +++++++-------- .../src/core/navigation/index.test.ts | 37 +++++++------- .../src/core/navigation/index.ts | 32 ++++++------ superset-frontend/src/core/utils.ts | 51 +++++++++++++++++++ 8 files changed, 146 insertions(+), 96 deletions(-) diff --git a/superset-frontend/packages/superset-core/src/chat/index.ts b/superset-frontend/packages/superset-core/src/chat/index.ts index 612b3d289106..92f65b70ee46 100644 --- a/superset-frontend/packages/superset-core/src/chat/index.ts +++ b/superset-frontend/packages/superset-core/src/chat/index.ts @@ -49,7 +49,7 @@ export interface Chat { description?: string; } -export type ChatMode = 'floating' | 'panel'; +export type DisplayMode = 'floating' | 'panel'; /** * Registers a chat provider. The host applies singleton resolution — only one @@ -131,43 +131,42 @@ export declare function close(): void; export declare function isOpen(): boolean; /** - * Event fired when the chat panel opens, with the descriptor of the chat - * whose panel opened. Listen to this rather than assuming your own chat is - * the one affected — another registration may hold the active slot. + * Event fired when the chat panel opens. Also fired by the host's own + * controls, not only by an extension's open() call. */ -export declare const onDidOpen: Event; +export declare const onDidOpen: Event; /** - * Event fired when the chat panel closes, with the descriptor of the chat - * whose panel closed. Also fired when the host closes the panel itself, e.g. - * because the active chat was disposed or displaced by a different chat. + * Event fired when the chat panel closes. Also fired when the host closes the + * panel itself, e.g. because the active chat was disposed or displaced by a + * different chat. */ -export declare const onDidClose: Event; +export declare const onDidClose: Event; /** * Returns the current display mode. * - * @returns The current ChatMode. + * @returns The current DisplayMode. */ -export declare function getMode(): ChatMode; +export declare function getDisplayMode(): DisplayMode; /** * Sets the display mode. * * The mode is host-global and applies to whichever chat is active, regardless * of which extension calls it. Hosts may also change the mode through their - * own controls — use onDidChangeMode to observe all changes rather than - * assuming the last setMode() call won. + * own controls — use onDidChangeDisplayMode to observe all changes rather than + * assuming the last setDisplayMode() call won. * - * @param mode The display mode to switch to. + * @param displayMode The display mode to switch to. */ -export declare function setMode(mode: ChatMode): void; +export declare function setDisplayMode(displayMode: DisplayMode): void; /** * Event fired when the display mode changes, whether triggered by an - * extension via setMode() or by host-provided controls. + * extension via setDisplayMode() or by host-provided controls. */ -export declare const onDidChangeMode: Event; +export declare const onDidChangeDisplayMode: Event; /** * Event fired when the panel is resized in panel mode. diff --git a/superset-frontend/packages/superset-core/src/navigation/index.ts b/superset-frontend/packages/superset-core/src/navigation/index.ts index 6e46d62bc9ca..585a87f2e9fc 100644 --- a/superset-frontend/packages/superset-core/src/navigation/index.ts +++ b/superset-frontend/packages/superset-core/src/navigation/index.ts @@ -36,10 +36,10 @@ import { Event } from '../common'; * `'dataset_list'` are the browse/list surfaces, distinct from those because no * single entity is active. `'sqllab'` is the SQL editor where * `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'` - * are the related SQL Lab browse pages, which are not the editor. `'other'` - * covers any route not explicitly enumerated. + * are the related SQL Lab browse pages, which are not the editor. `'home'` is + * the welcome surface and the fallback for any route not explicitly enumerated. */ -export type PageType = +export type Page = | 'dashboard' | 'dashboard_list' | 'explore' @@ -49,29 +49,28 @@ export type PageType = | 'saved_queries' | 'dataset' | 'dataset_list' - | 'home' - | 'other'; + | 'home'; /** - * Returns the current page surface type. + * Returns the current page surface. * * @example * ```typescript - * const pageType = navigation.getPageType(); - * if (pageType === 'dashboard') { + * const page = navigation.getPage(); + * if (page === 'dashboard') { * // react to being on a dashboard surface * } * ``` */ -export declare function getPageType(): PageType; +export declare function getPage(): Page; /** * Event fired whenever the user navigates to a different surface. * * @example * ```typescript - * const sub = navigation.onDidChangePage(pageType => { - * if (pageType === 'dashboard') { + * const sub = navigation.onDidChangePage(page => { + * if (page === 'dashboard') { * // react to navigating onto a dashboard surface * } * }); @@ -79,4 +78,4 @@ export declare function getPageType(): PageType; * sub.dispose(); * ``` */ -export declare const onDidChangePage: Event; +export declare const onDidChangePage: Event; diff --git a/superset-frontend/src/components/ChatMount/ChatMount.test.tsx b/superset-frontend/src/components/ChatMount/ChatMount.test.tsx index 069768310ee3..9a940baa80f9 100644 --- a/superset-frontend/src/components/ChatMount/ChatMount.test.tsx +++ b/superset-frontend/src/components/ChatMount/ChatMount.test.tsx @@ -28,7 +28,7 @@ afterEach(() => { disposables.length = 0; // Reset host-owned state shared across tests in this module. chat.close(); - chat.setMode('floating'); + chat.setDisplayMode('floating'); }); }); @@ -157,7 +157,7 @@ test('panel mode docks the open panel and hides the trigger', () => { render(); act(() => { - chat.setMode('panel'); + chat.setDisplayMode('panel'); chat.open(); }); diff --git a/superset-frontend/src/core/chat/index.test.ts b/superset-frontend/src/core/chat/index.test.ts index b764699dc0ea..53765d6df94d 100644 --- a/superset-frontend/src/core/chat/index.test.ts +++ b/superset-frontend/src/core/chat/index.test.ts @@ -29,7 +29,7 @@ afterEach(() => { disposables.length = 0; // Reset host-owned state shared across tests in this module. chat.close(); - chat.setMode('floating'); + chat.setDisplayMode('floating'); }); test('getChat returns undefined when no chat is registered', () => { @@ -156,7 +156,7 @@ test('a disposed event subscription stops receiving notifications', () => { expect(registered).not.toHaveBeenCalled(); }); -test('open and close toggle the panel and fire once with the active descriptor', () => { +test('open and close toggle the panel and fire once', () => { const opened = jest.fn(); const closed = jest.fn(); disposables.push(chat.onDidOpen(opened), chat.onDidClose(closed)); @@ -172,14 +172,12 @@ test('open and close toggle the panel and fire once with the active descriptor', expect(chat.isOpen()).toBe(true); expect(opened).toHaveBeenCalledTimes(1); - expect(opened).toHaveBeenCalledWith(descriptor); chat.close(); chat.close(); expect(chat.isOpen()).toBe(false); expect(closed).toHaveBeenCalledTimes(1); - expect(closed).toHaveBeenCalledWith(descriptor); }); test('open is a no-op while no chat is registered', () => { @@ -213,7 +211,6 @@ test('a takeover by a different id closes the displaced chat panel', () => { // The incoming chat must not mount into an open state it never requested. expect(chat.isOpen()).toBe(false); expect(closed).toHaveBeenCalledTimes(1); - expect(closed).toHaveBeenCalledWith(first); }); test('a same-id replacement keeps the open state', () => { @@ -250,7 +247,6 @@ test('disposing the active chat while open closes it; the fallback starts closed expect(chat.getChat()?.id).toBe('first.chat'); expect(chat.isOpen()).toBe(false); expect(closed).toHaveBeenCalledTimes(1); - expect(closed).toHaveBeenCalledWith(second); }); test('disposing an inactive registration leaves the open state untouched', () => { @@ -293,18 +289,18 @@ test('disposing the last chat while open resets the open state', () => { expect(chat.isOpen()).toBe(false); }); -test('mode defaults to floating and setMode fires only on change', () => { +test('mode defaults to floating and setDisplayMode fires only on change', () => { const modeChanged = jest.fn(); - disposables.push(chat.onDidChangeMode(modeChanged)); + disposables.push(chat.onDidChangeDisplayMode(modeChanged)); - expect(chat.getMode()).toBe('floating'); + expect(chat.getDisplayMode()).toBe('floating'); // Setting the current mode is a no-op. - chat.setMode('floating'); + chat.setDisplayMode('floating'); expect(modeChanged).not.toHaveBeenCalled(); - chat.setMode('panel'); - expect(chat.getMode()).toBe('panel'); + chat.setDisplayMode('panel'); + expect(chat.getDisplayMode()).toBe('panel'); expect(modeChanged).toHaveBeenCalledWith('panel'); }); diff --git a/superset-frontend/src/core/chat/index.ts b/superset-frontend/src/core/chat/index.ts index fea100e3ec1e..36e1b38e3ca3 100644 --- a/superset-frontend/src/core/chat/index.ts +++ b/superset-frontend/src/core/chat/index.ts @@ -44,7 +44,7 @@ import { Disposable } from '../models'; import { createEmitter, createEventEmitter } from '../utils'; type Chat = chatApi.Chat; -type ChatMode = chatApi.ChatMode; +type DisplayMode = chatApi.DisplayMode; /** A registered chat: its descriptor plus the host-mountable providers. */ export interface RegisteredChat { @@ -52,7 +52,7 @@ export interface RegisteredChat { chat: Chat; /** Renders the collapsed bubble. Hidden by the host in panel mode. */ trigger: () => ReactElement; - /** Renders the chat panel, mounted per the current {@link ChatMode}. */ + /** Renders the chat panel, mounted per the current {@link DisplayMode}. */ panel: () => ReactElement; /** * Unique per registration (a same-id re-registration gets a new one). The @@ -74,7 +74,7 @@ export interface ChatSnapshot { /** Whether the active chat's panel is open. */ open: boolean; /** The current display mode. */ - mode: ChatMode; + mode: DisplayMode; /** The active registration, or undefined when none is registered. */ active: RegisteredChat | undefined; } @@ -87,10 +87,10 @@ let nextRegistrationId = 1; const registerEmitter = createEventEmitter(); const unregisterEmitter = createEventEmitter(); -const openEmitter = createEventEmitter(); -const closeEmitter = createEventEmitter(); +const openEmitter = createEventEmitter(); +const closeEmitter = createEventEmitter(); const resizePanelEmitter = createEventEmitter<{ width: number }>(); -const modeEmitter = createEmitter('floating'); +const modeEmitter = createEmitter('floating'); /** * Host-internal: resolves the active chat with its providers. @@ -128,10 +128,10 @@ export const subscribeToChatState = (listener: () => void): (() => void) => { export const getChatSnapshot = (): ChatSnapshot => snapshot; -/** Closes the panel and fires `onDidClose` with the chat that was closed. */ -const closePanel = (closedChat: Chat) => { +/** Closes the panel and fires `onDidClose`. */ +const closePanel = () => { panelOpen = false; - closeEmitter.fire(closedChat); + closeEmitter.fire(); }; const registerChat: typeof chatApi.registerChat = ( @@ -162,7 +162,7 @@ const registerChat: typeof chatApi.registerChat = ( // incoming chat never mounts already-open; a same-id replacement is an // upgrade in place and keeps the open state. if (panelOpen && previousActive && previousActive.chat.id !== chat.id) { - closePanel(previousActive.chat); + closePanel(); } notifyState(); @@ -179,7 +179,7 @@ const registerChat: typeof chatApi.registerChat = ( // starts closed. Disposing an inactive registration leaves the open // state of the active chat untouched. if (panelOpen && wasActive) { - closePanel(chat); + closePanel(); } notifyState(); }); @@ -197,25 +197,27 @@ const open: typeof chatApi.open = (): void => { // would otherwise leak `open` into a future, unrelated registration. if (panelOpen || !active) return; panelOpen = true; - openEmitter.fire(active.chat); + openEmitter.fire(); notifyState(); }; const close: typeof chatApi.close = (): void => { const active = getActiveChat(); if (!panelOpen || !active) return; - closePanel(active.chat); + closePanel(); notifyState(); }; const isOpen: typeof chatApi.isOpen = (): boolean => panelOpen; -const getMode: typeof chatApi.getMode = (): ChatMode => +const getDisplayMode: typeof chatApi.getDisplayMode = (): DisplayMode => modeEmitter.getCurrent(); -const setMode: typeof chatApi.setMode = (mode: ChatMode): void => { - if (mode === modeEmitter.getCurrent()) return; - modeEmitter.fire(mode); +const setDisplayMode: typeof chatApi.setDisplayMode = ( + displayMode: DisplayMode, +): void => { + if (displayMode === modeEmitter.getCurrent()) return; + modeEmitter.fire(displayMode); notifyState(); }; @@ -229,9 +231,9 @@ export const chat: typeof chatApi = { isOpen, onDidOpen: openEmitter.event, onDidClose: closeEmitter.event, - getMode, - setMode, - onDidChangeMode: modeEmitter.event, + getDisplayMode, + setDisplayMode, + onDidChangeDisplayMode: modeEmitter.event, // The host fires this from its panel resizer; until that chrome exists the // event is exposed but never fires. onDidResizePanel: resizePanelEmitter.event, diff --git a/superset-frontend/src/core/navigation/index.test.ts b/superset-frontend/src/core/navigation/index.test.ts index b9cb7451d240..93fc2e03a96b 100644 --- a/superset-frontend/src/core/navigation/index.test.ts +++ b/superset-frontend/src/core/navigation/index.test.ts @@ -17,7 +17,7 @@ * under the License. */ -// Reset module state between tests so currentPageType is re-initialized. +// Reset module state between tests so currentPage is re-initialized. beforeEach(() => { jest.resetModules(); Object.defineProperty(window, 'location', { @@ -31,21 +31,24 @@ async function importNavigation() { return mod; } -test('getPageType returns "other" for unknown pathname', async () => { - const { navigation } = await importNavigation(); - expect(navigation.getPageType()).toBe('other'); +test('getPage falls back to "home" for the welcome page and unknown pathnames', async () => { + const { navigation, notifyPageChange } = await importNavigation(); + // The default pathname ('/') is not enumerated and falls back to home. + expect(navigation.getPage()).toBe('home'); + notifyPageChange('/superset/welcome/'); + expect(navigation.getPage()).toBe('home'); }); -test('getPageType derives page type from window.location.pathname', async () => { +test('getPage derives the page from window.location.pathname', async () => { window.location.pathname = '/superset/dashboard/42/'; const { navigation } = await importNavigation(); - expect(navigation.getPageType()).toBe('dashboard'); + expect(navigation.getPage()).toBe('dashboard'); }); test('notifyPageChange updates the current page type', async () => { const { navigation, notifyPageChange } = await importNavigation(); notifyPageChange('/explore/?form_data={}'); - expect(navigation.getPageType()).toBe('explore'); + expect(navigation.getPage()).toBe('explore'); }); test('notifyPageChange fires listeners on page type change', async () => { @@ -82,40 +85,40 @@ test('onDidChangePage listener is removed after dispose', async () => { test('sqllab path is matched with and without trailing slash', async () => { const { notifyPageChange, navigation } = await importNavigation(); notifyPageChange('/sqllab'); - expect(navigation.getPageType()).toBe('sqllab'); + expect(navigation.getPage()).toBe('sqllab'); notifyPageChange('/explore/'); notifyPageChange('/sqllab/'); - expect(navigation.getPageType()).toBe('sqllab'); + expect(navigation.getPage()).toBe('sqllab'); }); test('chart and dashboard list pages get their own page types', async () => { const { notifyPageChange, navigation } = await importNavigation(); notifyPageChange('/chart/list/'); - expect(navigation.getPageType()).toBe('chart_list'); + expect(navigation.getPage()).toBe('chart_list'); notifyPageChange('/dashboard/list/'); - expect(navigation.getPageType()).toBe('dashboard_list'); + expect(navigation.getPage()).toBe('dashboard_list'); }); test('dataset list and single-dataset pages get distinct page types', async () => { const { notifyPageChange, navigation } = await importNavigation(); notifyPageChange('/tablemodelview/list/'); - expect(navigation.getPageType()).toBe('dataset_list'); + expect(navigation.getPage()).toBe('dataset_list'); notifyPageChange('/dataset/42'); - expect(navigation.getPageType()).toBe('dataset'); + expect(navigation.getPage()).toBe('dataset'); }); test('sqllab editor, query history, and saved queries get distinct page types', async () => { const { notifyPageChange, navigation } = await importNavigation(); notifyPageChange('/sqllab/'); - expect(navigation.getPageType()).toBe('sqllab'); + expect(navigation.getPage()).toBe('sqllab'); notifyPageChange('/sqllab/history/'); - expect(navigation.getPageType()).toBe('query_history'); + expect(navigation.getPage()).toBe('query_history'); notifyPageChange('/savedqueryview/list/'); - expect(navigation.getPageType()).toBe('saved_queries'); + expect(navigation.getPage()).toBe('saved_queries'); }); test('chart/add resolves to explore, not chart_list', async () => { const { notifyPageChange, navigation } = await importNavigation(); notifyPageChange('/chart/add'); - expect(navigation.getPageType()).toBe('explore'); + expect(navigation.getPage()).toBe('explore'); }); diff --git a/superset-frontend/src/core/navigation/index.ts b/superset-frontend/src/core/navigation/index.ts index f02afb217b2a..f654ef412231 100644 --- a/superset-frontend/src/core/navigation/index.ts +++ b/superset-frontend/src/core/navigation/index.ts @@ -27,11 +27,11 @@ import type { navigation as navigationApi } from '@apache-superset/core'; import { Disposable } from '../models'; -type PageType = navigationApi.PageType; +type Page = navigationApi.Page; -const listeners = new Set<(pageType: PageType) => void>(); +const listeners = new Set<(page: Page) => void>(); -function derivePageType(pathname: string): PageType { +function derivePage(pathname: string): Page { if (pathname.startsWith('/superset/dashboard/')) return 'dashboard'; if (pathname.startsWith('/dashboard/list')) return 'dashboard_list'; if (pathname.startsWith('/explore/')) return 'explore'; @@ -44,31 +44,31 @@ function derivePageType(pathname: string): PageType { return 'sqllab'; if (pathname.startsWith('/tablemodelview/list')) return 'dataset_list'; if (pathname.startsWith('/dataset/')) return 'dataset'; - if (pathname.startsWith('/superset/welcome/')) return 'home'; - return 'other'; + // The welcome page and any route not explicitly enumerated fall back to home. + return 'home'; } -let currentPageType: PageType | undefined; +let currentPage: Page | undefined; -function getOrInitPageType(): PageType { - if (currentPageType === undefined) { - currentPageType = derivePageType(window.location.pathname); +function getOrInitPage(): Page { + if (currentPage === undefined) { + currentPage = derivePage(window.location.pathname); } - return currentPageType; + return currentPage; } /** Called by ExtensionsStartup whenever the React Router location changes. */ export const notifyPageChange = (pathname: string): void => { - const next = derivePageType(pathname); - if (next === getOrInitPageType()) return; - currentPageType = next; + const next = derivePage(pathname); + if (next === getOrInitPage()) return; + currentPage = next; listeners.forEach(fn => fn(next)); }; -const getPageType: typeof navigationApi.getPageType = () => getOrInitPageType(); +const getPage: typeof navigationApi.getPage = () => getOrInitPage(); const onDidChangePage: typeof navigationApi.onDidChangePage = ( - listener: (pageType: PageType) => void, + listener: (page: Page) => void, thisArgs?: any, ): Disposable => { const bound = thisArgs ? listener.bind(thisArgs) : listener; @@ -77,6 +77,6 @@ const onDidChangePage: typeof navigationApi.onDidChangePage = ( }; export const navigation: typeof navigationApi = { - getPageType, + getPage, onDidChangePage, }; diff --git a/superset-frontend/src/core/utils.ts b/superset-frontend/src/core/utils.ts index 1e4dded93c35..eb958a0e119e 100644 --- a/superset-frontend/src/core/utils.ts +++ b/superset-frontend/src/core/utils.ts @@ -21,6 +21,57 @@ import { AnyAction } from 'redux'; import { listenerMiddleware, RootState, store } from 'src/views/store'; import { AnyListenerPredicate } from '@reduxjs/toolkit'; +type Listener = (e: T) => unknown; + +/** A stateless event emitter exposing a VS Code-style `event` subscriber. */ +export interface EventEmitter { + /** Notifies every current subscriber with `value`. */ + fire(value: T): void; + /** The public {@link core.Event} used to subscribe to this emitter. */ + event: core.Event; +} + +/** A stateful emitter that also retains the last fired value. */ +export interface Emitter extends EventEmitter { + /** Returns the value last passed to {@link fire} (or the initial value). */ + getCurrent(): T; +} + +/** + * Creates a stateless event emitter. Listeners registered via `event` receive + * every subsequent `fire`; a returned Disposable removes the listener. + */ +export function createEventEmitter(): EventEmitter { + const listeners = new Set>(); + const event: core.Event = (listener, thisArgs) => { + const bound = thisArgs ? listener.bind(thisArgs) : listener; + listeners.add(bound); + return { dispose: () => listeners.delete(bound) }; + }; + return { + fire: value => listeners.forEach(fn => fn(value)), + event, + }; +} + +/** + * Creates a stateful emitter seeded with `initial`. Behaves like + * {@link createEventEmitter} but also tracks the last fired value, readable + * via `getCurrent` — useful for state that is both observed and queried. + */ +export function createEmitter(initial: T): Emitter { + const { fire, event } = createEventEmitter(); + let current = initial; + return { + fire: value => { + current = value; + fire(value); + }, + event, + getCurrent: () => current, + }; +} + export function createActionListener( predicate: AnyListenerPredicate, listener: (v: V) => void, From 395bbb9611f3b7832faa6c72cbe344569368a020 Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" <70410625+michael-s-molina@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:14:08 -0300 Subject: [PATCH 09/10] chore: More cleanup of chat extension code (#41116) --- .../superset-core/src/common/index.ts | 43 +-------- .../packages/superset-core/src/views/index.ts | 36 ++++++- superset-frontend/src/core/views/index.ts | 63 ++++++++++-- .../src/extensions/ExtensionsList.test.tsx | 88 +++++++++++++++++ .../src/extensions/ExtensionsList.tsx | 95 +++++++++++++++++++ .../src/extensions/ExtensionsLoader.test.ts | 65 +------------ .../src/extensions/ExtensionsLoader.ts | 34 +------ .../src/extensions/ExtensionsStartup.tsx | 23 +---- superset-frontend/src/views/routes.tsx | 11 +++ superset/extensions/api.py | 45 ++++----- superset/extensions/utils.py | 1 + .../__init__.py => extensions/view.py} | 19 ++++ superset/initialization/__init__.py | 47 ++++++++- tests/unit_tests/extensions/test_api.py | 46 --------- tests/unit_tests/extensions/test_types.py | 3 + 15 files changed, 376 insertions(+), 243 deletions(-) create mode 100644 superset-frontend/src/extensions/ExtensionsList.test.tsx create mode 100644 superset-frontend/src/extensions/ExtensionsList.tsx rename superset/{commands/extension/__init__.py => extensions/view.py} (56%) delete mode 100644 tests/unit_tests/extensions/test_api.py diff --git a/superset-frontend/packages/superset-core/src/common/index.ts b/superset-frontend/packages/superset-core/src/common/index.ts index 38cf4664d669..7f28aba584b3 100644 --- a/superset-frontend/packages/superset-core/src/common/index.ts +++ b/superset-frontend/packages/superset-core/src/common/index.ts @@ -213,53 +213,14 @@ export declare interface Event { (listener: (e: T) => any, thisArgs?: any): Disposable; } -/** - * Context handed to an extension's `activate` function. - * - * `context.subscriptions` is provided for extensions to push their - * {@link Disposable}s into. The host provides the array but does not dispose - * it (lifecycle management is deferred). - * - * @example - * ```typescript - * export function activate(context: ExtensionContext) { - * context.subscriptions.push( - * commands.registerCommand('my_ext.hello', () => {}), - * ); - * } - * ``` - */ -export interface ExtensionContext { - /** - * Disposables pushed by the extension. Provided for extensions to track - * their own registrations; the host does not dispose them. - */ - subscriptions: { dispose(): void }[]; -} - -/** - * Shape of an extension's entry module (its `./index`). - * - * Extensions are encouraged to export an `activate(context)` function so that - * their registrations are tracked via `context.subscriptions` regardless of - * whether they run synchronously or asynchronously. For backward compatibility, - * a module may instead register its contributions as top-level side effects when - * the module is evaluated. - */ -export interface ExtensionModule { - /** - * Called by the host once the extension module has loaded. May be async; the - * host awaits it before considering the extension active. - */ - activate?(context: ExtensionContext): void | Promise; -} - /** * Represents a Superset extension with its metadata. * Extensions are modular components that can extend Superset's functionality * by registering commands, views, menus, and editors as module-level side effects. */ export interface Extension { + /** List of other extensions that this extension depends on */ + dependencies: string[]; /** Human-readable description of the extension */ description: string; /** Unique identifier for the extension */ diff --git a/superset-frontend/packages/superset-core/src/views/index.ts b/superset-frontend/packages/superset-core/src/views/index.ts index c8ac971b2933..df5990046510 100644 --- a/superset-frontend/packages/superset-core/src/views/index.ts +++ b/superset-frontend/packages/superset-core/src/views/index.ts @@ -36,7 +36,7 @@ */ import { ReactElement } from 'react'; -import { Disposable } from '../common'; +import { Disposable, Event } from '../common'; /** * Represents a contributed view in the application. @@ -56,12 +56,12 @@ export interface View { * The view provider function is called when the UI renders the location, * and should return a React element to display. * - * @param view The view descriptor (id, name, and optional description). + * @param view The view descriptor (id and name). * @param location The location where this view should appear (e.g. "sqllab.panels"). * @param provider A function that returns the React element to render. * @returns A Disposable that unregisters the view when disposed. * - * @example SQL Lab panel + * @example * ```typescript * views.registerView( * { id: 'my_ext.result_stats', name: 'Result Stats' }, @@ -88,3 +88,33 @@ export declare function registerView( * ``` */ export declare function getViews(location: string): View[] | undefined; + +/** + * Event fired when a view is registered. + */ +export interface ViewRegisteredEvent { + /** The descriptor of the view that was registered. */ + view: View; + /** The location where the view was registered. */ + location: string; +} + +/** + * Event fired when a view is unregistered. + */ +export interface ViewUnregisteredEvent { + /** The descriptor of the view that was unregistered. */ + view: View; + /** The location where the view was registered. */ + location: string; +} + +/** + * Event fired when a view is registered. + */ +export declare const onDidRegisterView: Event; + +/** + * Event fired when a view is unregistered. + */ +export declare const onDidUnregisterView: Event; diff --git a/superset-frontend/src/core/views/index.ts b/superset-frontend/src/core/views/index.ts index 8f726432cce9..3e8f775993e6 100644 --- a/superset-frontend/src/core/views/index.ts +++ b/superset-frontend/src/core/views/index.ts @@ -24,13 +24,15 @@ * Extensions register views as side effects at import time. */ -import React, { ReactElement } from 'react'; +import React, { ReactElement, useSyncExternalStore } from 'react'; import type { views as viewsApi } from '@apache-superset/core'; import { ErrorBoundary } from 'src/components/ErrorBoundary'; import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder'; import { Disposable } from '../models'; type View = viewsApi.View; +type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent; +type ViewUnregisteredEvent = viewsApi.ViewUnregisteredEvent; const viewRegistry: Map< string, @@ -39,6 +41,27 @@ const viewRegistry: Map< const locationIndex: Map> = new Map(); +const syncListeners = new Set<() => void>(); +const subscribe = (listener: () => void) => { + syncListeners.add(listener); + return () => syncListeners.delete(listener); +}; + +const registerListeners = new Set<(e: ViewRegisteredEvent) => void>(); +const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>(); + +const viewsCache = new Map(); +const notifyRegister = (event: ViewRegisteredEvent) => { + viewsCache.clear(); + syncListeners.forEach(l => l()); + registerListeners.forEach(l => l(event)); +}; +const notifyUnregister = (event: ViewUnregisteredEvent) => { + viewsCache.clear(); + syncListeners.forEach(l => l()); + unregisterListeners.forEach(l => l(event)); +}; + const registerView: typeof viewsApi.registerView = ( view: View, location: string, @@ -46,21 +69,17 @@ const registerView: typeof viewsApi.registerView = ( ): Disposable => { const { id } = view; - const previousLocation = viewRegistry.get(id)?.location; - if (previousLocation && previousLocation !== location) { - locationIndex.get(previousLocation)?.delete(id); - } - viewRegistry.set(id, { view, location, provider }); const ids = locationIndex.get(location) ?? new Set(); ids.add(id); locationIndex.set(location, ids); + notifyRegister({ view, location }); return new Disposable(() => { - const registeredLocation = viewRegistry.get(id)?.location ?? location; viewRegistry.delete(id); - locationIndex.get(registeredLocation)?.delete(id); + locationIndex.get(location)?.delete(id); + notifyUnregister({ view, location }); }); }; @@ -83,7 +102,35 @@ const getViews: typeof viewsApi.getViews = ( .filter((c): c is View => !!c); }; +export const useViews = (location: string): View[] | undefined => + useSyncExternalStore( + subscribe, + () => { + if (!viewsCache.has(location)) { + viewsCache.set(location, getViews(location)); + } + return viewsCache.get(location); + }, + () => undefined, + ); + +export const onDidRegisterView: typeof viewsApi.onDidRegisterView = ( + listener: (e: ViewRegisteredEvent) => void, +): Disposable => { + registerListeners.add(listener); + return new Disposable(() => registerListeners.delete(listener)); +}; + +export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = ( + listener: (e: ViewUnregisteredEvent) => void, +): Disposable => { + unregisterListeners.add(listener); + return new Disposable(() => unregisterListeners.delete(listener)); +}; + export const views: typeof viewsApi = { registerView, getViews, + onDidRegisterView, + onDidUnregisterView, }; diff --git a/superset-frontend/src/extensions/ExtensionsList.test.tsx b/superset-frontend/src/extensions/ExtensionsList.test.tsx new file mode 100644 index 000000000000..ee2be4691d1f --- /dev/null +++ b/superset-frontend/src/extensions/ExtensionsList.test.tsx @@ -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 { render, waitFor } from 'spec/helpers/testing-library'; +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, + }, +}; + +const defaultProps = { + addDangerToast: jest.fn(), + addSuccessToast: jest.fn(), +}; + +const renderWithStore = (props = {}) => + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + initialState: mockInitialState, + }); + +test('renders extensions list with basic structure', async () => { + renderWithStore(); + + // Check that the component renders + expect(document.body).toBeInTheDocument(); +}); + +test('displays extension names in the list', async () => { + renderWithStore(); + + await waitFor(() => { + // These texts should appear somewhere in the rendered component + expect(document.body).toHaveTextContent(/Extensions/); + }); +}); + +test('calls toast functions when provided', () => { + const addDangerToast = jest.fn(); + const addSuccessToast = jest.fn(); + + renderWithStore({ + addDangerToast, + addSuccessToast, + }); + + // The component should accept these props without error + expect(addDangerToast).toBeDefined(); + expect(addSuccessToast).toBeDefined(); +}); diff --git a/superset-frontend/src/extensions/ExtensionsList.tsx b/superset-frontend/src/extensions/ExtensionsList.tsx new file mode 100644 index 000000000000..6f4f9c2f56db --- /dev/null +++ b/superset-frontend/src/extensions/ExtensionsList.tsx @@ -0,0 +1,95 @@ +/** + * 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 { t } from '@apache-superset/core/translation'; +import { FunctionComponent, useMemo } from 'react'; +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'; + +const PAGE_SIZE = 25; + +type Extension = { + id: number; + name: string; + enabled: boolean; +}; + +interface ExtensionsListProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; +} + +const ExtensionsList: FunctionComponent = ({ + addDangerToast, + addSuccessToast, +}) => { + const { + state: { loading, resourceCount, resourceCollection }, + fetchData, + refreshData, + } = useListViewResource( + 'extensions', + t('Extensions'), + addDangerToast, + ); + + const columns = useMemo( + () => [ + { + Header: t('Name'), + accessor: 'name', + size: 'lg', + id: 'name', + Cell: ({ + row: { + original: { name }, + }, + }: any) => name, + }, + ], + [loading], // We need to monitor loading to avoid stale state in actions + ); + + const menuData: SubMenuProps = { + activeChild: 'Extensions', + name: t('Extensions'), + buttons: [], + }; + + return ( + <> + + + columns={columns} + count={resourceCount} + data={resourceCollection} + initialSort={[{ id: 'name', desc: false }]} + pageSize={PAGE_SIZE} + fetchData={fetchData} + loading={loading} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + refreshData={refreshData} + /> + + ); +}; + +export default withToasts(ExtensionsList); diff --git a/superset-frontend/src/extensions/ExtensionsLoader.test.ts b/superset-frontend/src/extensions/ExtensionsLoader.test.ts index d5fc06c0d322..1d2493671ad1 100644 --- a/superset-frontend/src/extensions/ExtensionsLoader.test.ts +++ b/superset-frontend/src/extensions/ExtensionsLoader.test.ts @@ -29,20 +29,15 @@ function createMockExtension(overrides: Partial = {}): Extension { name: 'Test Extension', description: 'A test extension', version: '1.0.0', + dependencies: [], remoteEntry: '', + extensionDependencies: [], ...overrides, }; } beforeEach(() => { (ExtensionsLoader as any).instance = undefined; - // Minimal host registry surface the loader wraps during module evaluation. - (window as any).superset = { - commands: { registerCommand: jest.fn() }, - menus: { registerMenuItem: jest.fn() }, - editors: { registerEditor: jest.fn() }, - views: { registerView: jest.fn() }, - }; }); test('creates a singleton instance', () => { @@ -147,59 +142,3 @@ test('logs error when initializeExtensions fails', async () => { errorSpy.mockRestore(); }); - -/** - * Stubs the module-federation machinery `loadModule` depends on so a fake - * extension entry module (its `./index` factory) can be loaded in jsdom. - * Returns a cleanup function that restores the patched globals. - */ -function mockRemoteModule(containerName: string, factory: () => unknown) { - const appendChildSpy = jest - .spyOn(document.head, 'appendChild') - .mockImplementation((element: Node) => { - if (element instanceof HTMLScriptElement && element.onload) { - setTimeout(() => (element.onload as any)(new Event('load')), 0); - } - return element; - }); - - (global as any).__webpack_init_sharing__ = jest - .fn() - .mockResolvedValue(undefined); - (global as any).__webpack_share_scopes__ = { default: {} }; - (window as any)[containerName] = { - init: jest.fn().mockResolvedValue(undefined), - get: jest.fn().mockResolvedValue(factory), - }; - - return () => { - appendChildSpy.mockRestore(); - delete (global as any).__webpack_init_sharing__; - delete (global as any).__webpack_share_scopes__; - delete (window as any)[containerName]; - }; -} - -const remoteExtension = (overrides: Partial = {}) => - createMockExtension({ - id: 'remote-ext', - remoteEntry: 'http://example/remoteEntry.js', - ...overrides, - }); - -test('runs activate(context) hook for modern-style extensions', async () => { - const loader = ExtensionsLoader.getInstance(); - const activate = jest.fn().mockResolvedValue(undefined); - const factory = () => ({ activate }); - const cleanup = mockRemoteModule('remote-ext', factory); - - await loader.initializeExtension(remoteExtension()); - - expect(activate).toHaveBeenCalledTimes(1); - // The context object passed to activate must have a subscriptions array. - expect(activate).toHaveBeenCalledWith( - expect.objectContaining({ subscriptions: expect.any(Array) }), - ); - - cleanup(); -}); diff --git a/superset-frontend/src/extensions/ExtensionsLoader.ts b/superset-frontend/src/extensions/ExtensionsLoader.ts index fb2fbecbf322..0b74ef9be861 100644 --- a/superset-frontend/src/extensions/ExtensionsLoader.ts +++ b/superset-frontend/src/extensions/ExtensionsLoader.ts @@ -17,17 +17,10 @@ * under the License. */ import { SupersetClient } from '@superset-ui/core'; -import { t } from '@apache-superset/core/translation'; import { logging } from '@apache-superset/core/utils'; import type { common as core } from '@apache-superset/core'; -import { addDangerToast } from 'src/components/MessageToasts/actions'; -import { store } from 'src/views/store'; -// Side-effect import: brings the `window.superset` global augmentation into scope. -import 'src/extensions/supersetGlobal'; type Extension = core.Extension; -type ExtensionContext = core.ExtensionContext; -type ExtensionModule = core.ExtensionModule; /** * Loads extension modules via webpack module federation. @@ -88,8 +81,7 @@ class ExtensionsLoader { /** * Initializes a single extension. - * If the extension has a remote entry, loads the module and runs its - * `activate(context)` hook (or, for legacy extensions, its top-level + * If the extension has a remote entry, loads the module (which triggers * side-effect registrations for commands, views, menus, and editors). * @param extension The extension to initialize. */ @@ -104,15 +96,12 @@ class ExtensionsLoader { `Failed to initialize extension ${extension.name}\n`, error, ); - store.dispatch( - addDangerToast(t('Extension "%s" failed to load.', extension.name)), - ); } } /** - * Loads a single extension module via webpack module federation and runs its - * `activate(context)` hook. + * Loads a single extension module via webpack module federation. + * The module's top-level side effects fire contribution registrations. * @param extension The extension to load. */ private async loadModule(extension: Extension): Promise { @@ -160,21 +149,8 @@ class ExtensionsLoader { await container.init(__webpack_share_scopes__.default); const factory = await container.get('./index'); - - // `context.subscriptions` is provided for extensions to push their - // Disposables into. The host does not dispose them (lifecycle management is - // deferred); extensions own the array for as long as they are active. - const context: ExtensionContext = { subscriptions: [] }; - - // Evaluate the module factory. Extensions may register contributions as - // top-level side effects here, or return a module exposing `activate`. - const module = factory() as ExtensionModule | undefined; - - // Preferred path: hand the extension its context so it can track every - // registration it makes, synchronous or asynchronous. - if (typeof module?.activate === 'function') { - await module.activate(context); - } + // Execute the module factory - side effects fire registrations + factory(); } /** diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx index 36f6059cfcda..03df91789269 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; import { logging } from '@apache-superset/core/utils'; import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; @@ -42,7 +42,6 @@ import 'src/extensions/supersetGlobal'; const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ children, }) => { - const [initialized, setInitialized] = useState(false); const location = useLocation(); const prevPathname = useRef(null); @@ -75,14 +74,6 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ }, []); useEffect(() => { - if (initialized) return; - - if (!userId) { - // No user logged in — nothing to initialize - setInitialized(true); - return; - } - // 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. @@ -99,20 +90,10 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ views, }; - // Render the host immediately; extension bundles load in the background. - // ChatMount re-resolves reactively once a chat extension registers (via - // subscribeToChatState / getChatSnapshot), so the bubble appears - // without blocking the UI. - setInitialized(true); - if (isFeatureEnabled(FeatureFlag.EnableExtensions)) { ExtensionsLoader.getInstance().initializeExtensions(); } - }, [initialized, userId]); - - if (!initialized) { - return null; - } + }, [userId]); return <>{children}; }; diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index dfa30687e5ff..4f066e3ec2cb 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -128,6 +128,10 @@ const Tags = lazy( () => import(/* webpackChunkName: "Tags" */ 'src/pages/Tags'), ); +const Extensions = lazy( + () => import(/* webpackChunkName: "Tags" */ 'src/extensions/ExtensionsList'), +); + const RowLevelSecurityList = lazy( () => import( @@ -359,6 +363,13 @@ if (isAdmin) { Component: GroupsList, }, ); + + if (isFeatureEnabled(FeatureFlag.EnableExtensions)) { + routes.push({ + path: '/extensions/list/', + Component: Extensions, + }); + } } if (authRegistrationEnabled) { diff --git a/superset/extensions/api.py b/superset/extensions/api.py index 68ca938af1c6..b1b5734979ee 100644 --- a/superset/extensions/api.py +++ b/superset/extensions/api.py @@ -15,41 +15,34 @@ # specific language governing permissions and limitations # under the License. import mimetypes -import re from io import BytesIO from typing import Any from flask import send_file from flask.wrappers import Response -from flask_appbuilder.api import expose, protect, safe +from flask_appbuilder.api import BaseApi, expose, protect, safe from superset.extensions.utils import ( build_extension_data, get_extensions, ) -from superset.views.base_api import BaseSupersetApi -# 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_-]+$") +class ExtensionsRestApi(BaseApi): + allow_browser_login = True + resource_name = "extensions" -def _validate_segment(value: str) -> bool: - """Return True if *value* is a safe publisher or name segment.""" - return bool(_SEGMENT_RE.match(value)) + def response(self, status_code: int, **kwargs: Any) -> Response: + """Helper method to create JSON responses.""" + from flask import jsonify + return jsonify(kwargs), status_code -class ExtensionsRestApi(BaseSupersetApi): - allow_browser_login = True - resource_name = "extensions" - class_permission_name = "Extensions" - base_permissions = [ - "can_get_list", - "can_get", - "can_content", - "can_info", - ] + def response_404(self) -> Response: + """Helper method to create 404 responses.""" + from flask import jsonify + + return jsonify({"message": "Not found"}), 404 @expose("/_info", methods=("GET",)) @protect() @@ -79,13 +72,13 @@ def info(self, **kwargs: Any) -> Response: @safe @expose("/", methods=("GET",)) def get_list(self, **kwargs: Any) -> Response: - """List all installed extensions. + """List all enabled extensions. --- get_list: - summary: List all installed extensions. + summary: List all enabled extensions. responses: 200: - description: List of all installed extensions + description: List of all enabled extensions content: application/json: schema: @@ -165,8 +158,7 @@ def get(self, publisher: str, name: str, **kwargs: Any) -> Response: 500: $ref: '#/components/responses/500' """ - if not _validate_segment(publisher) or not _validate_segment(name): - return self.response(400, message="Invalid publisher or name.") + # Reconstruct composite ID from publisher and name composite_id = f"{publisher}.{name}" extensions = get_extensions() extension = extensions.get(composite_id) @@ -218,8 +210,7 @@ def content(self, publisher: str, name: str, file: str) -> Response: 500: $ref: '#/components/responses/500' """ - if not _validate_segment(publisher) or not _validate_segment(name): - return self.response(400, message="Invalid publisher or name.") + # Reconstruct composite ID from publisher and name composite_id = f"{publisher}.{name}" extensions = get_extensions() extension = extensions.get(composite_id) diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py index 1995874c5467..fc5b6250bb6c 100644 --- a/superset/extensions/utils.py +++ b/superset/extensions/utils.py @@ -242,6 +242,7 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]: "name": extension.name, "version": extension.version, "description": manifest.description or "", + "dependencies": manifest.dependencies, } if manifest.frontend: frontend = manifest.frontend diff --git a/superset/commands/extension/__init__.py b/superset/extensions/view.py similarity index 56% rename from superset/commands/extension/__init__.py rename to superset/extensions/view.py index 13a83393a912..c5f03afd5e39 100644 --- a/superset/commands/extension/__init__.py +++ b/superset/extensions/view.py @@ -14,3 +14,22 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from flask_appbuilder import expose +from flask_appbuilder.security.decorators import has_access, permission_name + +from superset.constants import MODEL_VIEW_RW_METHOD_PERMISSION_MAP +from superset.superset_typing import FlaskResponse +from superset.views.base import BaseSupersetView + + +class ExtensionsView(BaseSupersetView): + route_base = "/extensions" + class_permission_name = "Extensions" + method_permission_name = MODEL_VIEW_RW_METHOD_PERMISSION_MAP + + @expose("/list/") + @has_access + @permission_name("read") + def list(self) -> FlaskResponse: + return super().render_app_template() + \ No newline at end of file diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 5245dedcfd7e..738bfb22984c 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -177,6 +177,7 @@ def init_views(self) -> None: from superset.explore.api import ExploreRestApi from superset.explore.form_data.api import ExploreFormDataRestApi from superset.explore.permalink.api import ExplorePermalinkRestApi + from superset.extensions.view import ExtensionsView from superset.importexport.api import ImportExportRestApi from superset.queries.api import QueryRestApi from superset.queries.saved_queries.api import SavedQueryRestApi @@ -298,7 +299,7 @@ def init_views(self) -> None: # # Setup regular views # - app_root = appbuilder.app.config["APPLICATION_ROOT"] + app_root = current_app.config["APPLICATION_ROOT"] if app_root.endswith("/"): app_root = app_root.rstrip("/") @@ -350,7 +351,7 @@ def init_views(self) -> None: category="Security", category_label=_("Security"), menu_cond=lambda: bool( - appbuilder.app.config.get("SUPERSET_SECURITY_VIEW_MENU", True) + current_app.config.get("SUPERSET_SECURITY_VIEW_MENU", True) ), ) @@ -360,7 +361,7 @@ def init_views(self) -> None: label=_("User Registrations"), category="Security", category_label=_("Security"), - menu_cond=lambda: bool(appbuilder.app.config["AUTH_USER_REGISTRATION"]), + menu_cond=lambda: bool(current_app.config["AUTH_USER_REGISTRATION"]), ) appbuilder.add_view( @@ -370,7 +371,7 @@ def init_views(self) -> None: category="Security", category_label=_("Security"), menu_cond=lambda: bool( - appbuilder.app.config.get("SUPERSET_SECURITY_VIEW_MENU", True) + current_app.config.get("SUPERSET_SECURITY_VIEW_MENU", True) ), ) @@ -381,7 +382,7 @@ def init_views(self) -> None: category="Security", category_label=_("Security"), menu_cond=lambda: bool( - appbuilder.app.config.get("SUPERSET_SECURITY_VIEW_MENU", True) + current_app.config.get("SUPERSET_SECURITY_VIEW_MENU", True) ), ) @@ -417,6 +418,17 @@ def init_views(self) -> None: category_icon="", ) + appbuilder.add_view( + ExtensionsView, + "Extensions", + label=_("Extensions"), + category="Manage", + category_label=_("Manage"), + menu_cond=lambda: feature_flag_manager.is_feature_enabled( + "ENABLE_EXTENSIONS" + ), + ) + appbuilder.add_view( TaskModelView, "Tasks", @@ -717,6 +729,14 @@ def register_request_handlers(self) -> None: """Register app-level request handlers""" from flask import request, Response + from superset.security.password_change import ( + register_password_change_enforcement, + ) + + # Redirect users with a pending forced password change to the reset + # page (no-op unless ENABLE_FORCE_PASSWORD_CHANGE is enabled). + register_password_change_enforcement(self.superset_app) + @self.superset_app.after_request def apply_http_headers(response: Response) -> Response: """Applies the configuration's http headers to all responses""" @@ -752,6 +772,23 @@ def cleanup_analytics_memory(response: Response) -> Response: gc.collect() return response + @self.superset_app.before_request + def enforce_session_validity() -> Any: + """Force logout of sessions invalidated by a per-user epoch.""" + from superset.security.session_invalidation import ( + enforce_session_validity as _enforce, + ) + + return _enforce() + + # Stamp the per-user invalidation epoch when an account is disabled, + # so outstanding sessions are terminated on their next request. + from superset.security.session_invalidation import ( + register_session_invalidation_events, + ) + + register_session_invalidation_events(appbuilder.sm.user_model) + @self.superset_app.context_processor def get_common_bootstrap_data() -> dict[str, Any]: # Import here to avoid circular imports diff --git a/tests/unit_tests/extensions/test_api.py b/tests/unit_tests/extensions/test_api.py deleted file mode 100644 index 54dfd17cb50f..000000000000 --- a/tests/unit_tests/extensions/test_api.py +++ /dev/null @@ -1,46 +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. - -"""Unit tests for the extensions REST API.""" - -from __future__ import annotations - -from superset.extensions.api import _validate_segment - -# --------------------------------------------------------------------------- -# _validate_segment helper — used by GET /api/v1/extensions// -# and GET /api/v1/extensions/// -# --------------------------------------------------------------------------- - - -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 diff --git a/tests/unit_tests/extensions/test_types.py b/tests/unit_tests/extensions/test_types.py index db1c429236ab..52615a69ae77 100644 --- a/tests/unit_tests/extensions/test_types.py +++ b/tests/unit_tests/extensions/test_types.py @@ -44,6 +44,7 @@ def test_extension_config_minimal(): assert config.name == "my-extension" assert config.displayName == "My Extension" assert config.version == "0.0.0" + assert config.dependencies == [] assert config.permissions == [] assert config.backend is None @@ -58,6 +59,7 @@ def test_extension_config_full(): "version": "1.0.0", "license": "Apache-2.0", "description": "A query insights extension", + "dependencies": ["other-extension"], "permissions": ["can_read", "can_view"], "backend": { "files": ["backend/src/query_insights/**/*.py"], @@ -70,6 +72,7 @@ def test_extension_config_full(): assert config.version == "1.0.0" assert config.license == "Apache-2.0" assert config.description == "A query insights extension" + assert config.dependencies == ["other-extension"] assert config.permissions == ["can_read", "can_view"] assert config.backend is not None assert config.backend.files == ["backend/src/query_insights/**/*.py"] From d88a6730cd8d4ec91be305a34de9aec331303ef8 Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" <70410625+michael-s-molina@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:58:36 -0300 Subject: [PATCH 10/10] chore: Chat extension improvements (#41117) --- superset-frontend/package-lock.json | 54 ++++++++++--------- superset-frontend/src/core/chat/index.ts | 16 +++--- .../src/core/editors/EditorProviders.test.ts | 27 ---------- .../src/core/editors/EditorProviders.ts | 48 ++--------------- superset-frontend/src/core/menus/index.ts | 20 +++---- .../src/core/navigation/index.ts | 11 ++-- superset-frontend/src/core/utils.ts | 20 +++---- superset-frontend/src/core/views/index.ts | 19 +++---- .../src/extensions/ExtensionsLoader.test.ts | 1 - .../src/extensions/ExtensionsStartup.tsx | 6 ++- .../{supersetGlobal.ts => Namespaces.ts} | 4 +- superset/extensions/view.py | 1 - 12 files changed, 76 insertions(+), 151 deletions(-) rename superset-frontend/src/extensions/{supersetGlobal.ts => Namespaces.ts} (96%) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 61228bc65392..de28d3f529f1 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -8450,9 +8450,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8470,9 +8467,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8490,9 +8484,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8510,9 +8501,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8530,9 +8518,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8550,9 +8535,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8570,9 +8552,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8590,9 +8569,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -26132,6 +26108,21 @@ } } }, + "node_modules/jsdom/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/jsdom/node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -43244,6 +43235,21 @@ } } }, + "node_modules/whatwg-url/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/whatwg-url/node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/superset-frontend/src/core/chat/index.ts b/superset-frontend/src/core/chat/index.ts index 36e1b38e3ca3..b98a511286b2 100644 --- a/superset-frontend/src/core/chat/index.ts +++ b/superset-frontend/src/core/chat/index.ts @@ -41,7 +41,7 @@ import { ReactElement } from 'react'; import type { chat as chatApi } from '@apache-superset/core'; import { Disposable } from '../models'; -import { createEmitter, createEventEmitter } from '../utils'; +import { createValueEventEmitter, createEventEmitter } from '../utils'; type Chat = chatApi.Chat; type DisplayMode = chatApi.DisplayMode; @@ -90,7 +90,7 @@ const unregisterEmitter = createEventEmitter(); const openEmitter = createEventEmitter(); const closeEmitter = createEventEmitter(); const resizePanelEmitter = createEventEmitter<{ width: number }>(); -const modeEmitter = createEmitter('floating'); +const modeEmitter = createValueEventEmitter('floating'); /** * Host-internal: resolves the active chat with its providers. @@ -224,17 +224,17 @@ const setDisplayMode: typeof chatApi.setDisplayMode = ( export const chat: typeof chatApi = { registerChat, getChat, - onDidRegisterChat: registerEmitter.event, - onDidUnregisterChat: unregisterEmitter.event, + onDidRegisterChat: registerEmitter.subscribe, + onDidUnregisterChat: unregisterEmitter.subscribe, open, close, isOpen, - onDidOpen: openEmitter.event, - onDidClose: closeEmitter.event, + onDidOpen: openEmitter.subscribe, + onDidClose: closeEmitter.subscribe, getDisplayMode, setDisplayMode, - onDidChangeDisplayMode: modeEmitter.event, + onDidChangeDisplayMode: modeEmitter.subscribe, // The host fires this from its panel resizer; until that chrome exists the // event is exposed but never fires. - onDidResizePanel: resizePanelEmitter.event, + onDidResizePanel: resizePanelEmitter.subscribe, }; diff --git a/superset-frontend/src/core/editors/EditorProviders.test.ts b/superset-frontend/src/core/editors/EditorProviders.test.ts index c3d0580ef187..9f4ac0b2be2e 100644 --- a/superset-frontend/src/core/editors/EditorProviders.test.ts +++ b/superset-frontend/src/core/editors/EditorProviders.test.ts @@ -254,33 +254,6 @@ test('event listeners can be disposed', () => { expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call }); -test('handles errors in event listeners gracefully', () => { - const manager = EditorProviders.getInstance(); - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - - const errorListener = jest.fn(() => { - throw new Error('Listener error'); - }); - const successListener = jest.fn(); - - manager.onDidRegister(errorListener); - manager.onDidRegister(successListener); - - manager.registerProvider(createMockEditor(), createMockEditorComponent()); - - // Both listeners should have been called - expect(errorListener).toHaveBeenCalledTimes(1); - expect(successListener).toHaveBeenCalledTimes(1); - - // Error should have been logged - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Error in event listener:', - expect.any(Error), - ); - - consoleErrorSpy.mockRestore(); -}); - test('reset clears all providers and language mappings', () => { const manager = EditorProviders.getInstance(); diff --git a/superset-frontend/src/core/editors/EditorProviders.ts b/superset-frontend/src/core/editors/EditorProviders.ts index 2eb57c44054b..2ae7cb3d4cb1 100644 --- a/superset-frontend/src/core/editors/EditorProviders.ts +++ b/superset-frontend/src/core/editors/EditorProviders.ts @@ -19,6 +19,7 @@ import type { editors } from '@apache-superset/core'; import { Disposable } from '../models'; +import { createEventEmitter } from '../utils'; type EditorLanguage = editors.EditorLanguage; type EditorProvider = editors.EditorProvider; @@ -27,45 +28,8 @@ type EditorComponent = editors.EditorComponent; type EditorRegisteredEvent = editors.EditorRegisteredEvent; type EditorUnregisteredEvent = editors.EditorUnregisteredEvent; -/** - * Listener function type for events. - */ type Listener = (e: T) => void; -/** - * Simple event emitter for editor provider lifecycle events. - */ -class EventEmitter { - private listeners: Set> = new Set(); - - /** - * Subscribe to this event. - * @param listener The listener function to call when the event is fired. - * @returns A Disposable to unsubscribe from the event. - */ - subscribe(listener: Listener): Disposable { - this.listeners.add(listener); - return new Disposable(() => { - this.listeners.delete(listener); - }); - } - - /** - * Fire the event with the given data. - * @param data The event data to pass to listeners. - */ - fire(data: T): void { - this.listeners.forEach(listener => { - try { - listener(data); - } catch (error) { - // eslint-disable-next-line no-console - console.error('Error in event listener:', error); - } - }); - } -} - /** * Singleton manager for editor providers. * Handles registration, resolution, and lifecycle of custom editor implementations. @@ -83,15 +47,9 @@ class EditorProviders { */ private languageToProvider: Map = new Map(); - /** - * Event emitter for provider registration events. - */ - private registerEmitter = new EventEmitter(); + private registerEmitter = createEventEmitter(); - /** - * Event emitter for provider unregistration events. - */ - private unregisterEmitter = new EventEmitter(); + private unregisterEmitter = createEventEmitter(); private syncListeners: Set<() => void> = new Set(); diff --git a/superset-frontend/src/core/menus/index.ts b/superset-frontend/src/core/menus/index.ts index be2066a178b9..b11a0ff44b72 100644 --- a/superset-frontend/src/core/menus/index.ts +++ b/superset-frontend/src/core/menus/index.ts @@ -27,6 +27,7 @@ import { useSyncExternalStore } from 'react'; import type { menus as menusApi } from '@apache-superset/core'; import { Disposable } from '../models'; +import { createEventEmitter } from '../utils'; type MenuItem = menusApi.MenuItem; type Menu = menusApi.Menu; @@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => { return () => syncListeners.delete(listener); }; -const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>(); -const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>(); +const registerEmitter = createEventEmitter(); +const unregisterEmitter = createEventEmitter(); const menuCache = new Map(); const notifyRegister = (event: MenuItemRegisteredEvent) => { menuCache.clear(); syncListeners.forEach(l => l()); - registerListeners.forEach(l => l(event)); + registerEmitter.fire(event); }; const notifyUnregister = (event: MenuItemUnregisteredEvent) => { menuCache.clear(); syncListeners.forEach(l => l()); - unregisterListeners.forEach(l => l(event)); + unregisterEmitter.fire(event); }; const registerMenuItem: typeof menusApi.registerMenuItem = ( @@ -117,16 +118,11 @@ export const useMenu = (location: string): Menu | undefined => export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = ( listener: (e: MenuItemRegisteredEvent) => void, -): Disposable => { - registerListeners.add(listener); - return new Disposable(() => registerListeners.delete(listener)); -}; +): Disposable => registerEmitter.subscribe(listener); export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem = - (listener: (e: MenuItemUnregisteredEvent) => void): Disposable => { - unregisterListeners.add(listener); - return new Disposable(() => unregisterListeners.delete(listener)); - }; + (listener: (e: MenuItemUnregisteredEvent) => void): Disposable => + unregisterEmitter.subscribe(listener); export const menus: typeof menusApi = { registerMenuItem, diff --git a/superset-frontend/src/core/navigation/index.ts b/superset-frontend/src/core/navigation/index.ts index f654ef412231..f670a1a45f04 100644 --- a/superset-frontend/src/core/navigation/index.ts +++ b/superset-frontend/src/core/navigation/index.ts @@ -26,10 +26,11 @@ import type { navigation as navigationApi } from '@apache-superset/core'; import { Disposable } from '../models'; +import { createEventEmitter } from '../utils'; type Page = navigationApi.Page; -const listeners = new Set<(page: Page) => void>(); +const pageChangeEmitter = createEventEmitter(); function derivePage(pathname: string): Page { if (pathname.startsWith('/superset/dashboard/')) return 'dashboard'; @@ -62,7 +63,7 @@ export const notifyPageChange = (pathname: string): void => { const next = derivePage(pathname); if (next === getOrInitPage()) return; currentPage = next; - listeners.forEach(fn => fn(next)); + pageChangeEmitter.fire(next); }; const getPage: typeof navigationApi.getPage = () => getOrInitPage(); @@ -70,11 +71,7 @@ const getPage: typeof navigationApi.getPage = () => getOrInitPage(); const onDidChangePage: typeof navigationApi.onDidChangePage = ( listener: (page: Page) => void, thisArgs?: any, -): Disposable => { - const bound = thisArgs ? listener.bind(thisArgs) : listener; - listeners.add(bound); - return new Disposable(() => listeners.delete(bound)); -}; +): Disposable => pageChangeEmitter.subscribe(listener, thisArgs); export const navigation: typeof navigationApi = { getPage, diff --git a/superset-frontend/src/core/utils.ts b/superset-frontend/src/core/utils.ts index eb958a0e119e..00c675523f1a 100644 --- a/superset-frontend/src/core/utils.ts +++ b/superset-frontend/src/core/utils.ts @@ -27,12 +27,12 @@ type Listener = (e: T) => unknown; export interface EventEmitter { /** Notifies every current subscriber with `value`. */ fire(value: T): void; - /** The public {@link core.Event} used to subscribe to this emitter. */ - event: core.Event; + /** Registers a listener; returns a Disposable that removes it. */ + subscribe: core.Event; } -/** A stateful emitter that also retains the last fired value. */ -export interface Emitter extends EventEmitter { +/** An event emitter that also retains the last fired value. */ +export interface ValueEventEmitter extends EventEmitter { /** Returns the value last passed to {@link fire} (or the initial value). */ getCurrent(): T; } @@ -43,31 +43,31 @@ export interface Emitter extends EventEmitter { */ export function createEventEmitter(): EventEmitter { const listeners = new Set>(); - const event: core.Event = (listener, thisArgs) => { + const subscribe: core.Event = (listener, thisArgs) => { const bound = thisArgs ? listener.bind(thisArgs) : listener; listeners.add(bound); return { dispose: () => listeners.delete(bound) }; }; return { fire: value => listeners.forEach(fn => fn(value)), - event, + subscribe, }; } /** - * Creates a stateful emitter seeded with `initial`. Behaves like + * Creates a value event emitter seeded with `initial`. Behaves like * {@link createEventEmitter} but also tracks the last fired value, readable * via `getCurrent` — useful for state that is both observed and queried. */ -export function createEmitter(initial: T): Emitter { - const { fire, event } = createEventEmitter(); +export function createValueEventEmitter(initial: T): ValueEventEmitter { + const { fire, subscribe } = createEventEmitter(); let current = initial; return { fire: value => { current = value; fire(value); }, - event, + subscribe, getCurrent: () => current, }; } diff --git a/superset-frontend/src/core/views/index.ts b/superset-frontend/src/core/views/index.ts index 3e8f775993e6..a28cfc4c37bf 100644 --- a/superset-frontend/src/core/views/index.ts +++ b/superset-frontend/src/core/views/index.ts @@ -29,6 +29,7 @@ import type { views as viewsApi } from '@apache-superset/core'; import { ErrorBoundary } from 'src/components/ErrorBoundary'; import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder'; import { Disposable } from '../models'; +import { createEventEmitter } from '../utils'; type View = viewsApi.View; type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent; @@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => { return () => syncListeners.delete(listener); }; -const registerListeners = new Set<(e: ViewRegisteredEvent) => void>(); -const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>(); +const registerEmitter = createEventEmitter(); +const unregisterEmitter = createEventEmitter(); const viewsCache = new Map(); const notifyRegister = (event: ViewRegisteredEvent) => { viewsCache.clear(); syncListeners.forEach(l => l()); - registerListeners.forEach(l => l(event)); + registerEmitter.fire(event); }; const notifyUnregister = (event: ViewUnregisteredEvent) => { viewsCache.clear(); syncListeners.forEach(l => l()); - unregisterListeners.forEach(l => l(event)); + unregisterEmitter.fire(event); }; const registerView: typeof viewsApi.registerView = ( @@ -116,17 +117,11 @@ export const useViews = (location: string): View[] | undefined => export const onDidRegisterView: typeof viewsApi.onDidRegisterView = ( listener: (e: ViewRegisteredEvent) => void, -): Disposable => { - registerListeners.add(listener); - return new Disposable(() => registerListeners.delete(listener)); -}; +): Disposable => registerEmitter.subscribe(listener); export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = ( listener: (e: ViewUnregisteredEvent) => void, -): Disposable => { - unregisterListeners.add(listener); - return new Disposable(() => unregisterListeners.delete(listener)); -}; +): Disposable => unregisterEmitter.subscribe(listener); export const views: typeof viewsApi = { registerView, diff --git a/superset-frontend/src/extensions/ExtensionsLoader.test.ts b/superset-frontend/src/extensions/ExtensionsLoader.test.ts index 1d2493671ad1..d2debd6d69c1 100644 --- a/superset-frontend/src/extensions/ExtensionsLoader.test.ts +++ b/superset-frontend/src/extensions/ExtensionsLoader.test.ts @@ -31,7 +31,6 @@ function createMockExtension(overrides: Partial = {}): Extension { version: '1.0.0', dependencies: [], remoteEntry: '', - extensionDependencies: [], ...overrides, }; } diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx index 03df91789269..1e972bdc6535 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -20,6 +20,8 @@ import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; import { logging } from '@apache-superset/core/utils'; import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; +// eslint-disable-next-line no-restricted-syntax +import * as supersetCore from '@apache-superset/core'; import { authentication, chat, @@ -36,8 +38,7 @@ import { notifyPageChange } from 'src/core/navigation'; import { useSelector } from 'react-redux'; import { RootState } from 'src/views/store'; import ExtensionsLoader from './ExtensionsLoader'; -// Side-effect import: brings the `window.superset` global augmentation into scope. -import 'src/extensions/supersetGlobal'; +import 'src/extensions/Namespaces'; const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ children, @@ -78,6 +79,7 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ // 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, chat, core, diff --git a/superset-frontend/src/extensions/supersetGlobal.ts b/superset-frontend/src/extensions/Namespaces.ts similarity index 96% rename from superset-frontend/src/extensions/supersetGlobal.ts rename to superset-frontend/src/extensions/Namespaces.ts index 2025c0d08dd0..0e7f6a3063b2 100644 --- a/superset-frontend/src/extensions/supersetGlobal.ts +++ b/superset-frontend/src/extensions/Namespaces.ts @@ -40,7 +40,7 @@ import type { } from 'src/core'; /** The host namespaces exposed to extensions on `window.superset`. */ -export interface SupersetGlobal { +export interface Namespaces { authentication: typeof authentication; core: typeof core; chat: typeof chat; @@ -55,6 +55,6 @@ export interface SupersetGlobal { declare global { interface Window { - superset: SupersetGlobal; + superset: Namespaces; } } diff --git a/superset/extensions/view.py b/superset/extensions/view.py index c5f03afd5e39..b3699163b68c 100644 --- a/superset/extensions/view.py +++ b/superset/extensions/view.py @@ -32,4 +32,3 @@ class ExtensionsView(BaseSupersetView): @permission_name("read") def list(self) -> FlaskResponse: return super().render_app_template() - \ No newline at end of file