mirror of
https://github.com/apache/superset.git
synced 2026-06-29 11:25:34 +00:00
Compare commits
9 Commits
chore/ci/s
...
enxdev/fea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c92f1f5ae | ||
|
|
5294d05467 | ||
|
|
bf71eb6712 | ||
|
|
6a07ad2369 | ||
|
|
793ffb3d80 | ||
|
|
f575fdae3a | ||
|
|
7b418becc7 | ||
|
|
ba7db15f02 | ||
|
|
c85661f4fd |
0
extensions/chat/PUT_FILES_HERE.txt
Normal file
0
extensions/chat/PUT_FILES_HERE.txt
Normal file
@@ -18,6 +18,22 @@
|
|||||||
"types": "./lib/authentication/index.d.ts",
|
"types": "./lib/authentication/index.d.ts",
|
||||||
"default": "./lib/authentication/index.js"
|
"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": {
|
"./commands": {
|
||||||
"types": "./lib/commands/index.d.ts",
|
"types": "./lib/commands/index.d.ts",
|
||||||
"default": "./lib/commands/index.js"
|
"default": "./lib/commands/index.js"
|
||||||
|
|||||||
@@ -17,23 +17,9 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* @fileoverview Manifest schema for Superset extension contributions.
|
|
||||||
*
|
|
||||||
* This module defines the aggregate interfaces used by the extension.json
|
|
||||||
* manifest and the `superset-extensions` build command. Individual metadata
|
|
||||||
* types are defined in their respective namespace modules (commands, views,
|
|
||||||
* menus, editors) and re-exported here for the manifest schema.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Command } from '../commands';
|
|
||||||
import { View } from '../views';
|
import { View } from '../views';
|
||||||
import { Menu } from '../menus';
|
import { Menu } from '../menus';
|
||||||
import { Editor } from '../editors';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Valid locations within SQL Lab.
|
|
||||||
*/
|
|
||||||
export type SqlLabLocation =
|
export type SqlLabLocation =
|
||||||
| 'leftSidebar'
|
| 'leftSidebar'
|
||||||
| 'rightSidebar'
|
| 'rightSidebar'
|
||||||
@@ -43,43 +29,14 @@ export type SqlLabLocation =
|
|||||||
| 'results'
|
| 'results'
|
||||||
| 'queryHistory';
|
| 'queryHistory';
|
||||||
|
|
||||||
/**
|
/** Valid locations within the app shell (persist across all routes). */
|
||||||
* Nested structure for view contributions by scope and location.
|
export type AppLocation = 'chatbot';
|
||||||
* @example
|
|
||||||
* {
|
|
||||||
* sqllab: {
|
|
||||||
* panels: [{ id: "my-ext.panel", name: "My Panel" }],
|
|
||||||
* leftSidebar: [{ id: "my-ext.sidebar", name: "My Sidebar" }]
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export interface ViewContributions {
|
export interface ViewContributions {
|
||||||
sqllab?: Partial<Record<SqlLabLocation, View[]>>;
|
sqllab?: Partial<Record<SqlLabLocation, View[]>>;
|
||||||
|
app?: Partial<Record<AppLocation, View[]>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Nested structure for menu contributions by scope and location.
|
|
||||||
* @example
|
|
||||||
* {
|
|
||||||
* sqllab: {
|
|
||||||
* editor: { primary: [...], secondary: [...] }
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export interface MenuContributions {
|
export interface MenuContributions {
|
||||||
sqllab?: Partial<Record<SqlLabLocation, Menu>>;
|
sqllab?: Partial<Record<SqlLabLocation, Menu>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
|
|
||||||
*/
|
|
||||||
export interface Contributions {
|
|
||||||
/** List of commands. */
|
|
||||||
commands: Command[];
|
|
||||||
/** Nested mapping of menu contributions by scope and location. */
|
|
||||||
menus: MenuContributions;
|
|
||||||
/** Nested mapping of view contributions by scope and location. */
|
|
||||||
views: ViewContributions;
|
|
||||||
/** List of editors. */
|
|
||||||
editors?: Editor[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<DashboardContext>;
|
||||||
@@ -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<DatasetContext>;
|
||||||
@@ -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<ChartContext>;
|
||||||
@@ -19,9 +19,13 @@
|
|||||||
export * as common from './common';
|
export * as common from './common';
|
||||||
export * as authentication from './authentication';
|
export * as authentication from './authentication';
|
||||||
export * as commands from './commands';
|
export * as commands from './commands';
|
||||||
|
export * as dashboard from './dashboard';
|
||||||
|
export * as dataset from './dataset';
|
||||||
export * as editors from './editors';
|
export * as editors from './editors';
|
||||||
|
export * as explore from './explore';
|
||||||
export * as extensions from './extensions';
|
export * as extensions from './extensions';
|
||||||
export * as menus from './menus';
|
export * as menus from './menus';
|
||||||
|
export * as navigation from './navigation';
|
||||||
export * as sqlLab from './sqlLab';
|
export * as sqlLab from './sqlLab';
|
||||||
export * as views from './views';
|
export * as views from './views';
|
||||||
export * as contributions from './contributions';
|
export * as contributions from './contributions';
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* `'other'` covers any route not explicitly enumerated.
|
||||||
|
*/
|
||||||
|
export type PageType =
|
||||||
|
| 'dashboard'
|
||||||
|
| 'explore'
|
||||||
|
| 'sqllab'
|
||||||
|
| 'dataset'
|
||||||
|
| '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<PageType>;
|
||||||
@@ -20,19 +20,12 @@
|
|||||||
/**
|
/**
|
||||||
* @fileoverview Views registration API for Superset extensions.
|
* @fileoverview Views registration API for Superset extensions.
|
||||||
*
|
*
|
||||||
* This module provides functions for registering custom React views
|
* Extensions register React views at named locations using `registerView`.
|
||||||
* at specific locations in the Superset UI. Views are registered as
|
* Registrations happen as module-level side effects at import time.
|
||||||
* module-level side effects at import time.
|
|
||||||
*
|
*
|
||||||
* @example
|
* Built-in locations:
|
||||||
* ```typescript
|
* - `sqllab.panels` / `sqllab.rightSidebar` / … — SQL Lab surface
|
||||||
* import { views } from '@apache-superset/core';
|
* - `superset.chatbot` — app-shell chatbot bubble (singleton; host renders one)
|
||||||
*
|
|
||||||
* views.registerView(
|
|
||||||
* { id: 'my_ext.result_stats', name: 'Result Stats', location: 'sqllab.panels' },
|
|
||||||
* () => <ResultStatsPanel />,
|
|
||||||
* );
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ReactElement } from 'react';
|
import { ReactElement } from 'react';
|
||||||
@@ -48,20 +41,23 @@ export interface View {
|
|||||||
name: string;
|
name: string;
|
||||||
/** Optional description of the view, for display in contribution manifests. */
|
/** Optional description of the view, for display in contribution manifests. */
|
||||||
description?: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a custom view at a specific UI location.
|
* Registers a custom view at a specific UI location.
|
||||||
*
|
*
|
||||||
* The view provider function is called when the UI renders the location,
|
* @param view The view descriptor (id, name, and optional icon/description).
|
||||||
* and should return a React element to display.
|
* @param location The location where this view should appear.
|
||||||
*
|
|
||||||
* @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 provider A function that returns the React element to render.
|
||||||
* @returns A Disposable that unregisters the view when disposed.
|
* @returns A Disposable that unregisters the view when disposed.
|
||||||
*
|
*
|
||||||
* @example
|
* @example SQL Lab panel
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* views.registerView(
|
* views.registerView(
|
||||||
* { id: 'my_ext.result_stats', name: 'Result Stats' },
|
* { id: 'my_ext.result_stats', name: 'Result Stats' },
|
||||||
@@ -69,6 +65,15 @@ export interface View {
|
|||||||
* () => <ResultStatsPanel />,
|
* () => <ResultStatsPanel />,
|
||||||
* );
|
* );
|
||||||
* ```
|
* ```
|
||||||
|
*
|
||||||
|
* @example Chatbot bubble (`superset.chatbot` — singleton, host renders one)
|
||||||
|
* ```typescript
|
||||||
|
* views.registerView(
|
||||||
|
* { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' },
|
||||||
|
* 'superset.chatbot',
|
||||||
|
* () => <ChatbotApp />,
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export declare function registerView(
|
export declare function registerView(
|
||||||
view: View,
|
view: View,
|
||||||
@@ -76,6 +81,21 @@ export declare function registerView(
|
|||||||
provider: () => ReactElement,
|
provider: () => ReactElement,
|
||||||
): Disposable;
|
): Disposable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrowed descriptor for chatbot contributions (`superset.chatbot` location).
|
||||||
|
*
|
||||||
|
* Extension authors should use this type when calling `registerView` for the
|
||||||
|
* chatbot area. It is identical to {@link View} but makes the registration
|
||||||
|
* intent explicit and allows future narrowing (e.g. required `icon`).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const chatbot: ChatbotView = { id: 'my_ext.chatbot', name: 'My Chatbot', icon: 'Bubble' };
|
||||||
|
* views.registerView(chatbot, 'superset.chatbot', () => <ChatbotApp />);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export type ChatbotView = View;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves all views registered at a specific location.
|
* Retrieves all views registered at a specific location.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from 'spec/helpers/testing-library';
|
||||||
|
import { views } from 'src/core';
|
||||||
|
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||||
|
import ChatbotMount from '.';
|
||||||
|
|
||||||
|
const disposables: Array<{ dispose: () => void }> = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
disposables.forEach(d => d.dispose());
|
||||||
|
disposables.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders nothing when no chatbot extension is registered', () => {
|
||||||
|
render(<ChatbotMount />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('chatbot-mount')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the registered chatbot inside the fixed mount slot', () => {
|
||||||
|
const provider = () => React.createElement('div', null, 'My Chatbot Bubble');
|
||||||
|
disposables.push(
|
||||||
|
views.registerView(
|
||||||
|
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||||
|
CHATBOT_LOCATION,
|
||||||
|
provider,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ChatbotMount />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('chatbot-mount')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('My Chatbot Bubble')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders only the first-to-register chatbot when several are installed', () => {
|
||||||
|
const firstProvider = () => React.createElement('div', null, 'First Bubble');
|
||||||
|
const secondProvider = () =>
|
||||||
|
React.createElement('div', null, 'Second Bubble');
|
||||||
|
disposables.push(
|
||||||
|
views.registerView(
|
||||||
|
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||||
|
CHATBOT_LOCATION,
|
||||||
|
firstProvider,
|
||||||
|
),
|
||||||
|
views.registerView(
|
||||||
|
{ id: 'second.chatbot', name: 'Second Chatbot' },
|
||||||
|
CHATBOT_LOCATION,
|
||||||
|
secondProvider,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ChatbotMount />);
|
||||||
|
|
||||||
|
expect(screen.getByText('First Bubble')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isolates a failing chatbot so it does not crash the host', () => {
|
||||||
|
const FailingChatbot = () => {
|
||||||
|
throw new Error('chatbot blew up');
|
||||||
|
};
|
||||||
|
disposables.push(
|
||||||
|
views.registerView(
|
||||||
|
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||||
|
CHATBOT_LOCATION,
|
||||||
|
() => React.createElement(FailingChatbot),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The host-owned error boundary catches the failure; render does not throw.
|
||||||
|
expect(() => render(<ChatbotMount />)).not.toThrow();
|
||||||
|
});
|
||||||
84
superset-frontend/src/components/ChatbotMount/index.tsx
Normal file
84
superset-frontend/src/components/ChatbotMount/index.tsx
Normal file
@@ -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.
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { SupersetClient } from '@superset-ui/core';
|
||||||
|
import { css, useTheme } from '@apache-superset/core/theme';
|
||||||
|
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||||
|
import { getActiveChatbot } from 'src/core/chatbot';
|
||||||
|
import { subscribeToLocation } from 'src/core/views';
|
||||||
|
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||||
|
|
||||||
|
const CHATBOT_EDGE_MARGIN = 24;
|
||||||
|
|
||||||
|
const ChatbotMount = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [adminSelectedId, setAdminSelectedId] = useState<string | null>(null);
|
||||||
|
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
|
||||||
|
const [activeChatbot, setActiveChatbot] = useState(() =>
|
||||||
|
getActiveChatbot(null, {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
|
||||||
|
.then(({ json }) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const id = json.result?.active_chatbot_id ?? null;
|
||||||
|
const enabled: Record<string, boolean> = json.result?.enabled ?? {};
|
||||||
|
setAdminSelectedId(id);
|
||||||
|
setEnabledMap(enabled);
|
||||||
|
setActiveChatbot(getActiveChatbot(id, enabled));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Settings fetch failure is non-fatal — fall back to first-to-register.
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
subscribeToLocation(CHATBOT_LOCATION, () =>
|
||||||
|
setActiveChatbot(getActiveChatbot(adminSelectedId, enabledMap)),
|
||||||
|
),
|
||||||
|
[adminSelectedId, enabledMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!activeChatbot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-test="chatbot-mount"
|
||||||
|
css={css`
|
||||||
|
position: fixed;
|
||||||
|
right: ${CHATBOT_EDGE_MARGIN}px;
|
||||||
|
bottom: ${CHATBOT_EDGE_MARGIN}px;
|
||||||
|
/* Above dashboard content and the toast layer, below modal dialogs. */
|
||||||
|
z-index: ${theme.zIndexPopupBase + 2};
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<ErrorBoundary>{activeChatbot.provider()}</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChatbotMount;
|
||||||
96
superset-frontend/src/core/chatbot/index.test.ts
Normal file
96
superset-frontend/src/core/chatbot/index.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { views } from 'src/core/views';
|
||||||
|
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||||
|
import { getActiveChatbot } from './index';
|
||||||
|
|
||||||
|
const disposables: Array<{ dispose: () => void }> = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
disposables.forEach(d => d.dispose());
|
||||||
|
disposables.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getActiveChatbot returns undefined when no chatbot is registered', () => {
|
||||||
|
expect(getActiveChatbot()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getActiveChatbot resolves the single registered chatbot', () => {
|
||||||
|
const provider = () => React.createElement('div', null, 'Chatbot');
|
||||||
|
disposables.push(
|
||||||
|
views.registerView(
|
||||||
|
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||||
|
CHATBOT_LOCATION,
|
||||||
|
provider,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const active = getActiveChatbot();
|
||||||
|
expect(active).toEqual({ id: 'superset.chatbot', provider });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getActiveChatbot picks the first-to-register when multiple are installed', () => {
|
||||||
|
const firstProvider = () => React.createElement('div', null, 'First');
|
||||||
|
const secondProvider = () => React.createElement('div', null, 'Second');
|
||||||
|
disposables.push(
|
||||||
|
views.registerView(
|
||||||
|
{ id: 'first.chatbot', name: 'First Chatbot' },
|
||||||
|
CHATBOT_LOCATION,
|
||||||
|
firstProvider,
|
||||||
|
),
|
||||||
|
views.registerView(
|
||||||
|
{ id: 'second.chatbot', name: 'Second Chatbot' },
|
||||||
|
CHATBOT_LOCATION,
|
||||||
|
secondProvider,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const active = getActiveChatbot();
|
||||||
|
expect(active?.id).toBe('first.chatbot');
|
||||||
|
expect(active?.provider).toBe(firstProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getActiveChatbot ignores views registered at other locations', () => {
|
||||||
|
const provider = () => React.createElement('div', null, 'Panel');
|
||||||
|
disposables.push(
|
||||||
|
views.registerView(
|
||||||
|
{ id: 'some.panel', name: 'Some Panel' },
|
||||||
|
'sqllab.panels',
|
||||||
|
provider,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getActiveChatbot()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getActiveChatbot stops resolving a chatbot once it is disposed', () => {
|
||||||
|
const provider = () => React.createElement('div', null, 'Chatbot');
|
||||||
|
const disposable = views.registerView(
|
||||||
|
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||||
|
CHATBOT_LOCATION,
|
||||||
|
provider,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getActiveChatbot()?.id).toBe('superset.chatbot');
|
||||||
|
|
||||||
|
disposable.dispose();
|
||||||
|
|
||||||
|
expect(getActiveChatbot()).toBeUndefined();
|
||||||
|
});
|
||||||
87
superset-frontend/src/core/chatbot/index.ts
Normal file
87
superset-frontend/src/core/chatbot/index.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* 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<string, boolean>,
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedId =
|
||||||
|
adminSelectedId && candidates.includes(adminSelectedId)
|
||||||
|
? adminSelectedId
|
||||||
|
: candidates[0];
|
||||||
|
|
||||||
|
const provider = getViewProvider(CHATBOT_LOCATION, selectedId);
|
||||||
|
if (!provider) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: selectedId, provider };
|
||||||
|
};
|
||||||
90
superset-frontend/src/core/dashboard/index.ts
Normal file
90
superset-frontend/src/core/dashboard/index.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* 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 { 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 { createActionListener } from '../utils';
|
||||||
|
|
||||||
|
type DashboardContext = dashboardApi.DashboardContext;
|
||||||
|
type FilterValue = dashboardApi.FilterValue;
|
||||||
|
|
||||||
|
function buildDashboardContext(): DashboardContext | undefined {
|
||||||
|
const state = store.getState();
|
||||||
|
const info = (state as any).dashboardInfo;
|
||||||
|
if (!info?.id) return undefined;
|
||||||
|
|
||||||
|
const nativeFilters = (state as any).nativeFilters?.filters ?? {};
|
||||||
|
const dataMask = (state as any).dataMask ?? {};
|
||||||
|
|
||||||
|
const filters: FilterValue[] = Object.entries(dataMask)
|
||||||
|
.filter(([id, mask]: [string, any]) => {
|
||||||
|
if (!(id in nativeFilters)) return false;
|
||||||
|
const value = mask?.filterState?.value;
|
||||||
|
return value !== null && value !== undefined;
|
||||||
|
})
|
||||||
|
.map(([id, mask]: [string, any]) => ({
|
||||||
|
filterId: id,
|
||||||
|
label: nativeFilters[id]?.name ?? id,
|
||||||
|
value: mask.filterState.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
dashboardId: info.id as number,
|
||||||
|
title: info.dashboard_title ?? info.slug ?? String(info.id),
|
||||||
|
filters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardChangePredicate: AnyListenerPredicate<RootState> = 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<DashboardContext>(
|
||||||
|
dashboardChangePredicate,
|
||||||
|
listener,
|
||||||
|
() => buildDashboardContext() ?? null,
|
||||||
|
thisArgs,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dashboard: typeof dashboardApi = {
|
||||||
|
getCurrentDashboard,
|
||||||
|
onDidChangeDashboard,
|
||||||
|
};
|
||||||
62
superset-frontend/src/core/dataset/index.ts
Normal file
62
superset-frontend/src/core/dataset/index.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Disposable } from '../models';
|
||||||
|
|
||||||
|
type DatasetContext = datasetApi.DatasetContext;
|
||||||
|
|
||||||
|
let currentDataset: DatasetContext | undefined;
|
||||||
|
const listeners = new Set<(ctx: DatasetContext) => void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 => {
|
||||||
|
currentDataset = ctx;
|
||||||
|
if (ctx) {
|
||||||
|
listeners.forEach(fn => fn(ctx));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentDataset: typeof datasetApi.getCurrentDataset = () =>
|
||||||
|
currentDataset ? { ...currentDataset } : undefined;
|
||||||
|
|
||||||
|
const onDidChangeDataset: typeof datasetApi.onDidChangeDataset = (
|
||||||
|
listener: (ctx: DatasetContext) => void,
|
||||||
|
thisArgs?: any,
|
||||||
|
): Disposable => {
|
||||||
|
const bound = thisArgs ? listener.bind(thisArgs) : listener;
|
||||||
|
listeners.add(bound);
|
||||||
|
return new Disposable(() => listeners.delete(bound));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dataset: typeof datasetApi = {
|
||||||
|
getCurrentDataset,
|
||||||
|
onDidChangeDataset,
|
||||||
|
};
|
||||||
84
superset-frontend/src/core/explore/index.ts
Normal file
84
superset-frontend/src/core/explore/index.ts
Normal file
@@ -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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
SET_FORM_DATA,
|
||||||
|
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';
|
||||||
|
|
||||||
|
type ChartContext = exploreApi.ChartContext;
|
||||||
|
|
||||||
|
function buildChartContext(): ChartContext | undefined {
|
||||||
|
const state = store.getState();
|
||||||
|
const exploreState = (state as any).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<RootState> = action =>
|
||||||
|
action.type === HYDRATE_EXPLORE ||
|
||||||
|
action.type === SET_FORM_DATA ||
|
||||||
|
action.type === UPDATE_CHART_TITLE ||
|
||||||
|
action.type === SET_DATASOURCE;
|
||||||
|
|
||||||
|
const getCurrentChart: typeof exploreApi.getCurrentChart = () =>
|
||||||
|
buildChartContext();
|
||||||
|
|
||||||
|
const onDidChangeChart: typeof exploreApi.onDidChangeChart = (
|
||||||
|
listener: (ctx: ChartContext) => void,
|
||||||
|
thisArgs?: any,
|
||||||
|
) =>
|
||||||
|
createActionListener<ChartContext>(
|
||||||
|
exploreChangePredicate,
|
||||||
|
listener,
|
||||||
|
() => buildChartContext() ?? null,
|
||||||
|
thisArgs,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const explore: typeof exploreApi = {
|
||||||
|
getCurrentChart,
|
||||||
|
onDidChangeChart,
|
||||||
|
};
|
||||||
@@ -28,10 +28,14 @@ export const core: typeof coreType = {
|
|||||||
|
|
||||||
export * from './authentication';
|
export * from './authentication';
|
||||||
export * from './commands';
|
export * from './commands';
|
||||||
|
export * from './dashboard';
|
||||||
|
export * from './dataset';
|
||||||
export * from './editors';
|
export * from './editors';
|
||||||
|
export * from './explore';
|
||||||
export * from './extensions';
|
export * from './extensions';
|
||||||
export * from './menus';
|
export * from './menus';
|
||||||
export * from './models';
|
export * from './models';
|
||||||
|
export * from './navigation';
|
||||||
export * from './sqlLab';
|
export * from './sqlLab';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
export * from './views';
|
export * from './views';
|
||||||
|
|||||||
69
superset-frontend/src/core/navigation/index.ts
Normal file
69
superset-frontend/src/core/navigation/index.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 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('/explore/')) return 'explore';
|
||||||
|
if (pathname.startsWith('/superset/explore/')) return 'explore';
|
||||||
|
if (pathname.startsWith('/chart/add')) return 'explore';
|
||||||
|
if (pathname.startsWith('/sqllab/')) return 'sqllab';
|
||||||
|
if (pathname.startsWith('/dataset/')) return 'dataset';
|
||||||
|
if (pathname.startsWith('/superset/welcome/')) return 'home';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPageType: PageType = derivePageType(window.location.pathname);
|
||||||
|
|
||||||
|
/** Called by ExtensionsStartup whenever the React Router location changes. */
|
||||||
|
export const notifyPageChange = (pathname: string): void => {
|
||||||
|
const next = derivePageType(pathname);
|
||||||
|
if (next === currentPageType) return;
|
||||||
|
currentPageType = next;
|
||||||
|
listeners.forEach(fn => fn(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageType: typeof navigationApi.getPageType = () => currentPageType;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -17,7 +17,12 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { views, resolveView } from './index';
|
import {
|
||||||
|
views,
|
||||||
|
resolveView,
|
||||||
|
getViewProvider,
|
||||||
|
getRegisteredViewIds,
|
||||||
|
} from './index';
|
||||||
|
|
||||||
const disposables: Array<{ dispose: () => void }> = [];
|
const disposables: Array<{ dispose: () => void }> = [];
|
||||||
|
|
||||||
@@ -110,3 +115,59 @@ test('dispose removes the view registration', () => {
|
|||||||
|
|
||||||
expect(views.getViews('sqllab.panels')).toBeUndefined();
|
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([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -39,6 +39,27 @@ const viewRegistry: Map<
|
|||||||
|
|
||||||
const locationIndex: Map<string, Set<string>> = new Map();
|
const locationIndex: Map<string, Set<string>> = new Map();
|
||||||
|
|
||||||
|
/** Listeners notified whenever a view is registered or unregistered at a location. */
|
||||||
|
const locationListeners: Map<string, Set<() => void>> = new Map();
|
||||||
|
|
||||||
|
const notifyListeners = (location: string) => {
|
||||||
|
locationListeners.get(location)?.forEach(fn => fn());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to registration changes at a specific location.
|
||||||
|
* Returns an unsubscribe function.
|
||||||
|
*/
|
||||||
|
export const subscribeToLocation = (
|
||||||
|
location: string,
|
||||||
|
listener: () => void,
|
||||||
|
): (() => void) => {
|
||||||
|
const listeners = locationListeners.get(location) ?? new Set();
|
||||||
|
listeners.add(listener);
|
||||||
|
locationListeners.set(location, listeners);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
};
|
||||||
|
|
||||||
const registerView: typeof viewsApi.registerView = (
|
const registerView: typeof viewsApi.registerView = (
|
||||||
view: View,
|
view: View,
|
||||||
location: string,
|
location: string,
|
||||||
@@ -52,9 +73,12 @@ const registerView: typeof viewsApi.registerView = (
|
|||||||
ids.add(id);
|
ids.add(id);
|
||||||
locationIndex.set(location, ids);
|
locationIndex.set(location, ids);
|
||||||
|
|
||||||
|
notifyListeners(location);
|
||||||
|
|
||||||
return new Disposable(() => {
|
return new Disposable(() => {
|
||||||
viewRegistry.delete(id);
|
viewRegistry.delete(id);
|
||||||
locationIndex.get(location)?.delete(id);
|
locationIndex.get(location)?.delete(id);
|
||||||
|
notifyListeners(location);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,6 +101,28 @@ const getViews: typeof viewsApi.getViews = (
|
|||||||
.filter((c): c is View => !!c);
|
.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 = {
|
export const views: typeof viewsApi = {
|
||||||
registerView,
|
registerView,
|
||||||
getViews,
|
getViews,
|
||||||
|
|||||||
@@ -17,20 +17,31 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { t } from '@apache-superset/core/translation';
|
import { t } from '@apache-superset/core/translation';
|
||||||
import { FunctionComponent, useMemo } from 'react';
|
import { css } from '@apache-superset/core/theme';
|
||||||
|
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { SupersetClient } from '@superset-ui/core';
|
||||||
|
import { Select } from '@superset-ui/core/components';
|
||||||
|
import { Switch } from '@superset-ui/core/components/Switch';
|
||||||
import { useListViewResource } from 'src/views/CRUD/hooks';
|
import { useListViewResource } from 'src/views/CRUD/hooks';
|
||||||
import { ListView } from 'src/components';
|
import { ListView } from 'src/components';
|
||||||
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
|
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
|
||||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||||
|
import { CHATBOT_LOCATION } from 'src/views/contributions';
|
||||||
|
import { getRegisteredViewIds } from 'src/core/views';
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
type Extension = {
|
type Extension = {
|
||||||
id: number;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ExtensionSettings = {
|
||||||
|
active_chatbot_id: string | null;
|
||||||
|
enabled: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
interface ExtensionsListProps {
|
interface ExtensionsListProps {
|
||||||
addDangerToast: (msg: string) => void;
|
addDangerToast: (msg: string) => void;
|
||||||
addSuccessToast: (msg: string) => void;
|
addSuccessToast: (msg: string) => void;
|
||||||
@@ -50,6 +61,45 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
|
|||||||
addDangerToast,
|
addDangerToast,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [settings, setSettings] = useState<ExtensionSettings>({
|
||||||
|
active_chatbot_id: null,
|
||||||
|
enabled: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
SupersetClient.get({ endpoint: '/api/v1/extensions/settings' })
|
||||||
|
.then(({ json }) => setSettings(json.result))
|
||||||
|
.catch(() => addDangerToast(t('Failed to load extension settings.')));
|
||||||
|
}, [addDangerToast]);
|
||||||
|
|
||||||
|
const saveSettings = useCallback(
|
||||||
|
(patch: Partial<ExtensionSettings>) => {
|
||||||
|
const next = { ...settings, ...patch };
|
||||||
|
SupersetClient.put({
|
||||||
|
endpoint: '/api/v1/extensions/settings',
|
||||||
|
jsonPayload: next,
|
||||||
|
})
|
||||||
|
.then(({ json }) => {
|
||||||
|
setSettings(json.result);
|
||||||
|
addSuccessToast(t('Settings saved.'));
|
||||||
|
})
|
||||||
|
.catch(() => addDangerToast(t('Failed to save extension settings.')));
|
||||||
|
},
|
||||||
|
[settings, addDangerToast, addSuccessToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleEnabled = useCallback(
|
||||||
|
(extensionId: string, enabled: boolean) => {
|
||||||
|
saveSettings({ enabled: { ...settings.enabled, [extensionId]: enabled } });
|
||||||
|
},
|
||||||
|
[settings, saveSettings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const chatbotExtensions = useMemo(() => {
|
||||||
|
const chatbotIds = new Set(getRegisteredViewIds(CHATBOT_LOCATION));
|
||||||
|
return resourceCollection.filter(ext => chatbotIds.has(ext.id));
|
||||||
|
}, [resourceCollection]);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -58,15 +108,34 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
|
|||||||
size: 'lg',
|
size: 'lg',
|
||||||
id: 'name',
|
id: 'name',
|
||||||
Cell: ({
|
Cell: ({
|
||||||
row: {
|
row: { original: { name } },
|
||||||
original: { name },
|
|
||||||
},
|
|
||||||
}: any) => name,
|
}: any) => name,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Header: t('Enabled'),
|
||||||
|
accessor: 'enabled',
|
||||||
|
size: 'sm',
|
||||||
|
id: 'enabled',
|
||||||
|
Cell: ({
|
||||||
|
row: { original: { id, enabled } },
|
||||||
|
}: any) => (
|
||||||
|
<Switch
|
||||||
|
data-test="toggle-enabled"
|
||||||
|
checked={settings.enabled[id] ?? enabled}
|
||||||
|
onClick={(checked: boolean) => toggleEnabled(id, checked)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[loading], // We need to monitor loading to avoid stale state in actions
|
[loading, settings, toggleEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const chatbotOptions = chatbotExtensions.map(ext => ({
|
||||||
|
label: ext.name,
|
||||||
|
value: ext.id,
|
||||||
|
}));
|
||||||
|
|
||||||
const menuData: SubMenuProps = {
|
const menuData: SubMenuProps = {
|
||||||
activeChild: 'Extensions',
|
activeChild: 'Extensions',
|
||||||
name: t('Extensions'),
|
name: t('Extensions'),
|
||||||
@@ -76,6 +145,23 @@ const ExtensionsList: FunctionComponent<ExtensionsListProps> = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SubMenu {...menuData} />
|
<SubMenu {...menuData} />
|
||||||
|
{chatbotOptions.length > 1 && (
|
||||||
|
<div style={{ padding: '16px 24px' }}>
|
||||||
|
<label htmlFor="chatbot-select" style={{ marginRight: 8 }}>
|
||||||
|
{t('Default chatbot')}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
options={chatbotOptions}
|
||||||
|
value={settings.active_chatbot_id ?? undefined}
|
||||||
|
onChange={value =>
|
||||||
|
saveSettings({ active_chatbot_id: (value as string) ?? null })
|
||||||
|
}
|
||||||
|
placeholder={t('First registered (automatic)')}
|
||||||
|
css={css`width: 280px;`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ListView<Extension>
|
<ListView<Extension>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
count={resourceCount}
|
count={resourceCount}
|
||||||
|
|||||||
@@ -17,8 +17,11 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { SupersetClient } from '@superset-ui/core';
|
import { SupersetClient } from '@superset-ui/core';
|
||||||
|
import { t } from '@apache-superset/core/translation';
|
||||||
import { logging } from '@apache-superset/core/utils';
|
import { logging } from '@apache-superset/core/utils';
|
||||||
import type { common as core } from '@apache-superset/core';
|
import type { common as core } from '@apache-superset/core';
|
||||||
|
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||||
|
import { store } from 'src/views/store';
|
||||||
|
|
||||||
type Extension = core.Extension;
|
type Extension = core.Extension;
|
||||||
|
|
||||||
@@ -36,6 +39,9 @@ class ExtensionsLoader {
|
|||||||
|
|
||||||
private initializationPromise: Promise<void> | null = null;
|
private initializationPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/** Disposables returned by contribution registrations, keyed by extension id. */
|
||||||
|
private extensionDisposables: Map<string, (() => void)[]> = new Map();
|
||||||
|
|
||||||
// eslint-disable-next-line no-useless-constructor
|
// eslint-disable-next-line no-useless-constructor
|
||||||
private constructor() {
|
private constructor() {
|
||||||
// Private constructor for singleton pattern
|
// Private constructor for singleton pattern
|
||||||
@@ -88,7 +94,8 @@ class ExtensionsLoader {
|
|||||||
public async initializeExtension(extension: Extension) {
|
public async initializeExtension(extension: Extension) {
|
||||||
try {
|
try {
|
||||||
if (extension.remoteEntry) {
|
if (extension.remoteEntry) {
|
||||||
await this.loadModule(extension);
|
const disposables = await this.loadModule(extension);
|
||||||
|
this.extensionDisposables.set(extension.id, disposables);
|
||||||
}
|
}
|
||||||
this.extensionIndex.set(extension.id, extension);
|
this.extensionIndex.set(extension.id, extension);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -96,15 +103,31 @@ class ExtensionsLoader {
|
|||||||
`Failed to initialize extension ${extension.name}\n`,
|
`Failed to initialize extension ${extension.name}\n`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
|
store.dispatch(
|
||||||
|
addDangerToast(t('Extension "%s" failed to load.', extension.name)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivates an extension by disposing all of its registered contributions
|
||||||
|
* and removing it from the index.
|
||||||
|
*/
|
||||||
|
public deactivateExtension(id: string): void {
|
||||||
|
const disposables = this.extensionDisposables.get(id);
|
||||||
|
if (disposables) {
|
||||||
|
disposables.forEach(dispose => dispose());
|
||||||
|
this.extensionDisposables.delete(id);
|
||||||
|
}
|
||||||
|
this.extensionIndex.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads a single extension module via webpack module federation.
|
* Loads a single extension module via webpack module federation.
|
||||||
* The module's top-level side effects fire contribution registrations.
|
* The module's top-level side effects fire contribution registrations.
|
||||||
* @param extension The extension to load.
|
* @param extension The extension to load.
|
||||||
*/
|
*/
|
||||||
private async loadModule(extension: Extension): Promise<void> {
|
private async loadModule(extension: Extension): Promise<(() => void)[]> {
|
||||||
const { remoteEntry, id } = extension;
|
const { remoteEntry, id } = extension;
|
||||||
|
|
||||||
// Load the remote entry script
|
// Load the remote entry script
|
||||||
@@ -149,8 +172,33 @@ class ExtensionsLoader {
|
|||||||
await container.init(__webpack_share_scopes__.default);
|
await container.init(__webpack_share_scopes__.default);
|
||||||
|
|
||||||
const factory = await container.get('./index');
|
const factory = await container.get('./index');
|
||||||
// Execute the module factory - side effects fire registrations
|
|
||||||
factory();
|
// Intercept contribution registrations during module activation so we can
|
||||||
|
// collect the Disposables and drive cleanup on deactivation.
|
||||||
|
const collected: (() => void)[] = [];
|
||||||
|
const originalSuperset = window.superset;
|
||||||
|
window.superset = {
|
||||||
|
...originalSuperset,
|
||||||
|
views: {
|
||||||
|
...originalSuperset.views,
|
||||||
|
registerView: (
|
||||||
|
...args: Parameters<typeof originalSuperset.views.registerView>
|
||||||
|
) => {
|
||||||
|
const disposable = originalSuperset.views.registerView(...args);
|
||||||
|
collected.push(() => disposable.dispose());
|
||||||
|
return disposable;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute the module factory — side effects fire contribution registrations
|
||||||
|
factory();
|
||||||
|
} finally {
|
||||||
|
window.superset = originalSuperset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return collected;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,20 +16,27 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
import * as supersetCore from '@apache-superset/core';
|
import * as supersetCore from '@apache-superset/core';
|
||||||
|
import { logging } from '@apache-superset/core/utils';
|
||||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||||
import {
|
import {
|
||||||
authentication,
|
authentication,
|
||||||
core,
|
core,
|
||||||
commands,
|
commands,
|
||||||
|
dashboard,
|
||||||
|
dataset,
|
||||||
editors,
|
editors,
|
||||||
|
explore,
|
||||||
extensions,
|
extensions,
|
||||||
menus,
|
menus,
|
||||||
|
navigation,
|
||||||
sqlLab,
|
sqlLab,
|
||||||
views,
|
views,
|
||||||
} from 'src/core';
|
} from 'src/core';
|
||||||
|
import { notifyPageChange } from 'src/core/navigation';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { RootState } from 'src/views/store';
|
import { RootState } from 'src/views/store';
|
||||||
import ExtensionsLoader from './ExtensionsLoader';
|
import ExtensionsLoader from './ExtensionsLoader';
|
||||||
@@ -40,9 +47,13 @@ declare global {
|
|||||||
authentication: typeof authentication;
|
authentication: typeof authentication;
|
||||||
core: typeof core;
|
core: typeof core;
|
||||||
commands: typeof commands;
|
commands: typeof commands;
|
||||||
|
dashboard: typeof dashboard;
|
||||||
|
dataset: typeof dataset;
|
||||||
editors: typeof editors;
|
editors: typeof editors;
|
||||||
|
explore: typeof explore;
|
||||||
extensions: typeof extensions;
|
extensions: typeof extensions;
|
||||||
menus: typeof menus;
|
menus: typeof menus;
|
||||||
|
navigation: typeof navigation;
|
||||||
sqlLab: typeof sqlLab;
|
sqlLab: typeof sqlLab;
|
||||||
views: typeof views;
|
views: typeof views;
|
||||||
};
|
};
|
||||||
@@ -53,11 +64,40 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
|||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
const prevPathname = useRef<string | null>(null);
|
||||||
|
|
||||||
const userId = useSelector<RootState, number | undefined>(
|
const userId = useSelector<RootState, number | undefined>(
|
||||||
({ user }) => user.userId,
|
({ 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]);
|
||||||
|
|
||||||
|
// Isolate unhandled rejections from extension code for the lifetime of the
|
||||||
|
// app — registered once, never removed.
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||||
|
logging.error(
|
||||||
|
'[extensions] Unhandled rejection from extension:',
|
||||||
|
event.reason,
|
||||||
|
);
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(
|
||||||
|
'unhandledrejection',
|
||||||
|
handleUnhandledRejection,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialized) return;
|
if (initialized) return;
|
||||||
|
|
||||||
@@ -73,21 +113,25 @@ const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
|
|||||||
authentication,
|
authentication,
|
||||||
core,
|
core,
|
||||||
commands,
|
commands,
|
||||||
|
dashboard,
|
||||||
|
dataset,
|
||||||
editors,
|
editors,
|
||||||
|
explore,
|
||||||
extensions,
|
extensions,
|
||||||
menus,
|
menus,
|
||||||
|
navigation,
|
||||||
sqlLab,
|
sqlLab,
|
||||||
views,
|
views,
|
||||||
};
|
};
|
||||||
|
|
||||||
const setup = async () => {
|
// Render the host immediately; extension bundles load in the background.
|
||||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
// ChatbotMount re-resolves reactively once the chatbot extension registers
|
||||||
await ExtensionsLoader.getInstance().initializeExtensions();
|
// (via subscribeToLocation), so the bubble appears without blocking the UI.
|
||||||
}
|
setInitialized(true);
|
||||||
setInitialized(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
setup();
|
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||||
|
ExtensionsLoader.getInstance().initializeExtensions();
|
||||||
|
}
|
||||||
}, [initialized, userId]);
|
}, [initialized, userId]);
|
||||||
|
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import setupCodeOverrides from 'src/setup/setupCodeOverrides';
|
|||||||
import { logEvent } from 'src/logger/actions';
|
import { logEvent } from 'src/logger/actions';
|
||||||
import { store } from 'src/views/store';
|
import { store } from 'src/views/store';
|
||||||
import ExtensionsStartup from 'src/extensions/ExtensionsStartup';
|
import ExtensionsStartup from 'src/extensions/ExtensionsStartup';
|
||||||
|
import ChatbotMount from 'src/components/ChatbotMount';
|
||||||
import { RootContextProviders } from './RootContextProviders';
|
import { RootContextProviders } from './RootContextProviders';
|
||||||
import { ScrollToTop } from './ScrollToTop';
|
import { ScrollToTop } from './ScrollToTop';
|
||||||
|
|
||||||
@@ -112,6 +113,13 @@ const App = () => (
|
|||||||
</Route>
|
</Route>
|
||||||
))}
|
))}
|
||||||
</Switch>
|
</Switch>
|
||||||
|
{/*
|
||||||
|
The singleton chatbot bubble. Rendered as a sibling of the route
|
||||||
|
Switch — inside ExtensionsStartup so chatbot extensions have been
|
||||||
|
loaded and registered, but outside the Switch so the bubble persists
|
||||||
|
across route changes (SIP §3.2).
|
||||||
|
*/}
|
||||||
|
<ChatbotMount />
|
||||||
</ExtensionsStartup>
|
</ExtensionsStartup>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</RootContextProviders>
|
</RootContextProviders>
|
||||||
|
|||||||
31
superset-frontend/src/views/contributions.ts
Normal file
31
superset-frontend/src/views/contributions.ts
Normal file
@@ -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;
|
||||||
@@ -18,10 +18,15 @@ import mimetypes
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask import send_file
|
from flask import request, send_file
|
||||||
from flask.wrappers import Response
|
from flask.wrappers import Response
|
||||||
from flask_appbuilder.api import BaseApi, expose, protect, safe
|
from flask_appbuilder.api import BaseApi, expose, protect, safe
|
||||||
|
|
||||||
|
from superset.extensions import security_manager
|
||||||
|
from superset.extensions.settings import (
|
||||||
|
get_extension_settings,
|
||||||
|
update_extension_settings,
|
||||||
|
)
|
||||||
from superset.extensions.utils import (
|
from superset.extensions.utils import (
|
||||||
build_extension_data,
|
build_extension_data,
|
||||||
get_extensions,
|
get_extensions,
|
||||||
@@ -167,6 +172,53 @@ class ExtensionsRestApi(BaseApi):
|
|||||||
extension_data = build_extension_data(extension)
|
extension_data = build_extension_data(extension)
|
||||||
return self.response(200, result=extension_data)
|
return self.response(200, result=extension_data)
|
||||||
|
|
||||||
|
@protect()
|
||||||
|
@safe
|
||||||
|
@expose("/settings", methods=("GET",))
|
||||||
|
def get_settings(self, **kwargs: Any) -> Response:
|
||||||
|
"""Get global extension admin settings.
|
||||||
|
---
|
||||||
|
get:
|
||||||
|
summary: Get extension admin settings (active chatbot, enabled flags).
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Extension settings
|
||||||
|
"""
|
||||||
|
return self.response(200, result=get_extension_settings())
|
||||||
|
|
||||||
|
@protect()
|
||||||
|
@safe
|
||||||
|
@expose("/settings", methods=("PUT",))
|
||||||
|
def put_settings(self, **kwargs: Any) -> Response:
|
||||||
|
"""Update global extension admin settings.
|
||||||
|
---
|
||||||
|
put:
|
||||||
|
summary: Update extension admin settings.
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
active_chatbot_id:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enabled:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: boolean
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Updated settings
|
||||||
|
403:
|
||||||
|
$ref: '#/components/responses/403'
|
||||||
|
"""
|
||||||
|
if not security_manager.is_admin():
|
||||||
|
return self.response(403, message="Admin access required.")
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
result = update_extension_settings(body)
|
||||||
|
return self.response(200, result=result)
|
||||||
|
|
||||||
@protect()
|
@protect()
|
||||||
@safe
|
@safe
|
||||||
@expose("/<publisher>/<name>/<file>", methods=("GET",))
|
@expose("/<publisher>/<name>/<file>", methods=("GET",))
|
||||||
|
|||||||
108
superset/extensions/settings.py
Normal file
108
superset/extensions/settings.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
"""Admin settings persistence for extensions (active chatbot, enable/disable)."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||||
|
|
||||||
|
from superset import db
|
||||||
|
from superset.models.core import ExtensionEnabled, ExtensionSettings
|
||||||
|
from superset.utils.decorators import transaction
|
||||||
|
|
||||||
|
_SETTINGS_ROW_ID = 1
|
||||||
|
|
||||||
|
|
||||||
|
def get_extension_settings() -> dict[str, Any]:
|
||||||
|
row = db.session.get(ExtensionSettings, _SETTINGS_ROW_ID)
|
||||||
|
enabled_rows = db.session.query(ExtensionEnabled).all()
|
||||||
|
return {
|
||||||
|
"active_chatbot_id": row.active_chatbot_id if row else None,
|
||||||
|
"enabled": {r.extension_id: r.enabled for r in enabled_rows},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_settings_row(
|
||||||
|
active_chatbot_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Upsert the singleton settings row without a read-then-insert race."""
|
||||||
|
bind = db.session.get_bind()
|
||||||
|
dialect = bind.dialect.name
|
||||||
|
if dialect == "postgresql":
|
||||||
|
stmt = (
|
||||||
|
pg_insert(ExtensionSettings)
|
||||||
|
.values(id=_SETTINGS_ROW_ID, active_chatbot_id=active_chatbot_id)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=["id"],
|
||||||
|
set_={"active_chatbot_id": active_chatbot_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
else:
|
||||||
|
stmt = (
|
||||||
|
sqlite_insert(ExtensionSettings)
|
||||||
|
.values(id=_SETTINGS_ROW_ID, active_chatbot_id=active_chatbot_id)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=["id"],
|
||||||
|
set_={"active_chatbot_id": active_chatbot_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_enabled_flag(extension_id: str, enabled: bool) -> None:
|
||||||
|
"""Upsert a per-extension enabled flag without a read-then-insert race."""
|
||||||
|
bind = db.session.get_bind()
|
||||||
|
dialect = bind.dialect.name
|
||||||
|
if dialect == "postgresql":
|
||||||
|
stmt = (
|
||||||
|
pg_insert(ExtensionEnabled)
|
||||||
|
.values(extension_id=extension_id, enabled=enabled)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=["extension_id"],
|
||||||
|
set_={"enabled": enabled},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
else:
|
||||||
|
stmt = (
|
||||||
|
sqlite_insert(ExtensionEnabled)
|
||||||
|
.values(extension_id=extension_id, enabled=enabled)
|
||||||
|
.on_conflict_do_update(
|
||||||
|
index_elements=["extension_id"],
|
||||||
|
set_={"enabled": enabled},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction()
|
||||||
|
def update_extension_settings(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if "active_chatbot_id" in body:
|
||||||
|
value = body["active_chatbot_id"]
|
||||||
|
active_chatbot_id = str(value) if isinstance(value, str) and value else None
|
||||||
|
_upsert_settings_row(active_chatbot_id)
|
||||||
|
|
||||||
|
if "enabled" in body and isinstance(body["enabled"], dict):
|
||||||
|
for extension_id, enabled in body["enabled"].items():
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
continue
|
||||||
|
_upsert_enabled_flag(extension_id, enabled)
|
||||||
|
|
||||||
|
return get_extension_settings()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
"""Add extension_settings table for chatbot admin selection and enable/disable.
|
||||||
|
|
||||||
|
Revision ID: b2c3d4e5f6a7
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-05-25 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "b2c3d4e5f6a7"
|
||||||
|
down_revision = "a1b2c3d4e5f6"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"extension_settings",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("active_chatbot_id", sa.String(250), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"extension_enabled",
|
||||||
|
sa.Column("extension_id", sa.String(250), primary_key=True),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("extension_enabled")
|
||||||
|
op.drop_table("extension_settings")
|
||||||
@@ -108,6 +108,22 @@ class KeyValue(Model): # pylint: disable=too-few-public-methods
|
|||||||
value = Column(utils.MediumText(), nullable=False)
|
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):
|
class CssTemplate(AuditMixinNullable, UUIDMixin, Model):
|
||||||
"""CSS templates for dashboards"""
|
"""CSS templates for dashboards"""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user