mirror of
https://github.com/apache/superset.git
synced 2026-06-10 10:09:14 +00:00
Compare commits
3 Commits
feat/force
...
enxdev/cha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65c9523aa | ||
|
|
94e0071883 | ||
|
|
380e70060b |
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -213,18 +213,55 @@ export declare interface Event<T> {
|
||||
(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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
/** List of other extensions that this extension depends on */
|
||||
extensionDependencies: string[];
|
||||
/** Unique identifier for the extension */
|
||||
id: string;
|
||||
/** Human-readable name of the extension */
|
||||
|
||||
@@ -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<Record<SqlLabLocation, View[]>>;
|
||||
app?: Partial<Record<AppLocation, View[]>>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
114
superset-frontend/packages/superset-core/src/dashboard/index.ts
Normal file
114
superset-frontend/packages/superset-core/src/dashboard/index.ts
Normal file
@@ -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<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 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';
|
||||
|
||||
@@ -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<PageType>;
|
||||
@@ -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 {
|
||||
* () => <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(
|
||||
view: View,
|
||||
|
||||
@@ -519,7 +519,8 @@ const Select = forwardRef(
|
||||
handleSelectAll();
|
||||
}}
|
||||
>
|
||||
{t('Select all')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
|
||||
{t('Select all')}{' '}
|
||||
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
@@ -536,7 +537,8 @@ const Select = forwardRef(
|
||||
handleDeselectAll();
|
||||
}}
|
||||
>
|
||||
{t('Clear')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
|
||||
{t('Clear')}{' '}
|
||||
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
|
||||
</Button>
|
||||
</StyledBulkActionsContainer>
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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 { 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 () => {
|
||||
render(<ChatbotMount />);
|
||||
|
||||
// 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 () => {
|
||||
const provider = () => <div>My Chatbot Bubble</div>;
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
provider,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatbotMount />);
|
||||
|
||||
// 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', async () => {
|
||||
const firstProvider = () => <div>First Bubble</div>;
|
||||
const secondProvider = () => <div>Second Bubble</div>;
|
||||
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(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', async () => {
|
||||
const FailingChatbot = () => {
|
||||
throw new Error('chatbot blew up');
|
||||
};
|
||||
disposables.push(
|
||||
views.registerView(
|
||||
{ id: 'superset.chatbot', name: 'Superset Chatbot' },
|
||||
CHATBOT_LOCATION,
|
||||
() => <FailingChatbot />,
|
||||
),
|
||||
);
|
||||
|
||||
// The host-owned error boundary catches the failure; render does not throw.
|
||||
expect(() => render(<ChatbotMount />)).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', async () => {
|
||||
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(<ChatbotMount />)).not.toThrow();
|
||||
expect(await screen.findByTestId('chatbot-mount')).toBeInTheDocument();
|
||||
});
|
||||
124
superset-frontend/src/components/ChatbotMount/index.tsx
Normal file
124
superset-frontend/src/components/ChatbotMount/index.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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,
|
||||
useState,
|
||||
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);
|
||||
// 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,
|
||||
);
|
||||
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],
|
||||
);
|
||||
|
||||
if (!settingsLoaded || !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
|
||||
showMessage={false}
|
||||
onError={(error: Error) => {
|
||||
// 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.')));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChatbotRenderer provider={activeChatbot.provider} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatbotMount;
|
||||
132
superset-frontend/src/core/chatbot/index.test.ts
Normal file
132
superset-frontend/src/core/chatbot/index.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
79
superset-frontend/src/core/chatbot/index.ts
Normal file
79
superset-frontend/src/core/chatbot/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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.
|
||||
* - 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 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 => {
|
||||
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
|
||||
// guard is cheap defensiveness, not a fallback path.
|
||||
const selectedId =
|
||||
adminSelectedId && registeredIds.includes(adminSelectedId)
|
||||
? adminSelectedId
|
||||
: registeredIds[0];
|
||||
|
||||
const provider = getViewProvider(CHATBOT_LOCATION, selectedId);
|
||||
return provider ? { id: selectedId, provider } : undefined;
|
||||
};
|
||||
220
superset-frontend/src/core/dashboard/index.test.ts
Normal file
220
superset-frontend/src/core/dashboard/index.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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();
|
||||
});
|
||||
123
superset-frontend/src/core/dashboard/index.ts
Normal file
123
superset-frontend/src/core/dashboard/index.ts
Normal file
@@ -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<DashboardContext['charts']>[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<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,
|
||||
};
|
||||
63
superset-frontend/src/core/dataset/index.ts
Normal file
63
superset-frontend/src/core/dataset/index.ts
Normal file
@@ -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<DatasetContext | undefined>(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,
|
||||
};
|
||||
157
superset-frontend/src/core/explore/index.test.ts
Normal file
157
superset-frontend/src/core/explore/index.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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();
|
||||
});
|
||||
92
superset-frontend/src/core/explore/index.ts
Normal file
92
superset-frontend/src/core/explore/index.ts
Normal file
@@ -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<RootState> = 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<ChartContext>(
|
||||
exploreChangePredicate,
|
||||
listener,
|
||||
() => buildChartContext() ?? null,
|
||||
thisArgs,
|
||||
);
|
||||
|
||||
export const explore: typeof exploreApi = {
|
||||
getCurrentChart,
|
||||
onDidChangeChart,
|
||||
};
|
||||
@@ -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,61 @@ 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<ExtensionSettings> => {
|
||||
const { json } = await SupersetClient.get({ endpoint: SETTINGS_ENDPOINT });
|
||||
applyExtensionSettings(json.result ?? EMPTY_SETTINGS);
|
||||
return settings;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
121
superset-frontend/src/core/navigation/index.test.ts
Normal file
121
superset-frontend/src/core/navigation/index.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
82
superset-frontend/src/core/navigation/index.ts
Normal file
82
superset-frontend/src/core/navigation/index.ts
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<T>` contract.
|
||||
* Calling it with a listener (and optional `this` arg) subscribes and returns
|
||||
* a {@link Disposable} that unsubscribes.
|
||||
*/
|
||||
export type EventSubscriber<T> = (
|
||||
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<T> {
|
||||
/** Subscribe to changes; conforms to the public `Event<T>` shape. */
|
||||
event: EventSubscriber<T>;
|
||||
/** Notify all current subscribers with `value`. */
|
||||
fire: (value: T) => void;
|
||||
/** The most recently fired value (or the initial value). */
|
||||
getCurrent: () => T;
|
||||
}
|
||||
|
||||
export function createEmitter<T>(initial: T): Emitter<T> {
|
||||
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<V>(
|
||||
predicate: AnyListenerPredicate<RootState>,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -39,6 +39,27 @@ const viewRegistry: Map<
|
||||
|
||||
const locationIndex: Map<string, Set<string>> = 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,
|
||||
|
||||
@@ -1,88 +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, 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(<ExtensionsList {...defaultProps} {...props} />, {
|
||||
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();
|
||||
});
|
||||
@@ -1,95 +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 { 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<ExtensionsListProps> = ({
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
}) => {
|
||||
const {
|
||||
state: { loading, resourceCount, resourceCollection },
|
||||
fetchData,
|
||||
refreshData,
|
||||
} = useListViewResource<Extension>(
|
||||
'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 (
|
||||
<>
|
||||
<SubMenu {...menuData} />
|
||||
<ListView<Extension>
|
||||
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);
|
||||
@@ -29,15 +29,20 @@ function createMockExtension(overrides: Partial<Extension> = {}): 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', () => {
|
||||
@@ -142,3 +147,59 @@ 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<Extension> = {}) =>
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
@@ -81,7 +88,8 @@ 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.
|
||||
*/
|
||||
@@ -96,12 +104,15 @@ 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.
|
||||
* Loads a single extension module via webpack module federation and runs its
|
||||
* `activate(context)` hook.
|
||||
* @param extension The extension to load.
|
||||
*/
|
||||
private async loadModule(extension: Extension): Promise<void> {
|
||||
@@ -149,8 +160,21 @@ class ExtensionsLoader {
|
||||
await container.init(__webpack_share_scopes__.default);
|
||||
|
||||
const factory = await container.get('./index');
|
||||
// Execute the module factory - side effects fire registrations
|
||||
factory();
|
||||
|
||||
// `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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,6 +72,7 @@ afterEach(() => {
|
||||
test('renders without crashing', () => {
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -88,6 +89,7 @@ test('sets up global superset object when user is logged in', async () => {
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
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(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialStateNoUser,
|
||||
});
|
||||
|
||||
@@ -127,6 +130,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
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(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialStateNoUser,
|
||||
});
|
||||
|
||||
@@ -169,6 +174,7 @@ test('only initializes once even with multiple renders', async () => {
|
||||
|
||||
const { rerender } = render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -205,6 +211,7 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -234,6 +241,7 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -268,6 +276,7 @@ test('continues rendering children even when ExtensionsLoader initialization fai
|
||||
</ExtensionsStartup>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
const userId = useSelector<RootState, number | undefined>(
|
||||
({ 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) {
|
||||
|
||||
64
superset-frontend/src/extensions/supersetGlobal.ts
Normal file
64
superset-frontend/src/extensions/supersetGlobal.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(<EditDataset {...mockedProps} />);
|
||||
|
||||
@@ -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 = (
|
||||
<TabStyles>
|
||||
<span>{TRANSLATIONS.USAGE_TEXT}</span>
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = () => (
|
||||
</Route>
|
||||
))}
|
||||
</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).
|
||||
*/}
|
||||
{isFeatureEnabled(FeatureFlag.EnableExtensions) && <ChatbotMount />}
|
||||
</ExtensionsStartup>
|
||||
<ToastContainer />
|
||||
</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;
|
||||
@@ -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) {
|
||||
|
||||
16
superset/commands/extension/__init__.py
Normal file
16
superset/commands/extension/__init__.py
Normal file
@@ -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.
|
||||
16
superset/commands/extension/settings/__init__.py
Normal file
16
superset/commands/extension/settings/__init__.py
Normal file
@@ -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.
|
||||
29
superset/commands/extension/settings/get.py
Normal file
29
superset/commands/extension/settings/get.py
Normal file
@@ -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
|
||||
44
superset/daos/extension.py
Normal file
44
superset/daos/extension.py
Normal file
@@ -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.
|
||||
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,
|
||||
}
|
||||
@@ -15,34 +15,43 @@
|
||||
# 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 BaseApi, expose, protect, safe
|
||||
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,
|
||||
)
|
||||
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):
|
||||
def _validate_segment(value: str) -> bool:
|
||||
"""Return True if *value* is a safe publisher or name segment."""
|
||||
return bool(_SEGMENT_RE.match(value))
|
||||
|
||||
|
||||
class ExtensionsRestApi(BaseSupersetApi):
|
||||
allow_browser_login = True
|
||||
resource_name = "extensions"
|
||||
|
||||
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 response_404(self) -> Response:
|
||||
"""Helper method to create 404 responses."""
|
||||
from flask import jsonify
|
||||
|
||||
return jsonify({"message": "Not found"}), 404
|
||||
class_permission_name = "Extensions"
|
||||
base_permissions = [
|
||||
"can_get_list",
|
||||
"can_get",
|
||||
"can_content",
|
||||
"can_info",
|
||||
"can_get_settings",
|
||||
]
|
||||
|
||||
@expose("/_info", methods=("GET",))
|
||||
@protect()
|
||||
@@ -72,13 +81,13 @@ class ExtensionsRestApi(BaseApi):
|
||||
@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:
|
||||
@@ -158,7 +167,8 @@ class ExtensionsRestApi(BaseApi):
|
||||
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 +177,23 @@ class ExtensionsRestApi(BaseApi):
|
||||
extension_data = build_extension_data(extension)
|
||||
return self.response(200, result=extension_data)
|
||||
|
||||
@protect()
|
||||
@safe
|
||||
@expose("/settings", methods=("GET",))
|
||||
def get_settings(self, **kwargs: Any) -> Response:
|
||||
"""Get global extension admin settings.
|
||||
|
||||
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("/<publisher>/<name>/<file>", methods=("GET",))
|
||||
@@ -210,7 +237,8 @@ class ExtensionsRestApi(BaseApi):
|
||||
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)
|
||||
|
||||
@@ -14,21 +14,19 @@
|
||||
# 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
|
||||
"""SQLAlchemy models for extension settings persistence."""
|
||||
|
||||
from flask_appbuilder import Model
|
||||
from sqlalchemy import Column, Integer, String
|
||||
|
||||
# Column length for extension/chatbot identifiers.
|
||||
EXTENSION_ID_MAX_LENGTH = 250
|
||||
|
||||
|
||||
class ExtensionsView(BaseSupersetView):
|
||||
route_base = "/extensions"
|
||||
class_permission_name = "Extensions"
|
||||
method_permission_name = MODEL_VIEW_RW_METHOD_PERMISSION_MAP
|
||||
class ExtensionSettings(Model): # pylint: disable=too-few-public-methods
|
||||
"""Global admin settings for extensions (singleton row, id=1)."""
|
||||
|
||||
@expose("/list/")
|
||||
@has_access
|
||||
@permission_name("read")
|
||||
def list(self) -> FlaskResponse:
|
||||
return super().render_app_template()
|
||||
__tablename__ = "extension_settings"
|
||||
id = Column(Integer, primary_key=True)
|
||||
active_chatbot_id = Column(String(EXTENSION_ID_MAX_LENGTH), nullable=True)
|
||||
@@ -238,10 +238,10 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
|
||||
manifest = extension.manifest
|
||||
extension_data: dict[str, Any] = {
|
||||
"id": manifest.id,
|
||||
"publisher": manifest.publisher,
|
||||
"name": extension.name,
|
||||
"version": extension.version,
|
||||
"description": manifest.description or "",
|
||||
"dependencies": manifest.dependencies,
|
||||
}
|
||||
if manifest.frontend:
|
||||
frontend = manifest.frontend
|
||||
|
||||
@@ -177,7 +177,6 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
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 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
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",
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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()),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
46
tests/unit_tests/extensions/test_api.py
Normal file
46
tests/unit_tests/extensions/test_api.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# 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/<publisher>/<name>
|
||||
# and GET /api/v1/extensions/<publisher>/<name>/<file>
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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
|
||||
77
tests/unit_tests/extensions/test_settings.py
Normal file
77
tests/unit_tests/extensions/test_settings.py
Normal file
@@ -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.
|
||||
|
||||
"""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"
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user