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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 From bc3d0af77aa1d8871b30493d763905e787e6dbdc Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" Date: Thu, 18 Jun 2026 13:17:41 -0300 Subject: [PATCH 11/14] feat(extensions): add Chat contribution type (SIP-214) --- .../extensions/contribution-types.md | 23 +- .../extensions/extension-points/chat.md | 140 +++++++++ docs/developer_docs/sidebars.js | 2 + .../packages/superset-core/src/chat/index.ts | 78 ++--- .../packages/superset-core/src/views/index.ts | 10 +- .../src/components/ChatMount/index.tsx | 149 --------- .../chat/ChatHost.test.tsx} | 238 +++++++------- superset-frontend/src/core/chat/ChatHost.tsx | 133 ++++++++ .../src/core/chat/ChatProvider.test.ts | 257 ++++++++++++++++ .../src/core/chat/ChatProvider.ts | 203 ++++++++++++ superset-frontend/src/core/chat/index.test.ts | 291 +----------------- superset-frontend/src/core/chat/index.ts | 254 +++------------ superset-frontend/src/core/editors/index.ts | 135 ++------ .../src/core/navigation/index.test.ts | 58 ++-- .../src/core/navigation/index.ts | 81 +++-- superset-frontend/src/core/views/index.ts | 20 +- .../src/extensions/ExtensionsStartup.tsx | 16 +- .../src/utils/localStorageHelpers.ts | 2 + superset-frontend/src/views/App.tsx | 167 +++++++--- superset-frontend/src/views/routePaths.ts | 60 ++++ superset-frontend/src/views/routes.tsx | 208 +++---------- 21 files changed, 1315 insertions(+), 1210 deletions(-) create mode 100644 docs/developer_docs/extensions/extension-points/chat.md delete mode 100644 superset-frontend/src/components/ChatMount/index.tsx rename superset-frontend/src/{components/ChatMount/ChatMount.test.tsx => core/chat/ChatHost.test.tsx} (56%) create mode 100644 superset-frontend/src/core/chat/ChatHost.tsx create mode 100644 superset-frontend/src/core/chat/ChatProvider.test.ts create mode 100644 superset-frontend/src/core/chat/ChatProvider.ts create mode 100644 superset-frontend/src/views/routePaths.ts diff --git a/docs/developer_docs/extensions/contribution-types.md b/docs/developer_docs/extensions/contribution-types.md index e765c5009c40..f339fc01949c 100644 --- a/docs/developer_docs/extensions/contribution-types.md +++ b/docs/developer_docs/extensions/contribution-types.md @@ -34,15 +34,14 @@ Frontend contribution types allow extensions to extend Superset's user interface Extensions can add new views or panels to the host application, such as custom SQL Lab panels, dashboards, or other UI components. Contribution areas are uniquely identified (e.g., `sqllab.panels` for SQL Lab panels), enabling seamless integration into specific parts of the application. -```tsx -import React from 'react'; +```typescript import { views } from '@apache-superset/core'; import MyPanel from './MyPanel'; views.registerView( { id: 'my-extension.main', name: 'My Panel Name' }, 'sqllab.panels', - () => , + MyPanel, ); ``` @@ -112,6 +111,24 @@ editors.registerEditor( See [Editors Extension Point](./extension-points/editors.md) for implementation details. +### Chat + +Extensions can add a chat interface to Superset by registering a trigger component and a panel component. The host owns the layout, open/close state, and display mode — the extension only provides the UI. The panel can be displayed as a floating overlay or docked as a resizable sidebar beside the page content, and the user's preference is persisted across reloads. + +```tsx +import { chat } from '@apache-superset/core'; +import ChatTrigger from './ChatTrigger'; +import ChatPanel from './ChatPanel'; + +chat.registerChat( + { id: 'my-org.my-chat', name: 'My Chat' }, + ChatTrigger, + ChatPanel, +); +``` + +See [Chat](./extension-points/chat.md) for implementation details. + ## Backend Backend contribution types allow extensions to extend Superset's server-side capabilities. Backend contributions are registered at startup via classes and functions imported from the auto-discovered `entrypoint.py` file. diff --git a/docs/developer_docs/extensions/extension-points/chat.md b/docs/developer_docs/extensions/extension-points/chat.md new file mode 100644 index 000000000000..42ce77a6eec1 --- /dev/null +++ b/docs/developer_docs/extensions/extension-points/chat.md @@ -0,0 +1,140 @@ +--- +title: Chat +sidebar_position: 3 +--- + + + +# Chat Contributions + +Extensions can add a chat interface to Superset by registering a trigger and a panel. The host owns the layout, open/close state, and display mode — the extension only needs to provide the UI components. + +## Overview + +A chat registration consists of two React components: + +| Component | Role | +|-----------|------| +| **Trigger** | Always-visible entry point (e.g., a floating button). Rendered in the bottom-right corner in floating mode, or as a fixed overlay in panel mode. | +| **Panel** | The chat UI itself (message list, input, etc.). Mounted by the host in the active display mode. | + +## Display Modes + +The host supports two display modes, switchable by the user or the extension at runtime: + +| Mode | Behavior | +|------|----------| +| `floating` | Panel floats above page content, anchored to the bottom-right corner. | +| `panel` | Panel is docked to the right side of the application as a resizable sidebar, sitting beside the page content. | + +The user's last selected mode and open/closed state are persisted across page reloads. + +## Registering a Chat + +Call `chat.registerChat` from your extension's entry point with a descriptor, a trigger factory, and a panel factory: + +```tsx +import { chat } from '@apache-superset/core'; +import ChatTrigger from './ChatTrigger'; +import ChatPanel from './ChatPanel'; + +chat.registerChat( + { id: 'my-org.my-chat', name: 'My Chat' }, + ChatTrigger, + ChatPanel, +); +``` + +Only one chat registration is active at a time. If a second extension calls `registerChat`, it replaces the first and a warning is logged. + +## Opening and Closing the Chat + +The trigger component is responsible for toggling the panel. Use `chat.isOpen()`, `chat.open()`, and `chat.close()` to control visibility: + +```tsx +import { chat } from '@apache-superset/core'; + +export default function ChatTrigger() { + return ( + + ); +} +``` + +You can also subscribe to open/close events from any component: + +```tsx +useEffect(() => { + const { dispose } = chat.onDidOpen(() => console.log('chat opened')); + return dispose; +}, []); +``` + +## Changing the Display Mode + +Call `chat.setDisplayMode` to switch between `'floating'` and `'panel'` modes. In your panel component, subscribe to `onDidChangeDisplayMode` to react to changes (including those triggered by the user): + +```tsx +import { useState, useEffect } from 'react'; +import { chat } from '@apache-superset/core'; + +export default function ChatPanel() { + const [mode, setMode] = useState(chat.getDisplayMode()); + + useEffect(() => { + const { dispose } = chat.onDidChangeDisplayMode(m => setMode(m)); + return dispose; + }, []); + + return ( +
+ + {/* message list and input */} +
+ ); +} +``` + +## Chat API Reference + +All methods are available on the `chat` namespace from `@apache-superset/core`: + +| Method / Event | Description | +|----------------|-------------| +| `registerChat(descriptor, trigger, panel)` | Register a chat extension. Returns a `Disposable` to unregister. | +| `open()` | Open the chat panel. No-op if already open or no registration. | +| `close()` | Close the chat panel. | +| `isOpen()` | Returns `true` if the panel is currently open. | +| `getDisplayMode()` | Returns the current display mode (`'floating'` or `'panel'`). | +| `setDisplayMode(mode)` | Switch between `'floating'` and `'panel'` mode. | +| `onDidOpen(listener)` | Subscribe to panel open events. Returns a `Disposable`. | +| `onDidClose(listener)` | Subscribe to panel close events. Returns a `Disposable`. | +| `onDidChangeDisplayMode(listener)` | Subscribe to display mode changes. Returns a `Disposable`. | +| `onDidRegisterChat(listener)` | Subscribe to registration events. | +| `onDidUnregisterChat(listener)` | Subscribe to unregistration events. | + +## Next Steps + +- **[Contribution Types](../contribution-types.md)** — Explore other contribution types +- **[Development](../development.md)** — Set up your development environment diff --git a/docs/developer_docs/sidebars.js b/docs/developer_docs/sidebars.js index 7926d80cf615..fe03dac7b8f2 100644 --- a/docs/developer_docs/sidebars.js +++ b/docs/developer_docs/sidebars.js @@ -47,6 +47,8 @@ module.exports = { collapsed: true, items: [ 'extensions/extension-points/sqllab', + 'extensions/extension-points/editors', + 'extensions/extension-points/chat', ], }, 'extensions/development', diff --git a/superset-frontend/packages/superset-core/src/chat/index.ts b/superset-frontend/packages/superset-core/src/chat/index.ts index 92f65b70ee46..ad91761f150d 100644 --- a/superset-frontend/packages/superset-core/src/chat/index.ts +++ b/superset-frontend/packages/superset-core/src/chat/index.ts @@ -20,7 +20,7 @@ /** * @fileoverview Chat contribution API for Superset extensions. * - * Chat is a dedicated contribution type (not a view): an extension registers + * Chat is a dedicated contribution type: 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. @@ -31,13 +31,13 @@ * * chat.registerChat( * { id: 'acme.chat', name: 'Acme Chat' }, - * () => , - * () => , + * AcmeTrigger, + * AcmePanel, * ); * ``` */ -import { ReactElement } from 'react'; +import { ComponentType } from 'react'; import type { Disposable, Event } from '../common'; export interface Chat { @@ -45,55 +45,42 @@ export interface Chat { id: string; /** The display name of the chat. */ name: string; - /** Optional description of the chat, for display in contribution manifests. */ + /** Optional description of the chat. */ description?: string; } export type DisplayMode = '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. + * Registers a chat provider. Only one chat is active at a time; the most + * recently registered chat wins. 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. + * @param trigger The trigger component — the collapsed bubble entry point. + * Owns dynamic state such as unread counts. + * @param panel The panel component, rendered in either display mode. In + * 'floating' mode it appears as an overlay; in 'panel' mode it is docked + * alongside the main content. * @returns A Disposable that unregisters the chat when disposed. * * @example * ```typescript * chat.registerChat( * { id: 'acme.chat', name: 'Acme Chat' }, - * () => , - * () => , + * AcmeTrigger, + * AcmePanel, * ); * ``` */ export declare function registerChat( chat: Chat, - trigger: () => ReactElement, - panel: () => ReactElement, + trigger: ComponentType, + panel: ComponentType, ): 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. + * Returns the active chat descriptor, or undefined if none is registered. */ export declare function getChat(): Chat | undefined; @@ -125,8 +112,6 @@ 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; @@ -137,28 +122,20 @@ export declare function isOpen(): boolean; export declare const onDidOpen: Event; /** - * 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. + * Event fired when the chat panel closes, whether triggered by an extension + * or by the host. */ export declare const onDidClose: Event; /** * Returns the current display mode. - * - * @returns The current DisplayMode. */ 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 onDidChangeDisplayMode to observe all changes rather than - * assuming the last setDisplayMode() call won. - * - * @param displayMode The display mode to switch to. + * Sets the display mode. The mode is host-global and applies to whichever + * chat is active. Use {@link onDidChangeDisplayMode} to observe all changes, + * including those triggered by the host. */ export declare function setDisplayMode(displayMode: DisplayMode): void; @@ -169,13 +146,8 @@ export declare function setDisplayMode(displayMode: DisplayMode): void; export declare const onDidChangeDisplayMode: 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. + * Event fired when the panel is resized in panel mode. Not all hosts provide + * a resizer — do not rely on this event firing. */ export declare const onDidResizePanel: Event<{ width: number }>; diff --git a/superset-frontend/packages/superset-core/src/views/index.ts b/superset-frontend/packages/superset-core/src/views/index.ts index df5990046510..b3d57babe39e 100644 --- a/superset-frontend/packages/superset-core/src/views/index.ts +++ b/superset-frontend/packages/superset-core/src/views/index.ts @@ -30,12 +30,12 @@ * * views.registerView( * { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' }, - * () => , + * ResultStatsPanel, * ); * ``` */ -import { ReactElement } from 'react'; +import { ComponentType } from 'react'; import { Disposable, Event } from '../common'; /** @@ -58,7 +58,7 @@ export interface View { * * @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. + * @param component The React component to render at that location. * @returns A Disposable that unregisters the view when disposed. * * @example @@ -66,14 +66,14 @@ export interface View { * views.registerView( * { id: 'my_ext.result_stats', name: 'Result Stats' }, * 'sqllab.panels', - * () => , + * ResultStatsPanel, * ); * ``` */ export declare function registerView( view: View, location: string, - provider: () => ReactElement, + component: ComponentType, ): Disposable; /** diff --git a/superset-frontend/src/components/ChatMount/index.tsx b/superset-frontend/src/components/ChatMount/index.tsx deleted file mode 100644 index 4f9c96287381..000000000000 --- a/superset-frontend/src/components/ChatMount/index.tsx +++ /dev/null @@ -1,149 +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, 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/ChatMount/ChatMount.test.tsx b/superset-frontend/src/core/chat/ChatHost.test.tsx similarity index 56% rename from superset-frontend/src/components/ChatMount/ChatMount.test.tsx rename to superset-frontend/src/core/chat/ChatHost.test.tsx index 9a940baa80f9..aa4d569c70e6 100644 --- a/superset-frontend/src/components/ChatMount/ChatMount.test.tsx +++ b/superset-frontend/src/core/chat/ChatHost.test.tsx @@ -18,36 +18,27 @@ */ import { act, render, screen } from 'spec/helpers/testing-library'; import { chat } from 'src/core/chat'; -import ChatMount from '.'; +import ChatProvider from './ChatProvider'; +import { ChatFloatingHost as ChatHost, ChatPanelHost } from './ChatHost'; -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.setDisplayMode('floating'); - }); +beforeEach(() => { + ChatProvider.getInstance().reset(); }); test('renders nothing when no chat extension is registered', () => { - render(); + 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
, - ), + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, ); - render(); + render(); expect(screen.getByTestId('chat-mount')).toBeInTheDocument(); expect(screen.getByText('Acme Bubble')).toBeInTheDocument(); @@ -56,15 +47,13 @@ test('renders the trigger bubble of the registered chat', () => { }); 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
, - ), + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, ); - render(); + render(); act(() => chat.open()); @@ -78,20 +67,21 @@ test('mounts the panel when the chat opens and unmounts it on close', () => { }); 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
, - ), + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + 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(); + jest.restoreAllMocks(); + render(); // Last-loaded wins: the second registration takes over the singleton slot. expect(screen.getByText('Second Bubble')).toBeInTheDocument(); @@ -99,17 +89,15 @@ test('renders the last-registered chat when several are installed', () => { }); test('reacts to a chat registering after the initial render', () => { - render(); + render(); expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument(); act(() => { - disposables.push( - chat.registerChat( - { id: 'acme.chat', name: 'Acme Chat' }, - () => , - () =>
Acme Panel
, - ), + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, ); }); @@ -117,26 +105,24 @@ test('reacts to a chat registering after the initial render', () => { }); test('a takeover mounts the incoming chat closed', () => { - disposables.push( - chat.registerChat( - { id: 'first.chat', name: 'First Chat' }, - () =>
First Bubble
, - () =>
First Panel
, - ), + chat.registerChat( + { id: 'first.chat', name: 'First Chat' }, + () =>
First Bubble
, + () =>
First Panel
, ); - render(); + 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
, - ), + jest.spyOn(console, 'warn').mockImplementation(() => {}); + chat.registerChat( + { id: 'second.chat', name: 'Second Chat' }, + () =>
Second Bubble
, + () =>
Second Panel
, ); + jest.restoreAllMocks(); }); // The displaced chat's open state must not leak into the winner. @@ -145,16 +131,14 @@ test('a takeover mounts the incoming chat closed', () => { 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
, - ), +test('ChatPanelHost renders the panel when open in panel mode', () => { + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, ); - render(); + render(); act(() => { chat.setDisplayMode('panel'); @@ -162,27 +146,44 @@ test('panel mode docks the open panel and hides the trigger', () => { }); expect(screen.getByText('Acme Panel')).toBeInTheDocument(); - expect(screen.queryByText('Acme Bubble')).not.toBeInTheDocument(); +}); + +test('ChatFloatingHost suppresses the floating panel in panel mode but keeps the trigger', () => { + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, + ); + + render(); + + act(() => { + chat.setDisplayMode('panel'); + chat.open(); + }); + + // In panel mode the floating panel is suppressed (ChatPanelHost owns that slot). + expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument(); + // The trigger stays rendered so the user can reopen after collapsing. + expect(screen.getByText('Acme Bubble')).toBeInTheDocument(); act(() => chat.close()); - // A closed chat in panel mode renders nothing — the trigger is hidden too. - expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument(); + // Trigger remains visible even when closed — it's the user's only way back. + expect(screen.getByText('Acme Bubble')).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' }, - () => , - () => , - ), + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () => , ); - render(); + render(); act(() => chat.open()); // The panel's boundary contains the crash; the trigger keeps rendering so @@ -195,35 +196,29 @@ 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
, - ), + 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(); + 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
, - ), +test('isolates a component that throws during render', () => { + 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(() => render()).not.toThrow(); expect(screen.getByTestId('chat-mount')).toBeInTheDocument(); }); @@ -231,25 +226,23 @@ 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
, - ), + chat.registerChat( + { id: 'first.chat', name: 'First Chat' }, + () => , + () =>
First Panel
, ); - render(); + render(); expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument(); act(() => { - disposables.push( - chat.registerChat( - { id: 'second.chat', name: 'Second Chat' }, - () =>
Second Bubble
, - () =>
Second Panel
, - ), + jest.spyOn(console, 'warn').mockImplementation(() => {}); + chat.registerChat( + { id: 'second.chat', name: 'Second Chat' }, + () =>
Second Bubble
, + () =>
Second Panel
, ); + jest.restoreAllMocks(); }); // The boundary is keyed per registration, so the latched crash from the @@ -257,31 +250,28 @@ test('recovers from a crashed chat when a different chat takes over', () => { expect(screen.getByText('Second Bubble')).toBeInTheDocument(); }); -test('recovers when a crashed chat re-registers a fixed version under the same id', () => { +test('recovers from a crashed chat when a different id takes over', () => { const FailingTrigger = () => { throw new Error('broken release'); }; - disposables.push( - chat.registerChat( - { id: 'acme.chat', name: 'Acme Chat' }, - () => , - () =>
Acme Panel
, - ), + chat.registerChat( + { id: 'acme.chat', name: 'Acme Chat' }, + () => , + () =>
Acme Panel
, ); - render(); - expect(screen.queryByText('Fixed Bubble')).not.toBeInTheDocument(); + render(); act(() => { - disposables.push( - chat.registerChat( - { id: 'acme.chat', name: 'Acme Chat' }, - () =>
Fixed Bubble
, - () =>
Acme Panel
, - ), + jest.spyOn(console, 'warn').mockImplementation(() => {}); + chat.registerChat( + { id: 'fixed.chat', name: 'Fixed Chat' }, + () =>
Fixed Bubble
, + () =>
Fixed Panel
, ); + jest.restoreAllMocks(); }); - // Same id, new registrationId: the remounted boundary renders the fix. + // Different id: boundary key changes, latch resets, fix renders. expect(screen.getByText('Fixed Bubble')).toBeInTheDocument(); }); diff --git a/superset-frontend/src/core/chat/ChatHost.tsx b/superset-frontend/src/core/chat/ChatHost.tsx new file mode 100644 index 000000000000..73ba519f694d --- /dev/null +++ b/superset-frontend/src/core/chat/ChatHost.tsx @@ -0,0 +1,133 @@ +/** + * 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 ComponentType, useRef } 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 { useChat } from '.'; + +const CHAT_EDGE_MARGIN = 24; + +/** + * Returns an onError handler that shows a toast on crash, once per chat id. + */ +function useCrashNotifier(chatId: string | undefined) { + const notifiedFor = useRef(undefined); + return (error: Error) => { + if (!chatId) return; + logging.error('[chat] provider crashed', error); + if (notifiedFor.current !== chatId) { + notifiedFor.current = chatId; + store.dispatch(addDangerToast(t('The chat failed to load.'))); + } + }; +} + +/** + * Wraps a component in an ErrorBoundary, keyed by chat id so the boundary + * resets when a different chat takes over. + */ +const ChatBoundary = ({ + component: Component, + onError, +}: { + component: ComponentType; + onError: (error: Error) => void; +}) => ( + + + +); + +/** + * Renders the chat panel content in panel mode. Fills its container height. + */ +export const ChatPanelHost = () => { + const { chat, panel } = useChat(); + const onError = useCrashNotifier(chat?.id); + + if (!chat || !panel) { + return null; + } + + return ( +
+ +
+ ); +}; + +/** + * Renders the chat trigger and, when the panel is open in floating mode, the + * floating panel overlay. The trigger is always visible when a chat is + * registered; the panel overlay is suppressed in panel mode. + */ +export const ChatFloatingHost = () => { + const theme = useTheme(); + const { open: panelOpen, mode, chat, trigger, panel } = useChat(); + const onError = useCrashNotifier(chat?.id); + + if (!chat || !trigger || !panel) { + return null; + } + + return ( +
+ {/* + Separate boundaries so a crashing panel cannot take the trigger down + with it — the trigger is the user's only way back. + */} + {panelOpen && mode !== 'panel' && ( + + )} + +
+ ); +}; diff --git a/superset-frontend/src/core/chat/ChatProvider.test.ts b/superset-frontend/src/core/chat/ChatProvider.test.ts new file mode 100644 index 000000000000..392f50a114f8 --- /dev/null +++ b/superset-frontend/src/core/chat/ChatProvider.test.ts @@ -0,0 +1,257 @@ +/** + * 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 ChatProvider from './ChatProvider'; + +const trigger = () => createElement('button', null, 'Bubble'); +const panel = () => createElement('div', null, 'Panel'); + +beforeEach(() => { + ChatProvider.getInstance().reset(); +}); + +test('returns the singleton instance', () => { + expect(ChatProvider.getInstance()).toBe(ChatProvider.getInstance()); +}); + +test('getChat returns undefined when no chat is registered', () => { + expect(ChatProvider.getInstance().getChat()).toBeUndefined(); +}); + +test('registerChat sets the registration and returns the descriptor copy', () => { + const provider = ChatProvider.getInstance(); + const descriptor = { id: 'acme.chat', name: 'Acme Chat' }; + const disposable = provider.registerChat(descriptor, trigger, panel); + + expect(provider.getChat()).toEqual(descriptor); + disposable.dispose(); +}); + +test('the last-registered chat wins and logs a warning', () => { + const provider = ChatProvider.getInstance(); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel); + provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel); + + expect(provider.getChat()?.id).toBe('second.chat'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('second.chat'); + expect(warn.mock.calls[0][0]).toContain('first.chat'); + warn.mockRestore(); +}); + +test('re-registering with a different id replaces the active chat', () => { + const provider = ChatProvider.getInstance(); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel); + expect(provider.getChat()?.id).toBe('first.chat'); + + provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel); + expect(provider.getChat()?.id).toBe('second.chat'); + + jest.restoreAllMocks(); +}); + +test('disposing the registration clears it', () => { + const provider = ChatProvider.getInstance(); + const disposable = provider.registerChat( + { id: 'acme.chat', name: 'Acme' }, + trigger, + panel, + ); + + disposable.dispose(); + + expect(provider.getChat()).toBeUndefined(); +}); + +test('disposing twice fires unregister only once', () => { + const provider = ChatProvider.getInstance(); + const unregistered = jest.fn(); + provider.onDidUnregisterChat(unregistered); + + const disposable = provider.registerChat( + { id: 'acme.chat', name: 'Acme' }, + trigger, + panel, + ); + disposable.dispose(); + disposable.dispose(); + + expect(unregistered).toHaveBeenCalledTimes(1); +}); + +test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => { + const provider = ChatProvider.getInstance(); + const registered = jest.fn(); + const unregistered = jest.fn(); + provider.onDidRegisterChat(registered); + provider.onDidUnregisterChat(unregistered); + + const descriptor = { id: 'acme.chat', name: 'Acme' }; + const disposable = provider.registerChat(descriptor, trigger, panel); + + expect(registered).toHaveBeenCalledWith(descriptor); + expect(unregistered).not.toHaveBeenCalled(); + + disposable.dispose(); + + expect(unregistered).toHaveBeenCalledWith(descriptor); +}); + +test('open and close toggle the panel state', () => { + const provider = ChatProvider.getInstance(); + provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel); + + expect(provider.isOpen()).toBe(false); + + provider.open(); + expect(provider.isOpen()).toBe(true); + + provider.close(); + expect(provider.isOpen()).toBe(false); +}); + +test('open fires once; duplicate open is a no-op', () => { + const provider = ChatProvider.getInstance(); + const opened = jest.fn(); + provider.onDidOpen(opened); + provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel); + + provider.open(); + provider.open(); + + expect(opened).toHaveBeenCalledTimes(1); +}); + +test('close fires once; duplicate close is a no-op', () => { + const provider = ChatProvider.getInstance(); + const closed = jest.fn(); + provider.onDidClose(closed); + provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel); + + provider.open(); + provider.close(); + provider.close(); + + expect(closed).toHaveBeenCalledTimes(1); +}); + +test('open is a no-op when no chat is registered', () => { + const provider = ChatProvider.getInstance(); + const opened = jest.fn(); + provider.onDidOpen(opened); + + provider.open(); + + expect(provider.isOpen()).toBe(false); + expect(opened).not.toHaveBeenCalled(); +}); + +test('registering a second chat while open closes the panel', () => { + const provider = ChatProvider.getInstance(); + const closed = jest.fn(); + provider.onDidClose(closed); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + provider.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel); + provider.open(); + provider.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel); + + expect(provider.isOpen()).toBe(false); + expect(closed).toHaveBeenCalledTimes(1); + jest.restoreAllMocks(); +}); + +test('disposing the active chat while open closes the panel', () => { + const provider = ChatProvider.getInstance(); + const closed = jest.fn(); + provider.onDidClose(closed); + + const disposable = provider.registerChat( + { id: 'acme.chat', name: 'Acme' }, + trigger, + panel, + ); + provider.open(); + disposable.dispose(); + + expect(provider.isOpen()).toBe(false); + expect(closed).toHaveBeenCalledTimes(1); +}); + +test('a late registration does not inherit a stale open state', () => { + const provider = ChatProvider.getInstance(); + const disposable = provider.registerChat( + { id: 'acme.chat', name: 'Acme' }, + trigger, + panel, + ); + provider.open(); + disposable.dispose(); + + provider.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel); + + expect(provider.isOpen()).toBe(false); +}); + +test('getDisplayMode defaults to floating', () => { + expect(ChatProvider.getInstance().getDisplayMode()).toBe('floating'); +}); + +test('setDisplayMode updates mode and fires event only on change', () => { + const provider = ChatProvider.getInstance(); + const modeChanged = jest.fn(); + provider.onDidChangeDisplayMode(modeChanged); + + provider.setDisplayMode('floating'); + expect(modeChanged).not.toHaveBeenCalled(); + + provider.setDisplayMode('panel'); + expect(provider.getDisplayMode()).toBe('panel'); + expect(modeChanged).toHaveBeenCalledWith('panel'); +}); + +test('state reflects changes after registration and open', () => { + const provider = ChatProvider.getInstance(); + + expect(provider.getChat()).toBeUndefined(); + expect(provider.isOpen()).toBe(false); + + provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel); + provider.open(); + + expect(provider.isOpen()).toBe(true); + expect(provider.getChat()?.id).toBe('acme.chat'); +}); + +test('reset clears all state', () => { + const provider = ChatProvider.getInstance(); + provider.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel); + provider.open(); + provider.setDisplayMode('panel'); + + provider.reset(); + + expect(provider.getChat()).toBeUndefined(); + expect(provider.isOpen()).toBe(false); + expect(provider.getDisplayMode()).toBe('floating'); +}); diff --git a/superset-frontend/src/core/chat/ChatProvider.ts b/superset-frontend/src/core/chat/ChatProvider.ts new file mode 100644 index 000000000000..81e5c65a0767 --- /dev/null +++ b/superset-frontend/src/core/chat/ChatProvider.ts @@ -0,0 +1,203 @@ +/** + * 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 { ComponentType } from 'react'; +import type { chat as chatApi } from '@apache-superset/core'; +import { + LocalStorageKeys, + getItem, + setItem, +} from 'src/utils/localStorageHelpers'; +import { Disposable } from '../models'; +import { createValueEventEmitter, createEventEmitter } from '../utils'; + +type Chat = chatApi.Chat; +type DisplayMode = chatApi.DisplayMode; + +/** + * Singleton manager for the chat provider. + * Handles registration, open/close state, and display mode. + */ +class ChatProvider { + private static instance: ChatProvider; + + private chat: Chat | undefined; + + private trigger: ComponentType | undefined; + + private panel: ComponentType | undefined; + + private opened: boolean; + + private stateSubscribers = new Set<() => void>(); + + private registerEmitter = createEventEmitter(); + + private unregisterEmitter = createEventEmitter(); + + private openEmitter = createEventEmitter(); + + private closeEmitter = createEventEmitter(); + + private resizePanelEmitter = createEventEmitter<{ width: number }>(); + + private modeEmitter: ReturnType>; + + private constructor() { + const persisted = getItem(LocalStorageKeys.ChatState, { + open: false, + mode: 'floating', + }); + const mode = ( + persisted.mode === 'panel' ? 'panel' : 'floating' + ) as DisplayMode; + this.opened = persisted.open === true; + this.modeEmitter = createValueEventEmitter(mode); + } + + public static getInstance(): ChatProvider { + if (!ChatProvider.instance) { + ChatProvider.instance = new ChatProvider(); + } + return ChatProvider.instance; + } + + public subscribe = (listener: () => void): (() => void) => { + this.stateSubscribers.add(listener); + return () => this.stateSubscribers.delete(listener); + }; + + private notifyState(): void { + setItem(LocalStorageKeys.ChatState, { + open: this.opened, + mode: this.modeEmitter.getCurrent(), + }); + this.stateSubscribers.forEach(fn => fn()); + } + + private closePanel(): void { + this.opened = false; + this.closeEmitter.fire(); + } + + public registerChat( + chat: Chat, + trigger: ComponentType, + panel: ComponentType, + ): Disposable { + if (this.chat) { + // eslint-disable-next-line no-console + console.warn( + `[Superset] Multiple chat extensions registered. Using "${chat.id}"; discarding "${this.chat.id}".`, + ); + if (this.opened) this.closePanel(); + } + + this.chat = chat; + this.trigger = trigger; + this.panel = panel; + this.registerEmitter.fire(chat); + this.notifyState(); + + return new Disposable(() => { + if (this.chat?.id !== chat.id) return; + this.chat = undefined; + this.trigger = undefined; + this.panel = undefined; + this.unregisterEmitter.fire(chat); + if (this.opened) this.closePanel(); + this.notifyState(); + }); + } + + public getChat(): Chat | undefined { + return this.chat; + } + + public getTrigger(): ComponentType | undefined { + return this.trigger; + } + + public getPanel(): ComponentType | undefined { + return this.panel; + } + + public open(): void { + if (this.opened || !this.chat) return; + this.opened = true; + this.openEmitter.fire(); + this.notifyState(); + } + + public close(): void { + if (!this.opened || !this.chat) return; + this.closePanel(); + this.notifyState(); + } + + public isOpen(): boolean { + return this.opened; + } + + public getDisplayMode(): DisplayMode { + return this.modeEmitter.getCurrent(); + } + + public setDisplayMode(displayMode: DisplayMode): void { + if (displayMode === this.modeEmitter.getCurrent()) return; + this.modeEmitter.fire(displayMode); + this.notifyState(); + } + + public get onDidRegisterChat() { + return this.registerEmitter.subscribe; + } + + public get onDidUnregisterChat() { + return this.unregisterEmitter.subscribe; + } + + public get onDidOpen() { + return this.openEmitter.subscribe; + } + + public get onDidClose() { + return this.closeEmitter.subscribe; + } + + public get onDidChangeDisplayMode() { + return this.modeEmitter.subscribe; + } + + public get onDidResizePanel() { + return this.resizePanelEmitter.subscribe; + } + + public reset(): void { + this.chat = undefined; + this.trigger = undefined; + this.panel = undefined; + this.opened = false; + this.modeEmitter = createValueEventEmitter('floating'); + this.stateSubscribers.clear(); + setItem(LocalStorageKeys.ChatState, { open: false, mode: 'floating' }); + } +} + +export default ChatProvider; diff --git a/superset-frontend/src/core/chat/index.test.ts b/superset-frontend/src/core/chat/index.test.ts index 53765d6df94d..2fee1989135f 100644 --- a/superset-frontend/src/core/chat/index.test.ts +++ b/superset-frontend/src/core/chat/index.test.ts @@ -17,311 +17,52 @@ * under the License. */ import { createElement } from 'react'; -import { chat, getActiveChat, getChatSnapshot } from './index'; - -const disposables: Array<{ dispose: () => void }> = []; +import { chat } from './index'; +import ChatProvider from './ChatProvider'; 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.setDisplayMode('floating'); +beforeEach(() => { + ChatProvider.getInstance().reset(); }); 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', () => { +test('registerChat makes the chat retrievable via getChat', () => { const descriptor = { id: 'acme.chat', name: 'Acme Chat' }; - disposables.push(chat.registerChat(descriptor, trigger, panel)); + 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), - ); +test('the last-registered chat wins when multiple are registered', () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); - 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, - ); + 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'); - - 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); + jest.restoreAllMocks(); }); -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', () => { - 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)); +test('open and close toggle isOpen', () => { + chat.registerChat({ id: 'acme.chat', name: 'Acme' }, 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); - - chat.close(); chat.close(); - expect(chat.isOpen()).toBe(false); - expect(closed).toHaveBeenCalledTimes(1); }); -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); -}); - -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); -}); - -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 setDisplayMode fires only on change', () => { - const modeChanged = jest.fn(); - disposables.push(chat.onDidChangeDisplayMode(modeChanged)); - +test('getDisplayMode defaults to floating', () => { expect(chat.getDisplayMode()).toBe('floating'); +}); - // Setting the current mode is a no-op. - chat.setDisplayMode('floating'); - expect(modeChanged).not.toHaveBeenCalled(); - +test('setDisplayMode updates the display mode', () => { chat.setDisplayMode('panel'); expect(chat.getDisplayMode()).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 index b98a511286b2..b7536760926f 100644 --- a/superset-frontend/src/core/chat/index.ts +++ b/superset-frontend/src/core/chat/index.ts @@ -20,221 +20,63 @@ /** * @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. + * Extensions register via the public `chat.registerChat()` and the host owns + * mounting, open/close state, and the display mode. Only the last-registered + * chat is active at a time. * - * 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. + * The public namespace (`chat`) is exposed to extensions on `window.superset`. + * `useChat` is host-internal and NOT part of the public `@apache-superset/core` API. */ -import { ReactElement } from 'react'; +import { useSyncExternalStore } from 'react'; +import memoizeOne from 'memoize-one'; import type { chat as chatApi } from '@apache-superset/core'; -import { Disposable } from '../models'; -import { createValueEventEmitter, createEventEmitter } from '../utils'; - -type Chat = chatApi.Chat; -type DisplayMode = chatApi.DisplayMode; - -/** 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 DisplayMode}. */ - 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: DisplayMode; - /** 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 = createValueEventEmitter('floating'); +import ChatProvider from './ChatProvider'; + +export { ChatFloatingHost, ChatPanelHost } from './ChatHost'; + +const provider = ChatProvider.getInstance(); + +const buildSnapshot = memoizeOne( + ( + open: boolean, + mode: chatApi.DisplayMode, + chat: chatApi.Chat | undefined, + trigger: ReturnType, + panel: ReturnType, + ) => ({ open, mode, chat, trigger, panel }), +); + +const getSnapshot = () => + buildSnapshot( + provider.isOpen(), + provider.getDisplayMode(), + provider.getChat(), + provider.getTrigger(), + provider.getPanel(), + ); /** - * 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. + * Host-internal hook. Returns the current open/mode state and the active chat + * (trigger, panel, descriptor). */ -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`. */ -const closePanel = () => { - panelOpen = false; - closeEmitter.fire(); -}; - -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(); - } - 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(); - } - 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(); - notifyState(); -}; - -const close: typeof chatApi.close = (): void => { - const active = getActiveChat(); - if (!panelOpen || !active) return; - closePanel(); - notifyState(); -}; - -const isOpen: typeof chatApi.isOpen = (): boolean => panelOpen; - -const getDisplayMode: typeof chatApi.getDisplayMode = (): DisplayMode => - modeEmitter.getCurrent(); - -const setDisplayMode: typeof chatApi.setDisplayMode = ( - displayMode: DisplayMode, -): void => { - if (displayMode === modeEmitter.getCurrent()) return; - modeEmitter.fire(displayMode); - notifyState(); -}; +export const useChat = () => + useSyncExternalStore(provider.subscribe, getSnapshot); export const chat: typeof chatApi = { - registerChat, - getChat, - onDidRegisterChat: registerEmitter.subscribe, - onDidUnregisterChat: unregisterEmitter.subscribe, - open, - close, - isOpen, - onDidOpen: openEmitter.subscribe, - onDidClose: closeEmitter.subscribe, - getDisplayMode, - setDisplayMode, - onDidChangeDisplayMode: modeEmitter.subscribe, + registerChat: provider.registerChat.bind(provider), + getChat: provider.getChat.bind(provider), + onDidRegisterChat: provider.onDidRegisterChat, + onDidUnregisterChat: provider.onDidUnregisterChat, + open: provider.open.bind(provider), + close: provider.close.bind(provider), + isOpen: provider.isOpen.bind(provider), + onDidOpen: provider.onDidOpen, + onDidClose: provider.onDidClose, + getDisplayMode: provider.getDisplayMode.bind(provider), + setDisplayMode: provider.setDisplayMode.bind(provider), + onDidChangeDisplayMode: provider.onDidChangeDisplayMode, // The host fires this from its panel resizer; until that chrome exists the // event is exposed but never fires. - onDidResizePanel: resizePanelEmitter.subscribe, + onDidResizePanel: provider.onDidResizePanel, }; diff --git a/superset-frontend/src/core/editors/index.ts b/superset-frontend/src/core/editors/index.ts index 3613b4d42474..83ecf53eaca9 100644 --- a/superset-frontend/src/core/editors/index.ts +++ b/superset-frontend/src/core/editors/index.ts @@ -18,130 +18,39 @@ */ /** - * @fileoverview Implementation of the editors API for Superset. + * @fileoverview Host implementation of the `editors` contribution type. * - * This module provides the runtime implementation of the editor registration - * and resolution functions declared in the API types. + * Extensions register via the public `editors.registerEditor()` and the host + * resolves the appropriate provider per language, falling back to the built-in + * AceEditorProvider when no extension is registered. + * + * The public namespace (`editors`) is exposed to extensions on `window.superset`. + * `EditorHost` is the host-internal component for rendering editors and is NOT + * part of the public `@apache-superset/core` API. */ import { useSyncExternalStore } from 'react'; import { editors as editorsApi } from '@apache-superset/core'; -import { Disposable } from '../models'; import EditorProviders from './EditorProviders'; -type EditorLanguage = editorsApi.EditorLanguage; -type Editor = editorsApi.Editor; -type EditorProvider = editorsApi.EditorProvider; -type EditorComponent = editorsApi.EditorComponent; -type EditorRegisteredEvent = editorsApi.EditorRegisteredEvent; -type EditorUnregisteredEvent = editorsApi.EditorUnregisteredEvent; - -/** - * Register an editor provider as a module-level side effect. - * Takes the editor descriptor directly rather than looking it up - * from a manifest by ID. - * - * @param editor The editor descriptor. - * @param component The React component implementing the editor. - * @returns A Disposable to unregister the provider. - */ -export const registerEditor = ( - editor: Editor, - component: EditorComponent, -): Disposable => { - const providers = EditorProviders.getInstance(); - return providers.registerProvider(editor, component); -}; - -/** - * Get the editor provider for a specific language. - * Returns the extension's editor if registered, otherwise undefined. - * - * @param language The language to get an editor for - * @returns The editor provider or undefined if no extension provides one - */ -export const getEditor = ( - language: EditorLanguage, -): EditorProvider | undefined => { - const manager = EditorProviders.getInstance(); - return manager.getProvider(language); -}; - -/** - * Check if an extension has registered an editor for a language. - * - * @param language The language to check - * @returns True if an extension provides an editor for this language - */ -export const hasEditor = (language: EditorLanguage): boolean => { - const manager = EditorProviders.getInstance(); - return manager.hasProvider(language); -}; - -/** - * Get all registered editor providers. - * - * @returns Array of all registered editor providers - */ -export const getAllEditors = (): EditorProvider[] => { - const manager = EditorProviders.getInstance(); - return manager.getAllProviders(); -}; - -/** - * Event fired when an editor is registered. - * Subscribe to this event to react when extensions register new editors. - */ -export const onDidRegisterEditor = ( - listener: (e: EditorRegisteredEvent) => void, -): Disposable => { - const manager = EditorProviders.getInstance(); - return manager.onDidRegister(listener); -}; +export type { EditorHostProps } from './EditorHost'; +export { default as EditorHost } from './EditorHost'; +export { default as AceEditorProvider } from './AceEditorProvider'; -/** - * Event fired when an editor is unregistered. - * Subscribe to this event to react when extensions unregister editors. - */ -export const onDidUnregisterEditor = ( - listener: (e: EditorUnregisteredEvent) => void, -): Disposable => { - const manager = EditorProviders.getInstance(); - return manager.onDidUnregister(listener); -}; +const provider = EditorProviders.getInstance(); -/** - * Hook that returns the editor provider for a specific language and re-renders when it changes. - * - * @param language The language to get an editor for - * @returns The editor provider or undefined if no extension provides one - */ -export const useEditor = ( - language: EditorLanguage, -): EditorProvider | undefined => { - const manager = EditorProviders.getInstance(); - return useSyncExternalStore( - manager.subscribe, - () => manager.getProvider(language), +export const useEditor = (language: editorsApi.EditorLanguage) => + useSyncExternalStore( + provider.subscribe, + () => provider.getProvider(language), () => undefined, ); -}; -/** - * Editors API object for use in the extension system. - */ export const editors: typeof editorsApi = { - registerEditor, - getEditor, - hasEditor, - getAllEditors, - onDidRegisterEditor, - onDidUnregisterEditor, + registerEditor: provider.registerProvider.bind(provider), + getEditor: provider.getProvider.bind(provider), + hasEditor: provider.hasProvider.bind(provider), + getAllEditors: provider.getAllProviders.bind(provider), + onDidRegisterEditor: provider.onDidRegister.bind(provider), + onDidUnregisterEditor: provider.onDidUnregister.bind(provider), }; - -export { EditorProviders }; - -// Component exports -export { default as EditorHost } from './EditorHost'; -export type { EditorHostProps } from './EditorHost'; -export { default as AceEditorProvider } from './AceEditorProvider'; diff --git a/superset-frontend/src/core/navigation/index.test.ts b/superset-frontend/src/core/navigation/index.test.ts index 93fc2e03a96b..e7c2fbe183d7 100644 --- a/superset-frontend/src/core/navigation/index.test.ts +++ b/superset-frontend/src/core/navigation/index.test.ts @@ -32,10 +32,10 @@ async function importNavigation() { } test('getPage falls back to "home" for the welcome page and unknown pathnames', async () => { - const { navigation, notifyPageChange } = await importNavigation(); + const { navigation, notifyLocationChanged } = await importNavigation(); // The default pathname ('/') is not enumerated and falls back to home. expect(navigation.getPage()).toBe('home'); - notifyPageChange('/superset/welcome/'); + notifyLocationChanged('/superset/welcome/'); expect(navigation.getPage()).toBe('home'); }); @@ -45,80 +45,80 @@ test('getPage derives the page from window.location.pathname', async () => { expect(navigation.getPage()).toBe('dashboard'); }); -test('notifyPageChange updates the current page type', async () => { - const { navigation, notifyPageChange } = await importNavigation(); - notifyPageChange('/explore/?form_data={}'); +test('notifyLocationChanged updates the current page type', async () => { + const { navigation, notifyLocationChanged } = await importNavigation(); + notifyLocationChanged('/explore/?form_data={}'); expect(navigation.getPage()).toBe('explore'); }); -test('notifyPageChange fires listeners on page type change', async () => { - const { navigation, notifyPageChange } = await importNavigation(); +test('notifyLocationChanged fires listeners on page type change', async () => { + const { navigation, notifyLocationChanged } = await importNavigation(); const listener = jest.fn(); const disposable = navigation.onDidChangePage(listener); - notifyPageChange('/superset/dashboard/1/'); + notifyLocationChanged('/superset/dashboard/1/'); expect(listener).toHaveBeenCalledWith('dashboard'); disposable.dispose(); }); -test('notifyPageChange does not fire listeners when page type is unchanged', async () => { +test('notifyLocationChanged does not fire listeners when page type is unchanged', async () => { window.location.pathname = '/superset/dashboard/1/'; - const { navigation, notifyPageChange } = await importNavigation(); + const { navigation, notifyLocationChanged } = await importNavigation(); const listener = jest.fn(); navigation.onDidChangePage(listener); - notifyPageChange('/superset/dashboard/2/'); + notifyLocationChanged('/superset/dashboard/2/'); expect(listener).not.toHaveBeenCalled(); }); test('onDidChangePage listener is removed after dispose', async () => { - const { navigation, notifyPageChange } = await importNavigation(); + const { navigation, notifyLocationChanged } = await importNavigation(); const listener = jest.fn(); const disposable = navigation.onDidChangePage(listener); disposable.dispose(); - notifyPageChange('/superset/dashboard/1/'); + notifyLocationChanged('/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'); + const { notifyLocationChanged, navigation } = await importNavigation(); + notifyLocationChanged('/sqllab'); expect(navigation.getPage()).toBe('sqllab'); - notifyPageChange('/explore/'); - notifyPageChange('/sqllab/'); + notifyLocationChanged('/explore/'); + notifyLocationChanged('/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/'); + const { notifyLocationChanged, navigation } = await importNavigation(); + notifyLocationChanged('/chart/list/'); expect(navigation.getPage()).toBe('chart_list'); - notifyPageChange('/dashboard/list/'); + notifyLocationChanged('/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/'); + const { notifyLocationChanged, navigation } = await importNavigation(); + notifyLocationChanged('/tablemodelview/list/'); expect(navigation.getPage()).toBe('dataset_list'); - notifyPageChange('/dataset/42'); + notifyLocationChanged('/dataset/42'); 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/'); + const { notifyLocationChanged, navigation } = await importNavigation(); + notifyLocationChanged('/sqllab/'); expect(navigation.getPage()).toBe('sqllab'); - notifyPageChange('/sqllab/history/'); + notifyLocationChanged('/sqllab/history/'); expect(navigation.getPage()).toBe('query_history'); - notifyPageChange('/savedqueryview/list/'); + notifyLocationChanged('/savedqueryview/list/'); expect(navigation.getPage()).toBe('saved_queries'); }); test('chart/add resolves to explore, not chart_list', async () => { - const { notifyPageChange, navigation } = await importNavigation(); - notifyPageChange('/chart/add'); + const { notifyLocationChanged, navigation } = await importNavigation(); + notifyLocationChanged('/chart/add'); expect(navigation.getPage()).toBe('explore'); }); diff --git a/superset-frontend/src/core/navigation/index.ts b/superset-frontend/src/core/navigation/index.ts index f670a1a45f04..9e3578addf47 100644 --- a/superset-frontend/src/core/navigation/index.ts +++ b/superset-frontend/src/core/navigation/index.ts @@ -18,60 +18,75 @@ */ /** - * Host-internal implementation of the `navigation` namespace. + * @fileoverview Host-internal implementation of the `navigation` namespace. * - * Backed by browser location — no Redux dependency. - * The app shell calls `notifyPageChange(pathname)` whenever the route changes. + * Derives the current {@link Page} from the browser location by matching + * against {@link RoutePaths}. Call {@link useNavigationTracker} once in the + * app shell to keep the page in sync with React Router. */ +import { useEffect, useRef } from 'react'; +import { useLocation, matchPath } from 'react-router-dom'; import type { navigation as navigationApi } from '@apache-superset/core'; +import { RoutePaths } from '../../views/routePaths'; import { Disposable } from '../models'; -import { createEventEmitter } from '../utils'; +import { createValueEventEmitter } from '../utils'; type Page = navigationApi.Page; -const pageChangeEmitter = createEventEmitter(); +/** Maps route path patterns to their corresponding Page type. */ +const PAGE_ROUTES: { path: string; page: Page }[] = [ + { path: RoutePaths.DASHBOARD, page: 'dashboard' }, + { path: RoutePaths.DASHBOARD_LIST, page: 'dashboard_list' }, + { path: RoutePaths.QUERY_HISTORY, page: 'query_history' }, + { path: RoutePaths.SAVED_QUERIES, page: 'saved_queries' }, + { path: RoutePaths.SQLLAB, page: 'sqllab' }, + { path: RoutePaths.CHART_ADD, page: 'explore' }, + { path: RoutePaths.CHART_LIST, page: 'chart_list' }, + { path: RoutePaths.EXPLORE, page: 'explore' }, + { path: RoutePaths.EXPLORE_PERMALINK, page: 'explore' }, + { path: RoutePaths.DATASET_LIST, page: 'dataset_list' }, + { path: RoutePaths.DATASET_ADD, page: 'dataset' }, + { path: RoutePaths.DATASET, page: 'dataset' }, +]; 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'; - 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'; - // The welcome page and any route not explicitly enumerated fall back to home. + for (const { path, page } of PAGE_ROUTES) { + if (matchPath(pathname, { path, exact: false })) return page; + } return 'home'; } -let currentPage: Page | undefined; - -function getOrInitPage(): Page { - if (currentPage === undefined) { - currentPage = derivePage(window.location.pathname); - } - return currentPage; -} +const pageEmitter = createValueEventEmitter( + derivePage(window.location.pathname), +); -/** Called by ExtensionsStartup whenever the React Router location changes. */ -export const notifyPageChange = (pathname: string): void => { +/** Updates the current page from a pathname. No-op when the page is unchanged. */ +export const notifyLocationChanged = (pathname: string): void => { const next = derivePage(pathname); - if (next === getOrInitPage()) return; - currentPage = next; - pageChangeEmitter.fire(next); + if (next === pageEmitter.getCurrent()) return; + pageEmitter.fire(next); }; -const getPage: typeof navigationApi.getPage = () => getOrInitPage(); +const getPage: typeof navigationApi.getPage = () => pageEmitter.getCurrent(); const onDidChangePage: typeof navigationApi.onDidChangePage = ( listener: (page: Page) => void, thisArgs?: any, -): Disposable => pageChangeEmitter.subscribe(listener, thisArgs); +): Disposable => pageEmitter.subscribe(listener, thisArgs); + +/** Synchronizes the navigation module with React Router. Call once in the app shell. */ +export const useNavigationTracker = () => { + const location = useLocation(); + const prevPathname = useRef(null); + + useEffect(() => { + if (prevPathname.current !== location.pathname) { + prevPathname.current = location.pathname; + notifyLocationChanged(location.pathname); + } + }, [location.pathname]); +}; export const navigation: typeof navigationApi = { getPage, diff --git a/superset-frontend/src/core/views/index.ts b/superset-frontend/src/core/views/index.ts index a28cfc4c37bf..23f74e4de2e2 100644 --- a/superset-frontend/src/core/views/index.ts +++ b/superset-frontend/src/core/views/index.ts @@ -24,7 +24,7 @@ * Extensions register views as side effects at import time. */ -import React, { ReactElement, useSyncExternalStore } from 'react'; +import React, { ComponentType, useSyncExternalStore } from 'react'; import type { views as viewsApi } from '@apache-superset/core'; import { ErrorBoundary } from 'src/components/ErrorBoundary'; import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder'; @@ -37,7 +37,7 @@ type ViewUnregisteredEvent = viewsApi.ViewUnregisteredEvent; const viewRegistry: Map< string, - { view: View; location: string; provider: () => ReactElement } + { view: View; location: string; component: ComponentType } > = new Map(); const locationIndex: Map> = new Map(); @@ -66,11 +66,11 @@ const notifyUnregister = (event: ViewUnregisteredEvent) => { const registerView: typeof viewsApi.registerView = ( view: View, location: string, - provider: () => ReactElement, + component: ComponentType, ): Disposable => { const { id } = view; - viewRegistry.set(id, { view, location, provider }); + viewRegistry.set(id, { view, location, component }); const ids = locationIndex.get(location) ?? new Set(); ids.add(id); @@ -84,12 +84,16 @@ const registerView: typeof viewsApi.registerView = ( }); }; -export const resolveView = (id: string): ReactElement => { - const provider = viewRegistry.get(id)?.provider; - if (!provider) { +export const resolveView = (id: string): React.ReactElement => { + const entry = viewRegistry.get(id); + if (!entry) { return React.createElement(ExtensionPlaceholder, { id }); } - return React.createElement(ErrorBoundary, null, provider()); + return React.createElement( + ErrorBoundary, + null, + React.createElement(entry.component), + ); }; const getViews: typeof viewsApi.getViews = ( diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx index 1e972bdc6535..1a6300979708 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -16,8 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { useEffect, useRef } from 'react'; -import { useLocation } from 'react-router-dom'; +import { useEffect } from 'react'; import { logging } from '@apache-superset/core/utils'; import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; // eslint-disable-next-line no-restricted-syntax @@ -31,10 +30,10 @@ import { extensions, menus, navigation, + useNavigationTracker, 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'; @@ -43,21 +42,12 @@ import 'src/extensions/Namespaces'; const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ children, }) => { - const location = useLocation(); - const prevPathname = useRef(null); + useNavigationTracker(); 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. diff --git a/superset-frontend/src/utils/localStorageHelpers.ts b/superset-frontend/src/utils/localStorageHelpers.ts index 3c59b04b9c44..3bc302caf813 100644 --- a/superset-frontend/src/utils/localStorageHelpers.ts +++ b/superset-frontend/src/utils/localStorageHelpers.ts @@ -57,6 +57,7 @@ export enum LocalStorageKeys { DashboardExploreContext = 'dashboard__explore_context', DashboardEditorShowOnlyMyCharts = 'dashboard__editor_show_only_my_charts', CommonResizableSidebarWidths = 'common__resizable_sidebar_widths', + ChatState = 'chat__state', } export type LocalStorageValues = { @@ -78,6 +79,7 @@ export type LocalStorageValues = { dashboard__explore_context: Record; dashboard__editor_show_only_my_charts: boolean; common__resizable_sidebar_widths: Record; + chat__state: { open: boolean; mode: string }; }; /* diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 6a5c08672ee5..02aa803f413f 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -39,8 +39,11 @@ 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 { isUser } from 'src/types/bootstrapTypes'; import ExtensionsStartup from 'src/extensions/ExtensionsStartup'; -import ChatMount from 'src/components/ChatMount'; +import { Splitter } from 'src/components/Splitter'; +import { ChatFloatingHost, ChatPanelHost, useChat } from 'src/core/chat'; +import useStoredSidebarWidth from 'src/components/ResizableSidebar/useStoredSidebarWidth'; import { RootContextProviders } from './RootContextProviders'; import { ScrollToTop } from './ScrollToTop'; @@ -80,48 +83,136 @@ const LocationPathnameLogger = () => { return <>; }; +const CHAT_PANEL_DEFAULT_WIDTH = 400; +const CHAT_PANEL_MIN_WIDTH = 280; + +const RouteSwitch = () => ( + + {routes.map(({ path, Component, props = {}, Fallback = Loading }) => ( + + }> + + + + + + ))} + +); + +const layoutCss = css` + flex: 1; + min-height: 0; + overflow: hidden; +`; + +const contentCss = css` + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: auto; + position: relative; +`; + +/** + * Renders the main content area. When the chat panel is open in panel mode, + * wraps and in a Splitter so they sit side-by-side + * with a lazy drag bar (blue preview line, resize committed on mouseup). + * The full tree lives inside the first panel so its internal flex + * context is preserved — SQL Lab, Explore, and other pages are unaffected. + */ +const AppContent = () => { + const isAuthenticated = + isUser(bootstrapData.user) && !bootstrapData.user.isAnonymous; + const chatExtensionsEnabled = + isFeatureEnabled(FeatureFlag.EnableExtensions) && isAuthenticated; + const { open: panelOpen, mode, chat } = useChat(); + const hasChatExtension = chatExtensionsEnabled && !!chat; + const isPanelOpen = hasChatExtension && mode === 'panel' && panelOpen; + + const [storedWidth, setStoredWidth] = useStoredSidebarWidth( + 'chat:panel', + CHAT_PANEL_DEFAULT_WIDTH, + ); + + const layoutContent = ( + + + + + + ); + + if (!isPanelOpen) { + return ( + <> + {layoutContent} + {hasChatExtension && } + + ); + } + + return ( + { + const chatWidth = sizes[sizes.length - 1]; + if ( + typeof chatWidth === 'number' && + chatWidth >= CHAT_PANEL_MIN_WIDTH + ) { + setStoredWidth(chatWidth); + } + }} + css={css` + flex: 1; + min-height: 0; + overflow: hidden; + + /* + * Splitter.Panel is not a flex container by default, so flex:1 on + * children (Layout, ChatPanelHost) has no height effect and + * panels collapse. Making them flex columns lets children fill them. + */ + & > .ant-splitter-panel { + display: flex !important; + flex-direction: column; + } + `} + > + {layoutContent} + + + + + ); +}; + const App = () => ( - - - - {routes.map(({ path, Component, props = {}, Fallback = Loading }) => ( - - }> - - - - - - - - - - ))} - - {/* - 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) && } - +
+ + + + +
diff --git a/superset-frontend/src/views/routePaths.ts b/superset-frontend/src/views/routePaths.ts new file mode 100644 index 000000000000..7cc682ea632e --- /dev/null +++ b/superset-frontend/src/views/routePaths.ts @@ -0,0 +1,60 @@ +/** + * 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. + */ + +export const RoutePaths = { + REDIRECT: '/redirect/', + LOGIN: '/login/', + REGISTER_ACTIVATION: '/register/activation/:activationHash', + REGISTER: '/register/', + LOGOUT: '/logout/', + HOME: '/superset/welcome/', + FILE_HANDLER: '/superset/file-handler', + DASHBOARD: '/superset/dashboard/:idOrSlug/', + DASHBOARD_LIST: '/dashboard/list/', + CHART_ADD: '/chart/add', + CHART_LIST: '/chart/list/', + DATASET_LIST: '/tablemodelview/list/', + DATABASE_LIST: '/databaseview/list/', + SAVED_QUERIES: '/savedqueryview/list/', + CSS_TEMPLATES: '/csstemplatemodelview/list/', + THEMES: '/theme/list/', + ANNOTATION_LAYERS: '/annotationlayer/list/', + ANNOTATION_LIST: '/annotationlayer/:annotationLayerId/annotation/', + QUERY_HISTORY: '/sqllab/history/', + ALERTS: '/alert/list/', + REPORTS: '/report/list/', + ALERT_LOG: '/alert/:alertId/log/', + REPORT_LOG: '/report/:alertId/log/', + EXPLORE: '/explore/', + EXPLORE_PERMALINK: '/superset/explore/p', + DATASET_ADD: '/dataset/add/', + DATASET: '/dataset/:datasetId', + ROW_LEVEL_SECURITY: '/rowlevelsecurity/list', + TASKS: '/tasks/list/', + SQLLAB: '/sqllab/', + USER_INFO: '/user_info/', + ACTION_LOG: '/actionlog/list', + REGISTRATIONS: '/registrations/', + ALL_ENTITIES: '/superset/all_entities/', + TAGS: '/superset/tags/', + ROLES: '/roles/', + USERS: '/users/', + GROUPS: '/list_groups/', + EXTENSIONS: '/extensions/list/', +} as const; diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index 4f066e3ec2cb..4c18e28ab427 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -26,6 +26,7 @@ import { } from 'react'; import { isUserAdmin } from 'src/dashboard/util/permissionUtils'; import getBootstrapData from 'src/utils/getBootstrapData'; +import { RoutePaths } from './routePaths'; // not lazy loaded since this is the home page. import Home from 'src/pages/Home'; @@ -189,158 +190,58 @@ const RedirectWarning = lazy( type Routes = { path: string; - Component: ComponentType; - Fallback?: ComponentType; + Component: ComponentType; + Fallback?: ComponentType; props?: ComponentProps; }[]; export const routes: Routes = [ - { - path: '/redirect/', - Component: RedirectWarning, - }, - { - path: '/login/', - Component: Login, - }, - { - path: '/register/activation/:activationHash', - Component: Register, - }, - { - path: '/register/', - Component: Register, - }, - { - path: '/logout/', - Component: Login, - }, - { - path: '/superset/welcome/', - Component: Home, - }, - { - path: '/superset/file-handler', - Component: FileHandler, - }, - { - path: '/dashboard/list/', - Component: DashboardList, - }, - { - path: '/superset/dashboard/:idOrSlug/', - Component: Dashboard, - }, - { - path: '/chart/add', - Component: ChartCreation, - }, - { - path: '/chart/list/', - Component: ChartList, - }, - { - path: '/tablemodelview/list/', - Component: DatasetList, - }, - { - path: '/databaseview/list/', - Component: DatabaseList, - }, - { - path: '/savedqueryview/list/', - Component: SavedQueryList, - }, - { - path: '/csstemplatemodelview/list/', - Component: CssTemplateList, - }, - { - path: '/theme/list/', - Component: ThemeList, - }, - { - path: '/annotationlayer/list/', - Component: AnnotationLayerList, - }, - { - path: '/annotationlayer/:annotationLayerId/annotation/', - Component: AnnotationList, - }, - { - path: '/sqllab/history/', - Component: QueryHistoryList, - }, - { - path: '/alert/list/', - Component: AlertReportList, - }, - { - path: '/report/list/', + { path: RoutePaths.REDIRECT, Component: RedirectWarning }, + { path: RoutePaths.LOGIN, Component: Login }, + { path: RoutePaths.REGISTER_ACTIVATION, Component: Register }, + { path: RoutePaths.REGISTER, Component: Register }, + { path: RoutePaths.LOGOUT, Component: Login }, + { path: RoutePaths.HOME, Component: Home }, + { path: RoutePaths.FILE_HANDLER, Component: FileHandler }, + { path: RoutePaths.DASHBOARD_LIST, Component: DashboardList }, + { path: RoutePaths.DASHBOARD, Component: Dashboard }, + { path: RoutePaths.CHART_ADD, Component: ChartCreation }, + { path: RoutePaths.CHART_LIST, Component: ChartList }, + { path: RoutePaths.DATASET_LIST, Component: DatasetList }, + { path: RoutePaths.DATABASE_LIST, Component: DatabaseList }, + { path: RoutePaths.SAVED_QUERIES, Component: SavedQueryList }, + { path: RoutePaths.CSS_TEMPLATES, Component: CssTemplateList }, + { path: RoutePaths.THEMES, Component: ThemeList }, + { path: RoutePaths.ANNOTATION_LAYERS, Component: AnnotationLayerList }, + { path: RoutePaths.ANNOTATION_LIST, Component: AnnotationList }, + { path: RoutePaths.QUERY_HISTORY, Component: QueryHistoryList }, + { path: RoutePaths.ALERTS, Component: AlertReportList }, + { + path: RoutePaths.REPORTS, Component: AlertReportList, - props: { - isReportEnabled: true, - }, - }, - { - path: '/alert/:alertId/log/', - Component: ExecutionLogList, + props: { isReportEnabled: true }, }, + { path: RoutePaths.ALERT_LOG, Component: ExecutionLogList }, { - path: '/report/:alertId/log/', + path: RoutePaths.REPORT_LOG, Component: ExecutionLogList, - props: { - isReportEnabled: true, - }, - }, - { - path: '/explore/', - Component: Chart, - }, - { - path: '/superset/explore/p', - Component: Chart, - }, - { - path: '/dataset/add/', - Component: DatasetCreation, - }, - { - path: '/dataset/:datasetId', - Component: DatasetCreation, - }, - { - path: '/rowlevelsecurity/list', - Component: RowLevelSecurityList, - }, - { - path: '/tasks/list/', - Component: TaskList, - }, - { - path: '/sqllab/', - Component: SqlLab, - }, - { path: '/user_info/', Component: UserInfo }, - { - path: '/actionlog/list', - Component: ActionLogList, - }, - { - path: '/registrations/', - Component: UserRegistrations, - }, + props: { isReportEnabled: true }, + }, + { path: RoutePaths.EXPLORE, Component: Chart }, + { path: RoutePaths.EXPLORE_PERMALINK, Component: Chart }, + { path: RoutePaths.DATASET_ADD, Component: DatasetCreation }, + { path: RoutePaths.DATASET, Component: DatasetCreation }, + { path: RoutePaths.ROW_LEVEL_SECURITY, Component: RowLevelSecurityList }, + { path: RoutePaths.TASKS, Component: TaskList }, + { path: RoutePaths.SQLLAB, Component: SqlLab }, + { path: RoutePaths.USER_INFO, Component: UserInfo }, + { path: RoutePaths.ACTION_LOG, Component: ActionLogList }, + { path: RoutePaths.REGISTRATIONS, Component: UserRegistrations }, ]; if (isFeatureEnabled(FeatureFlag.TaggingSystem)) { - routes.push({ - path: '/superset/all_entities/', - Component: AllEntities, - }); - routes.push({ - path: '/superset/tags/', - Component: Tags, - }); + routes.push({ path: RoutePaths.ALL_ENTITIES, Component: AllEntities }); + routes.push({ path: RoutePaths.TAGS, Component: Tags }); } const user = getBootstrapData()?.user; @@ -350,33 +251,18 @@ const isAdmin = isUserAdmin(user); if (isAdmin) { routes.push( - { - path: '/roles/', - Component: RolesList, - }, - { - path: '/users/', - Component: UsersList, - }, - { - path: '/list_groups/', - Component: GroupsList, - }, + { path: RoutePaths.ROLES, Component: RolesList }, + { path: RoutePaths.USERS, Component: UsersList }, + { path: RoutePaths.GROUPS, Component: GroupsList }, ); if (isFeatureEnabled(FeatureFlag.EnableExtensions)) { - routes.push({ - path: '/extensions/list/', - Component: Extensions, - }); + routes.push({ path: RoutePaths.EXTENSIONS, Component: Extensions }); } } if (authRegistrationEnabled) { - routes.push({ - path: '/registrations/', - Component: UserRegistrations, - }); + routes.push({ path: RoutePaths.REGISTRATIONS, Component: UserRegistrations }); } const frontEndRoutes: Record = routes From 1847608053a0e0e2c43ade6e33a001084381bca8 Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" Date: Thu, 18 Jun 2026 15:36:30 -0300 Subject: [PATCH 12/14] Address CodeAnt comments --- superset-frontend/src/core/chat/ChatProvider.ts | 8 +++++++- .../src/core/editors/EditorProviders.ts | 12 +++++++++--- superset-frontend/src/core/menus/index.ts | 9 ++++++--- superset-frontend/src/core/navigation/index.ts | 2 +- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/superset-frontend/src/core/chat/ChatProvider.ts b/superset-frontend/src/core/chat/ChatProvider.ts index 81e5c65a0767..b0d2f79af50e 100644 --- a/superset-frontend/src/core/chat/ChatProvider.ts +++ b/superset-frontend/src/core/chat/ChatProvider.ts @@ -106,6 +106,7 @@ class ChatProvider { console.warn( `[Superset] Multiple chat extensions registered. Using "${chat.id}"; discarding "${this.chat.id}".`, ); + this.unregisterEmitter.fire(this.chat); if (this.opened) this.closePanel(); } @@ -116,7 +117,7 @@ class ChatProvider { this.notifyState(); return new Disposable(() => { - if (this.chat?.id !== chat.id) return; + if (this.chat !== chat) return; this.chat = undefined; this.trigger = undefined; this.panel = undefined; @@ -194,6 +195,11 @@ class ChatProvider { this.trigger = undefined; this.panel = undefined; this.opened = false; + this.registerEmitter = createEventEmitter(); + this.unregisterEmitter = createEventEmitter(); + this.openEmitter = createEventEmitter(); + this.closeEmitter = createEventEmitter(); + this.resizePanelEmitter = createEventEmitter<{ width: number }>(); this.modeEmitter = createValueEventEmitter('floating'); this.stateSubscribers.clear(); setItem(LocalStorageKeys.ChatState, { open: false, mode: 'floating' }); diff --git a/superset-frontend/src/core/editors/EditorProviders.ts b/superset-frontend/src/core/editors/EditorProviders.ts index 2ae7cb3d4cb1..104e891d1f05 100644 --- a/superset-frontend/src/core/editors/EditorProviders.ts +++ b/superset-frontend/src/core/editors/EditorProviders.ts @@ -184,8 +184,11 @@ class EditorProviders { * @param listener The listener function. * @returns A Disposable to unsubscribe. */ - public onDidRegister(listener: Listener): Disposable { - return this.registerEmitter.subscribe(listener); + public onDidRegister( + listener: Listener, + thisArgs?: unknown, + ): Disposable { + return this.registerEmitter.subscribe(listener, thisArgs); } /** @@ -195,8 +198,9 @@ class EditorProviders { */ public onDidUnregister( listener: Listener, + thisArgs?: unknown, ): Disposable { - return this.unregisterEmitter.subscribe(listener); + return this.unregisterEmitter.subscribe(listener, thisArgs); } /** @@ -206,6 +210,8 @@ class EditorProviders { this.providers.clear(); this.languageToProvider.clear(); this.syncListeners.clear(); + this.registerEmitter = createEventEmitter(); + this.unregisterEmitter = createEventEmitter(); } } diff --git a/superset-frontend/src/core/menus/index.ts b/superset-frontend/src/core/menus/index.ts index b11a0ff44b72..ce6184a90dce 100644 --- a/superset-frontend/src/core/menus/index.ts +++ b/superset-frontend/src/core/menus/index.ts @@ -118,11 +118,14 @@ export const useMenu = (location: string): Menu | undefined => export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = ( listener: (e: MenuItemRegisteredEvent) => void, -): Disposable => registerEmitter.subscribe(listener); + thisArgs?: unknown, +): Disposable => registerEmitter.subscribe(listener, thisArgs); export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem = - (listener: (e: MenuItemUnregisteredEvent) => void): Disposable => - unregisterEmitter.subscribe(listener); + ( + listener: (e: MenuItemUnregisteredEvent) => void, + thisArgs?: unknown, + ): Disposable => unregisterEmitter.subscribe(listener, thisArgs); 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 9e3578addf47..d41e76e50bf7 100644 --- a/superset-frontend/src/core/navigation/index.ts +++ b/superset-frontend/src/core/navigation/index.ts @@ -72,7 +72,7 @@ const getPage: typeof navigationApi.getPage = () => pageEmitter.getCurrent(); const onDidChangePage: typeof navigationApi.onDidChangePage = ( listener: (page: Page) => void, - thisArgs?: any, + thisArgs?: unknown, ): Disposable => pageEmitter.subscribe(listener, thisArgs); /** Synchronizes the navigation module with React Router. Call once in the app shell. */ From bb9ee355a54f8d58049478b50bbe73c64450cde9 Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" Date: Thu, 18 Jun 2026 16:21:10 -0300 Subject: [PATCH 13/14] Fix CI errors --- superset-frontend/src/core/sqlLab/index.ts | 2 +- superset-frontend/src/core/storeUtils.ts | 50 +++++++++++++++++++ superset-frontend/src/core/utils.ts | 30 ----------- .../src/extensions/ExtensionsStartup.tsx | 2 + 4 files changed, 53 insertions(+), 31 deletions(-) create mode 100644 superset-frontend/src/core/storeUtils.ts diff --git a/superset-frontend/src/core/sqlLab/index.ts b/superset-frontend/src/core/sqlLab/index.ts index b14be7efd07b..707b731c1b1d 100644 --- a/superset-frontend/src/core/sqlLab/index.ts +++ b/superset-frontend/src/core/sqlLab/index.ts @@ -48,7 +48,7 @@ import { AnyListenerPredicate } from '@reduxjs/toolkit'; import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types'; import { newQueryTabName } from 'src/SqlLab/utils/newQueryTabName'; import { Database, Disposable } from '../models'; -import { createActionListener } from '../utils'; +import { createActionListener } from '../storeUtils'; import { Panel, Tab, diff --git a/superset-frontend/src/core/storeUtils.ts b/superset-frontend/src/core/storeUtils.ts new file mode 100644 index 000000000000..87938d02cd2d --- /dev/null +++ b/superset-frontend/src/core/storeUtils.ts @@ -0,0 +1,50 @@ +/** + * 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 { common as core } from '@apache-superset/core'; +import { listenerMiddleware, RootState, store } from 'src/views/store'; +import { AnyListenerPredicate } from '@reduxjs/toolkit'; + +export function createActionListener( + predicate: AnyListenerPredicate, + listener: (v: V) => void, + valueParser: (action: A, state: RootState) => V | null | undefined, + thisArgs?: unknown, +): core.Disposable { + const boundListener = thisArgs + ? listener.bind(thisArgs as object) + : listener; + + const unsubscribe = listenerMiddleware.startListening({ + predicate, + effect: action => { + const state = store.getState(); + // The predicate already ensures the action matches type A at runtime. + const value = valueParser(action as unknown as A, state); + if (value != null) { + boundListener(value); + } + }, + }); + + return { + dispose: () => { + unsubscribe(); + }, + }; +} diff --git a/superset-frontend/src/core/utils.ts b/superset-frontend/src/core/utils.ts index 00c675523f1a..536508d88a09 100644 --- a/superset-frontend/src/core/utils.ts +++ b/superset-frontend/src/core/utils.ts @@ -17,9 +17,6 @@ * under the License. */ 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'; type Listener = (e: T) => unknown; @@ -71,30 +68,3 @@ export function createValueEventEmitter(initial: T): ValueEventEmitter { getCurrent: () => current, }; } - -export function createActionListener( - predicate: AnyListenerPredicate, - listener: (v: V) => void, - valueParser: (action: AnyAction, state: RootState) => V | null | undefined, - thisArgs?: any, -): core.Disposable { - const boundListener = thisArgs ? listener.bind(thisArgs) : listener; - - const unsubscribe = listenerMiddleware.startListening({ - predicate, - effect: (action: AnyAction) => { - const state = store.getState(); - const value = valueParser(action, state); - // Skip calling listener if valueParser returns null/undefined - if (value != null) { - boundListener(value); - } - }, - }); - - return { - dispose: () => { - unsubscribe(); - }, - }; -} diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx index 1a6300979708..c2dc63442f1e 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -65,6 +65,8 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ }, []); useEffect(() => { + if (!userId) 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. From 0ad3ef2a2f3e236a9fadbe04f8e7ee2fc098d639 Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" Date: Thu, 18 Jun 2026 16:27:13 -0300 Subject: [PATCH 14/14] Remove promise error listener --- .../src/extensions/ExtensionsStartup.tsx | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx index c2dc63442f1e..21dbc2419f11 100644 --- a/superset-frontend/src/extensions/ExtensionsStartup.tsx +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -17,7 +17,6 @@ * under the License. */ import { useEffect } from 'react'; -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'; @@ -48,24 +47,8 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({ ({ user }) => user.userId, ); - // 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 (!userId) return; +useEffect(() => { + if (userId == null) return; // Provide the implementations for @apache-superset/core. // Namespaces are listed explicitly — do not spread the core package here,