mirror of
https://github.com/apache/superset.git
synced 2026-06-13 19:49:18 +00:00
Compare commits
14 Commits
url-param-
...
enxdev/cha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1eba0f9a1 | ||
|
|
568337f370 | ||
|
|
f170dc1d9e | ||
|
|
09c09f3f6b | ||
|
|
c65c9523aa | ||
|
|
94e0071883 | ||
|
|
2f71771b56 | ||
|
|
d7ddf2023d | ||
|
|
c58408d76c | ||
|
|
1188cfef1d | ||
|
|
fb0e7fecaf | ||
|
|
3afbb48188 | ||
|
|
837f41986d | ||
|
|
380e70060b |
@@ -55,6 +55,13 @@ WORKDIR /app/superset-frontend
|
||||
RUN mkdir -p /app/superset/static/assets \
|
||||
/app/superset/translations
|
||||
|
||||
# Harden `npm ci` against transient npm-registry network blips (e.g. ECONNRESET),
|
||||
# which otherwise fail the entire multi-platform image build with no retry.
|
||||
ENV npm_config_fetch_retries=5 \
|
||||
npm_config_fetch_retry_mintimeout=20000 \
|
||||
npm_config_fetch_retry_maxtimeout=120000 \
|
||||
npm_config_fetch_timeout=600000
|
||||
|
||||
# Mount package files and install dependencies if not in dev mode
|
||||
# NOTE: we mount packages and plugins as they are referenced in package.json as workspaces
|
||||
# ideally we'd COPY only their package.json. Here npm ci will be cached as long
|
||||
|
||||
14
UPDATING.md
14
UPDATING.md
@@ -44,6 +44,20 @@ The embedded dashboard page now validates the origin of incoming `postMessage` e
|
||||
|
||||
Enforcement only applies when the Allowed Domains list is non-empty. If the list is empty (the default), any origin is accepted, so there is no behavior change for embeds that did not configure Allowed Domains.
|
||||
|
||||
### Default guest/async JWT secrets are rejected at startup
|
||||
|
||||
Superset already refuses to start in production (non-debug, non-testing) when `SECRET_KEY` is left at its built-in default, and when `GUEST_TOKEN_JWT_SECRET` is left at its default while `EMBEDDED_SUPERSET` is enabled. This behavior is extended to `GLOBAL_ASYNC_QUERIES_JWT_SECRET`: if the `GLOBAL_ASYNC_QUERIES` feature flag is enabled and the secret is still the publicly known default (`test-secret-change-me`), Superset logs a clear error and refuses to start.
|
||||
|
||||
As with the existing `SECRET_KEY` check, this only fails in production. In debug mode, testing mode, or under the test runner, a warning is logged instead of exiting, so local development is unaffected.
|
||||
|
||||
To resolve the error, set a strong random value in `superset_config.py`:
|
||||
|
||||
```python
|
||||
GLOBAL_ASYNC_QUERIES_JWT_SECRET = "<output of: openssl rand -base64 42>"
|
||||
```
|
||||
|
||||
The check is only active when the relevant feature is enabled, so deployments that do not use global async queries (or embedding) are not affected.
|
||||
|
||||
### Dataset import validates catalog against the target connection
|
||||
|
||||
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.
|
||||
|
||||
@@ -18,6 +18,26 @@
|
||||
"types": "./lib/authentication/index.d.ts",
|
||||
"default": "./lib/authentication/index.js"
|
||||
},
|
||||
"./chat": {
|
||||
"types": "./lib/chat/index.d.ts",
|
||||
"default": "./lib/chat/index.js"
|
||||
},
|
||||
"./dashboard": {
|
||||
"types": "./lib/dashboard/index.d.ts",
|
||||
"default": "./lib/dashboard/index.js"
|
||||
},
|
||||
"./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"
|
||||
|
||||
185
superset-frontend/packages/superset-core/src/chat/index.ts
Normal file
185
superset-frontend/packages/superset-core/src/chat/index.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Chat contribution API for Superset extensions.
|
||||
*
|
||||
* Chat is a dedicated contribution type (not a view): an extension registers
|
||||
* a chat via {@link registerChat} and the host owns where and how it is
|
||||
* mounted. The host applies singleton resolution — multiple chat extensions
|
||||
* may register, but exactly one is active at a time.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { chat } from '@apache-superset/core';
|
||||
*
|
||||
* chat.registerChat(
|
||||
* { id: 'acme.chat', name: 'Acme Chat' },
|
||||
* () => <AcmeTrigger />,
|
||||
* () => <AcmePanel />,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import type { Disposable, Event } from '../common';
|
||||
|
||||
export interface Chat {
|
||||
/** The unique identifier for the chat. */
|
||||
id: string;
|
||||
/** The display name of the chat. */
|
||||
name: string;
|
||||
/** Optional description of the chat, for display in contribution manifests. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type ChatMode = 'floating' | 'panel';
|
||||
|
||||
/**
|
||||
* Registers a chat provider. The host applies singleton resolution — only one
|
||||
* chat is active at a time: the most recently registered chat wins, and
|
||||
* disposing it restores the previously registered one. Re-registering an id
|
||||
* replaces that registration in place.
|
||||
*
|
||||
* When a registration with a different id takes over the active slot (or the
|
||||
* active chat is disposed), the host closes the panel first, firing
|
||||
* {@link onDidClose}; an in-place same-id replacement keeps the open state.
|
||||
*
|
||||
* Disposing the returned Disposable unregisters the chat.
|
||||
*
|
||||
* @param chat The chat descriptor (id, name).
|
||||
* @param trigger A function returning the collapsed bubble element. Owned by
|
||||
* the extension — dynamic state such as unread counts and badges lives here.
|
||||
* Hidden by the host when in panel mode.
|
||||
* @param panel A function returning the chat panel element. Mounted by the
|
||||
* host as a floating overlay in 'floating' mode, or docked at the side of
|
||||
* the viewport in 'panel' mode (the reference host docks a fixed-width
|
||||
* overlay at the right edge; hosts may integrate a true layout slot
|
||||
* instead). Same component in both modes.
|
||||
* @returns A Disposable that unregisters the chat when disposed.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* chat.registerChat(
|
||||
* { id: 'acme.chat', name: 'Acme Chat' },
|
||||
* () => <AcmeTrigger />,
|
||||
* () => <AcmePanel />,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export declare function registerChat(
|
||||
chat: Chat,
|
||||
trigger: () => ReactElement,
|
||||
panel: () => ReactElement,
|
||||
): Disposable;
|
||||
|
||||
/**
|
||||
* Returns the active chat descriptor.
|
||||
*
|
||||
* @returns A copy of the active Chat descriptor, or undefined if none is
|
||||
* registered. Mutating the returned object has no effect on the registry.
|
||||
*/
|
||||
export declare function getChat(): Chat | undefined;
|
||||
|
||||
/**
|
||||
* Event fired when a chat is registered.
|
||||
*/
|
||||
export declare const onDidRegisterChat: Event<Chat>;
|
||||
|
||||
/**
|
||||
* Event fired when a chat is unregistered.
|
||||
*/
|
||||
export declare const onDidUnregisterChat: Event<Chat>;
|
||||
|
||||
/**
|
||||
* Opens the active chat's panel.
|
||||
*
|
||||
* Acts on whichever chat is active, regardless of which extension calls it.
|
||||
* No-op when no chat is registered or the panel is already open.
|
||||
*/
|
||||
export declare function open(): void;
|
||||
|
||||
/**
|
||||
* Closes the active chat's panel.
|
||||
*
|
||||
* Acts on whichever chat is active, regardless of which extension calls it.
|
||||
* No-op when the panel is not open.
|
||||
*/
|
||||
export declare function close(): void;
|
||||
|
||||
/**
|
||||
* Returns whether the active chat's panel is currently open.
|
||||
*
|
||||
* @returns True if the chat panel is open.
|
||||
*/
|
||||
export declare function isOpen(): boolean;
|
||||
|
||||
/**
|
||||
* Event fired when the chat panel opens, with the descriptor of the chat
|
||||
* whose panel opened. Listen to this rather than assuming your own chat is
|
||||
* the one affected — another registration may hold the active slot.
|
||||
*/
|
||||
export declare const onDidOpen: Event<Chat>;
|
||||
|
||||
/**
|
||||
* Event fired when the chat panel closes, with the descriptor of the chat
|
||||
* whose panel closed. Also fired when the host closes the panel itself, e.g.
|
||||
* because the active chat was disposed or displaced by a different chat.
|
||||
*/
|
||||
export declare const onDidClose: Event<Chat>;
|
||||
|
||||
/**
|
||||
* Returns the current display mode.
|
||||
*
|
||||
* @returns The current ChatMode.
|
||||
*/
|
||||
export declare function getMode(): ChatMode;
|
||||
|
||||
/**
|
||||
* Sets the display mode.
|
||||
*
|
||||
* The mode is host-global and applies to whichever chat is active, regardless
|
||||
* of which extension calls it. Hosts may also change the mode through their
|
||||
* own controls — use onDidChangeMode to observe all changes rather than
|
||||
* assuming the last setMode() call won.
|
||||
*
|
||||
* @param mode The display mode to switch to.
|
||||
*/
|
||||
export declare function setMode(mode: ChatMode): void;
|
||||
|
||||
/**
|
||||
* Event fired when the display mode changes, whether triggered by an
|
||||
* extension via setMode() or by host-provided controls.
|
||||
*/
|
||||
export declare const onDidChangeMode: Event<ChatMode>;
|
||||
|
||||
/**
|
||||
* Event fired when the panel is resized in panel mode.
|
||||
*
|
||||
* The host owns the resizer handle and drag interaction; a host without a
|
||||
* resizer never fires this event. (The reference host mounts the panel at a
|
||||
* fixed width and does not provide a resizer, so subscribers receive no
|
||||
* events there.) Listen to this event to adapt internal layout to the
|
||||
* available width; do not rely on it firing.
|
||||
*/
|
||||
export declare const onDidResizePanel: Event<{ width: number }>;
|
||||
|
||||
// TODO: client actions API — tool availability functions will be added here
|
||||
// once the client_actions SIP is finalized. The chat namespace is the
|
||||
// intended integration point between the two SIPs.
|
||||
@@ -213,6 +213,47 @@ 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
|
||||
@@ -223,8 +264,6 @@ export interface Extension {
|
||||
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 */
|
||||
|
||||
@@ -23,9 +23,10 @@
|
||||
* This module defines the aggregate interfaces used by the extension.json
|
||||
* manifest and the `superset-extensions` build command. Individual metadata
|
||||
* types are defined in their respective namespace modules (commands, views,
|
||||
* menus, editors) and re-exported here for the manifest schema.
|
||||
* menus, editors, chat) and re-exported here for the manifest schema.
|
||||
*/
|
||||
|
||||
import { Chat } from '../chat';
|
||||
import { Command } from '../commands';
|
||||
import { View } from '../views';
|
||||
import { Menu } from '../menus';
|
||||
@@ -71,7 +72,8 @@ export interface MenuContributions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
|
||||
* Aggregates all contributions (commands, menus, views, editors, and chat)
|
||||
* provided by an extension or module.
|
||||
*/
|
||||
export interface Contributions {
|
||||
/** List of commands. */
|
||||
@@ -82,4 +84,10 @@ export interface Contributions {
|
||||
views: ViewContributions;
|
||||
/** List of editors. */
|
||||
editors?: Editor[];
|
||||
/**
|
||||
* The chat contributed by the extension — at most one per extension, since
|
||||
* the host applies singleton resolution and renders exactly one active
|
||||
* chat at a time.
|
||||
*/
|
||||
chat?: Chat;
|
||||
}
|
||||
|
||||
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>;
|
||||
@@ -18,10 +18,15 @@
|
||||
*/
|
||||
export * as common from './common';
|
||||
export * as authentication from './authentication';
|
||||
export * as chat from './chat';
|
||||
export * as commands from './commands';
|
||||
export * as dashboard from './dashboard';
|
||||
export * as dataset from './dataset';
|
||||
export * as editors from './editors';
|
||||
export * as explore from './explore';
|
||||
export * as extensions from './extensions';
|
||||
export * as menus from './menus';
|
||||
export * as navigation from './navigation';
|
||||
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>;
|
||||
@@ -508,6 +508,12 @@ export interface ThemeContextType {
|
||||
clearLocalOverrides: () => void;
|
||||
getCurrentCrudThemeId: () => string | null;
|
||||
hasDevOverride: () => boolean;
|
||||
/**
|
||||
* True when an explicit theme config override is active (e.g. supplied via
|
||||
* the Embedded SDK). Such an override takes precedence over a
|
||||
* dashboard-level theme.
|
||||
*/
|
||||
hasThemeConfigOverride: boolean;
|
||||
canSetMode: () => boolean;
|
||||
canSetTheme: () => boolean;
|
||||
canDetectOSPreference: () => boolean;
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -25,6 +25,7 @@ import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Logger } from 'src/logger/LogUtils';
|
||||
import { EmptyState, Tooltip } from '@superset-ui/core/components';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import { detectOS } from 'src/utils/common';
|
||||
import * as Actions from 'src/SqlLab/actions/sqlLab';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
@@ -176,14 +177,16 @@ class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
|
||||
key: qe.id,
|
||||
label: <SqlEditorTabHeader queryEditor={qe} />,
|
||||
children: (
|
||||
<SqlEditor
|
||||
queryEditor={qe}
|
||||
defaultQueryLimit={this.props.defaultQueryLimit}
|
||||
maxRow={this.props.maxRow}
|
||||
displayLimit={this.props.displayLimit}
|
||||
saveQueryWarning={this.props.saveQueryWarning}
|
||||
scheduleQueryWarning={this.props.scheduleQueryWarning}
|
||||
/>
|
||||
<ErrorBoundary>
|
||||
<SqlEditor
|
||||
queryEditor={qe}
|
||||
defaultQueryLimit={this.props.defaultQueryLimit}
|
||||
maxRow={this.props.maxRow}
|
||||
displayLimit={this.props.displayLimit}
|
||||
saveQueryWarning={this.props.saveQueryWarning}
|
||||
scheduleQueryWarning={this.props.scheduleQueryWarning}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
287
superset-frontend/src/components/ChatMount/ChatMount.test.tsx
Normal file
287
superset-frontend/src/components/ChatMount/ChatMount.test.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { act, render, screen } from 'spec/helpers/testing-library';
|
||||
import { chat } from 'src/core/chat';
|
||||
import ChatMount from '.';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
// Reset host-owned state shared across tests in this module.
|
||||
chat.close();
|
||||
chat.setMode('floating');
|
||||
});
|
||||
});
|
||||
|
||||
test('renders nothing when no chat extension is registered', () => {
|
||||
render(<ChatMount />);
|
||||
|
||||
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the trigger bubble of the registered chat', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
|
||||
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
// The panel stays unmounted until the chat is opened.
|
||||
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('mounts the panel when the chat opens and unmounts it on close', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
|
||||
act(() => chat.open());
|
||||
|
||||
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
|
||||
// In floating mode the trigger stays mounted alongside the open panel.
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
|
||||
act(() => chat.close());
|
||||
|
||||
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the last-registered chat when several are installed', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'first.chat', name: 'First Chat' },
|
||||
() => <div>First Bubble</div>,
|
||||
() => <div>First Panel</div>,
|
||||
),
|
||||
chat.registerChat(
|
||||
{ id: 'second.chat', name: 'Second Chat' },
|
||||
() => <div>Second Bubble</div>,
|
||||
() => <div>Second Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
|
||||
// Last-loaded wins: the second registration takes over the singleton slot.
|
||||
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
|
||||
expect(screen.queryByText('First Bubble')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('reacts to a chat registering after the initial render', () => {
|
||||
render(<ChatMount />);
|
||||
|
||||
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a takeover mounts the incoming chat closed', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'first.chat', name: 'First Chat' },
|
||||
() => <div>First Bubble</div>,
|
||||
() => <div>First Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
act(() => chat.open());
|
||||
expect(screen.getByText('First Panel')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'second.chat', name: 'Second Chat' },
|
||||
() => <div>Second Bubble</div>,
|
||||
() => <div>Second Panel</div>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// The displaced chat's open state must not leak into the winner.
|
||||
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Second Panel')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('First Panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('panel mode docks the open panel and hides the trigger', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
|
||||
act(() => {
|
||||
chat.setMode('panel');
|
||||
chat.open();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Acme Bubble')).not.toBeInTheDocument();
|
||||
|
||||
act(() => chat.close());
|
||||
|
||||
// A closed chat in panel mode renders nothing — the trigger is hidden too.
|
||||
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a crashing panel does not take the trigger down with it', () => {
|
||||
const FailingPanel = () => {
|
||||
throw new Error('panel blew up');
|
||||
};
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <button type="button">Acme Bubble</button>,
|
||||
() => <FailingPanel />,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
act(() => chat.open());
|
||||
|
||||
// The panel's boundary contains the crash; the trigger keeps rendering so
|
||||
// the user is not stranded without a way back.
|
||||
expect(screen.queryByText('panel blew up')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('isolates a failing trigger so it does not crash the host', () => {
|
||||
const FailingTrigger = () => {
|
||||
throw new Error('chat blew up');
|
||||
};
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <FailingTrigger />,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
// The host-owned error boundary catches the failure; render does not throw.
|
||||
expect(() => render(<ChatMount />)).not.toThrow();
|
||||
// The mount slot still renders (the boundary lives inside it), confirming
|
||||
// the provider was actually exercised and contained.
|
||||
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('isolates a chat whose provider function itself throws', () => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => {
|
||||
throw new Error('provider blew up');
|
||||
},
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
// ChatRenderer wraps provider() in a component so ErrorBoundary catches
|
||||
// synchronous throws from the provider function, not just from its output.
|
||||
expect(() => render(<ChatMount />)).not.toThrow();
|
||||
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('recovers from a crashed chat when a different chat takes over', () => {
|
||||
const FailingTrigger = () => {
|
||||
throw new Error('first chat blew up');
|
||||
};
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'first.chat', name: 'First Chat' },
|
||||
() => <FailingTrigger />,
|
||||
() => <div>First Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'second.chat', name: 'Second Chat' },
|
||||
() => <div>Second Bubble</div>,
|
||||
() => <div>Second Panel</div>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// The boundary is keyed per registration, so the latched crash from the
|
||||
// first chat does not blank the second one.
|
||||
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('recovers when a crashed chat re-registers a fixed version under the same id', () => {
|
||||
const FailingTrigger = () => {
|
||||
throw new Error('broken release');
|
||||
};
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <FailingTrigger />,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChatMount />);
|
||||
expect(screen.queryByText('Fixed Bubble')).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
disposables.push(
|
||||
chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme Chat' },
|
||||
() => <div>Fixed Bubble</div>,
|
||||
() => <div>Acme Panel</div>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// Same id, new registrationId: the remounted boundary renders the fix.
|
||||
expect(screen.getByText('Fixed Bubble')).toBeInTheDocument();
|
||||
});
|
||||
149
superset-frontend/src/components/ChatMount/index.tsx
Normal file
149
superset-frontend/src/components/ChatMount/index.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { type ReactElement, useRef, useSyncExternalStore } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { ErrorBoundary } from 'src/components/ErrorBoundary';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { store } from 'src/views/store';
|
||||
import { getChatSnapshot, subscribeToChatState } from 'src/core/chat';
|
||||
|
||||
const CHAT_EDGE_MARGIN = 24;
|
||||
const PANEL_MODE_WIDTH = 400;
|
||||
|
||||
/**
|
||||
* Wraps a chat provider in a React component so that ErrorBoundary can catch
|
||||
* synchronous throws from the provider function itself. Calling `provider()`
|
||||
* inline (e.g. `{activeChat.panel()}`) would throw outside React's render
|
||||
* boundary and crash the host.
|
||||
*/
|
||||
const ChatRenderer = ({ provider }: { provider: () => ReactElement }) =>
|
||||
provider();
|
||||
|
||||
const ChatMount = () => {
|
||||
const theme = useTheme();
|
||||
// Notify at most once per registration; a crash can re-render and would
|
||||
// otherwise re-toast, while a replacement (new registrationId) deserves a
|
||||
// fresh notification if it crashes too.
|
||||
const crashNotifiedFor = useRef<number | null>(null);
|
||||
|
||||
// The active chat, the open state, and the display mode are read from one
|
||||
// immutable registry snapshot so a render never mixes state from two
|
||||
// different store versions (the tearing useSyncExternalStore prevents).
|
||||
const {
|
||||
open: panelOpen,
|
||||
mode,
|
||||
active,
|
||||
} = useSyncExternalStore(subscribeToChatState, getChatSnapshot);
|
||||
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { registrationId } = active;
|
||||
|
||||
const onProviderError = (error: Error) => {
|
||||
// Fault isolation: contain the crash, log it, surface a one-time
|
||||
// notification, and leave the slot empty rather than parking a
|
||||
// persistent error card.
|
||||
logging.error('[chat] provider crashed', error);
|
||||
if (crashNotifiedFor.current !== registrationId) {
|
||||
crashNotifiedFor.current = registrationId;
|
||||
store.dispatch(addDangerToast(t('The chat failed to load.')));
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === 'panel') {
|
||||
// Panel mode hides the trigger and docks the panel to the right edge.
|
||||
// Interim approximation of the "layout slot between header and footer"
|
||||
// from the chat API contract — the dock overlays the page until the host
|
||||
// grows a real layout slot and resizer chrome.
|
||||
if (!panelOpen) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-test="chat-mount"
|
||||
css={css`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: ${PANEL_MODE_WIDTH}px;
|
||||
background: ${theme.colorBgContainer};
|
||||
box-shadow: ${theme.boxShadow};
|
||||
/* Above dashboard content and the toast layer, below modal dialogs. */
|
||||
z-index: ${theme.zIndexPopupBase + 2};
|
||||
`}
|
||||
>
|
||||
<ErrorBoundary
|
||||
key={registrationId}
|
||||
showMessage={false}
|
||||
onError={onProviderError}
|
||||
>
|
||||
<ChatRenderer provider={active.panel} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-test="chat-mount"
|
||||
css={css`
|
||||
position: fixed;
|
||||
right: ${CHAT_EDGE_MARGIN}px;
|
||||
bottom: ${CHAT_EDGE_MARGIN}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
/* Above dashboard content and the toast layer, below modal dialogs. */
|
||||
z-index: ${theme.zIndexPopupBase + 2};
|
||||
`}
|
||||
>
|
||||
{/*
|
||||
Each provider gets its own boundary so a crashing panel cannot take
|
||||
the trigger down with it (the trigger is the user's only way back).
|
||||
Keyed by registrationId: Superset's ErrorBoundary latches its error
|
||||
state, so a takeover, fallback, or same-id re-registration must
|
||||
remount the boundary to recover.
|
||||
*/}
|
||||
{panelOpen && (
|
||||
<ErrorBoundary
|
||||
key={`panel-${registrationId}`}
|
||||
showMessage={false}
|
||||
onError={onProviderError}
|
||||
>
|
||||
<ChatRenderer provider={active.panel} />
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<ErrorBoundary
|
||||
key={`trigger-${registrationId}`}
|
||||
showMessage={false}
|
||||
onError={onProviderError}
|
||||
>
|
||||
<ChatRenderer provider={active.trigger} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMount;
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
isThemeConfigDark,
|
||||
} from '@apache-superset/core/theme';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { ThemeContext } from 'src/theme/ThemeProvider';
|
||||
import type { ThemeContextType } from '@apache-superset/core/theme';
|
||||
import CrudThemeProvider from './CrudThemeProvider';
|
||||
|
||||
jest.mock('@apache-superset/core/theme', () => ({
|
||||
@@ -307,6 +309,59 @@ test('ignores non-array fontUrls in theme config without throwing', () => {
|
||||
expect(fontStyle).toBeNull();
|
||||
});
|
||||
|
||||
test('skips the dashboard theme when an SDK theme config override is active', () => {
|
||||
const themeConfig = {
|
||||
token: {
|
||||
colorPrimary: '#ff0000',
|
||||
fontUrls: ['https://fonts.example.com/dashboard.css'],
|
||||
},
|
||||
};
|
||||
render(
|
||||
<ThemeContext.Provider
|
||||
value={{ hasThemeConfigOverride: true } as unknown as ThemeContextType}
|
||||
>
|
||||
<CrudThemeProvider
|
||||
theme={{
|
||||
id: 1,
|
||||
theme_name: 'Custom Theme',
|
||||
json_data: JSON.stringify(themeConfig),
|
||||
}}
|
||||
>
|
||||
<div>Dashboard Content</div>
|
||||
</CrudThemeProvider>
|
||||
</ThemeContext.Provider>,
|
||||
);
|
||||
|
||||
// The SDK override wins: the dashboard theme provider must not wrap children.
|
||||
expect(screen.getByText('Dashboard Content')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('dashboard-theme-provider'),
|
||||
).not.toBeInTheDocument();
|
||||
// The override fully owns theming, so dashboard fonts must not be injected.
|
||||
expect(document.querySelector('style[data-superset-fonts]')).toBeNull();
|
||||
});
|
||||
|
||||
test('applies the dashboard theme when no SDK theme config override is active', () => {
|
||||
const themeConfig = { token: { colorPrimary: '#ff0000' } };
|
||||
render(
|
||||
<ThemeContext.Provider
|
||||
value={{ hasThemeConfigOverride: false } as unknown as ThemeContextType}
|
||||
>
|
||||
<CrudThemeProvider
|
||||
theme={{
|
||||
id: 1,
|
||||
theme_name: 'Custom Theme',
|
||||
json_data: JSON.stringify(themeConfig),
|
||||
}}
|
||||
>
|
||||
<div>Dashboard Content</div>
|
||||
</CrudThemeProvider>
|
||||
</ThemeContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('dashboard-theme-provider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not inject font style element when no fontUrls in config', () => {
|
||||
const themeConfig = { token: { colorPrimary: '#ff0000' } };
|
||||
render(
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode, useEffect, useMemo } from 'react';
|
||||
import { ReactNode, useContext, useEffect, useMemo } from 'react';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import {
|
||||
Theme,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
isThemeConfigDark,
|
||||
} from '@apache-superset/core/theme';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { ThemeContext } from 'src/theme/ThemeProvider';
|
||||
import type { Dashboard } from 'src/types/Dashboard';
|
||||
|
||||
interface CrudThemeProviderProps {
|
||||
@@ -41,8 +42,18 @@ export default function CrudThemeProvider({
|
||||
children,
|
||||
theme,
|
||||
}: CrudThemeProviderProps) {
|
||||
// An explicit theme config override (e.g. supplied via the Embedded SDK)
|
||||
// applies on the global theme controller and must win over the
|
||||
// dashboard-level theme. When such an override is active, skip the
|
||||
// dashboard theme so the override is not shadowed by this nested provider.
|
||||
const themeContext = useContext(ThemeContext);
|
||||
const hasThemeConfigOverride = themeContext?.hasThemeConfigOverride ?? false;
|
||||
|
||||
const { dashboardTheme, fontUrls } = useMemo(() => {
|
||||
if (!theme?.json_data) {
|
||||
// When an SDK override is active it fully owns theming, so skip parsing the
|
||||
// dashboard theme entirely. This also prevents the font-injection effect
|
||||
// below from loading dashboard fonts the override does not use.
|
||||
if (hasThemeConfigOverride || !theme?.json_data) {
|
||||
return { dashboardTheme: null, fontUrls: undefined };
|
||||
}
|
||||
try {
|
||||
@@ -64,7 +75,7 @@ export default function CrudThemeProvider({
|
||||
logging.warn('Failed to load dashboard theme:', error);
|
||||
return { dashboardTheme: null, fontUrls: undefined };
|
||||
}
|
||||
}, [theme?.json_data]);
|
||||
}, [theme?.json_data, hasThemeConfigOverride]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardTheme || !fontUrls?.length) return undefined;
|
||||
@@ -83,7 +94,7 @@ export default function CrudThemeProvider({
|
||||
};
|
||||
}, [dashboardTheme, fontUrls]);
|
||||
|
||||
if (!dashboardTheme) {
|
||||
if (!dashboardTheme || hasThemeConfigOverride) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
331
superset-frontend/src/core/chat/index.test.ts
Normal file
331
superset-frontend/src/core/chat/index.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createElement } from 'react';
|
||||
import { chat, getActiveChat, getChatSnapshot } from './index';
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [];
|
||||
|
||||
const trigger = () => createElement('button', null, 'Bubble');
|
||||
const panel = () => createElement('div', null, 'Panel');
|
||||
|
||||
afterEach(() => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
disposables.length = 0;
|
||||
// Reset host-owned state shared across tests in this module.
|
||||
chat.close();
|
||||
chat.setMode('floating');
|
||||
});
|
||||
|
||||
test('getChat returns undefined when no chat is registered', () => {
|
||||
expect(chat.getChat()).toBeUndefined();
|
||||
expect(getActiveChat()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('registerChat resolves the registered chat with its providers', () => {
|
||||
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
|
||||
disposables.push(chat.registerChat(descriptor, trigger, panel));
|
||||
|
||||
expect(chat.getChat()).toEqual(descriptor);
|
||||
expect(getActiveChat()).toMatchObject({ chat: descriptor, trigger, panel });
|
||||
});
|
||||
|
||||
test('getChat returns a copy that cannot mutate the registry', () => {
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme Chat' }, trigger, panel),
|
||||
);
|
||||
|
||||
const copy = chat.getChat();
|
||||
copy!.name = 'Hijacked';
|
||||
|
||||
expect(chat.getChat()?.name).toBe('Acme Chat');
|
||||
});
|
||||
|
||||
test('the last-registered chat wins when multiple are installed', () => {
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
|
||||
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
|
||||
);
|
||||
|
||||
expect(chat.getChat()?.id).toBe('second.chat');
|
||||
});
|
||||
|
||||
test('disposing the active chat falls back to the previous registration', () => {
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
|
||||
);
|
||||
const second = chat.registerChat(
|
||||
{ id: 'second.chat', name: 'Second' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
|
||||
expect(chat.getChat()?.id).toBe('second.chat');
|
||||
|
||||
second.dispose();
|
||||
|
||||
expect(chat.getChat()?.id).toBe('first.chat');
|
||||
});
|
||||
|
||||
test('re-registering an id replaces the previous registration', () => {
|
||||
const stalePanel = () => createElement('div', null, 'Stale');
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, stalePanel),
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
|
||||
);
|
||||
|
||||
expect(chat.getChat()?.name).toBe('Acme v2');
|
||||
expect(getActiveChat()?.panel).toBe(panel);
|
||||
});
|
||||
|
||||
test('each registration gets a distinct registrationId, including same-id replacements', () => {
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
|
||||
);
|
||||
const first = getActiveChat()?.registrationId;
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
|
||||
);
|
||||
const second = getActiveChat()?.registrationId;
|
||||
|
||||
expect(first).toBeDefined();
|
||||
expect(second).toBeDefined();
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
test('disposing a registration twice unregisters only once', () => {
|
||||
const unregistered = jest.fn();
|
||||
disposables.push(chat.onDidUnregisterChat(unregistered));
|
||||
|
||||
const registration = chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
registration.dispose();
|
||||
registration.dispose();
|
||||
|
||||
expect(unregistered).toHaveBeenCalledTimes(1);
|
||||
expect(chat.getChat()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => {
|
||||
const registered = jest.fn();
|
||||
const unregistered = jest.fn();
|
||||
disposables.push(
|
||||
chat.onDidRegisterChat(registered),
|
||||
chat.onDidUnregisterChat(unregistered),
|
||||
);
|
||||
|
||||
const descriptor = { id: 'acme.chat', name: 'Acme' };
|
||||
const registration = chat.registerChat(descriptor, trigger, panel);
|
||||
|
||||
expect(registered).toHaveBeenCalledWith(descriptor);
|
||||
expect(unregistered).not.toHaveBeenCalled();
|
||||
|
||||
registration.dispose();
|
||||
|
||||
expect(unregistered).toHaveBeenCalledWith(descriptor);
|
||||
});
|
||||
|
||||
test('a disposed event subscription stops receiving notifications', () => {
|
||||
const registered = jest.fn();
|
||||
const subscription = chat.onDidRegisterChat(registered);
|
||||
subscription.dispose();
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
|
||||
);
|
||||
|
||||
expect(registered).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('open and close toggle the panel and fire once with the active descriptor', () => {
|
||||
const opened = jest.fn();
|
||||
const closed = jest.fn();
|
||||
disposables.push(chat.onDidOpen(opened), chat.onDidClose(closed));
|
||||
|
||||
const descriptor = { id: 'acme.chat', name: 'Acme' };
|
||||
disposables.push(chat.registerChat(descriptor, trigger, panel));
|
||||
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
|
||||
chat.open();
|
||||
// Opening an already-open panel is a no-op and must not re-fire.
|
||||
chat.open();
|
||||
|
||||
expect(chat.isOpen()).toBe(true);
|
||||
expect(opened).toHaveBeenCalledTimes(1);
|
||||
expect(opened).toHaveBeenCalledWith(descriptor);
|
||||
|
||||
chat.close();
|
||||
chat.close();
|
||||
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
expect(closed).toHaveBeenCalledWith(descriptor);
|
||||
});
|
||||
|
||||
test('open is a no-op while no chat is registered', () => {
|
||||
const opened = jest.fn();
|
||||
disposables.push(chat.onDidOpen(opened));
|
||||
|
||||
chat.open();
|
||||
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
expect(opened).not.toHaveBeenCalled();
|
||||
|
||||
// A registration arriving later therefore starts closed.
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
|
||||
);
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('a takeover by a different id closes the displaced chat panel', () => {
|
||||
const closed = jest.fn();
|
||||
disposables.push(chat.onDidClose(closed));
|
||||
|
||||
const first = { id: 'first.chat', name: 'First' };
|
||||
disposables.push(chat.registerChat(first, trigger, panel));
|
||||
chat.open();
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
|
||||
);
|
||||
|
||||
// The incoming chat must not mount into an open state it never requested.
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
expect(closed).toHaveBeenCalledWith(first);
|
||||
});
|
||||
|
||||
test('a same-id replacement keeps the open state', () => {
|
||||
const closed = jest.fn();
|
||||
disposables.push(chat.onDidClose(closed));
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
|
||||
);
|
||||
chat.open();
|
||||
|
||||
// Upgrade in place: same id, new providers.
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
|
||||
);
|
||||
|
||||
expect(chat.isOpen()).toBe(true);
|
||||
expect(closed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('disposing the active chat while open closes it; the fallback starts closed', () => {
|
||||
const closed = jest.fn();
|
||||
disposables.push(chat.onDidClose(closed));
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
|
||||
);
|
||||
const second = { id: 'second.chat', name: 'Second' };
|
||||
const registration = chat.registerChat(second, trigger, panel);
|
||||
chat.open();
|
||||
|
||||
registration.dispose();
|
||||
|
||||
expect(chat.getChat()?.id).toBe('first.chat');
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
expect(closed).toHaveBeenCalledTimes(1);
|
||||
expect(closed).toHaveBeenCalledWith(second);
|
||||
});
|
||||
|
||||
test('disposing an inactive registration leaves the open state untouched', () => {
|
||||
const closed = jest.fn();
|
||||
disposables.push(chat.onDidClose(closed));
|
||||
|
||||
const inactive = chat.registerChat(
|
||||
{ id: 'first.chat', name: 'First' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
|
||||
);
|
||||
chat.open();
|
||||
|
||||
inactive.dispose();
|
||||
|
||||
expect(chat.isOpen()).toBe(true);
|
||||
expect(closed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('disposing the last chat while open resets the open state', () => {
|
||||
const registration = chat.registerChat(
|
||||
{ id: 'acme.chat', name: 'Acme' },
|
||||
trigger,
|
||||
panel,
|
||||
);
|
||||
chat.open();
|
||||
expect(chat.isOpen()).toBe(true);
|
||||
|
||||
registration.dispose();
|
||||
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
|
||||
// A registration arriving much later must not inherit a stale open state.
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel),
|
||||
);
|
||||
expect(chat.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
test('mode defaults to floating and setMode fires only on change', () => {
|
||||
const modeChanged = jest.fn();
|
||||
disposables.push(chat.onDidChangeMode(modeChanged));
|
||||
|
||||
expect(chat.getMode()).toBe('floating');
|
||||
|
||||
// Setting the current mode is a no-op.
|
||||
chat.setMode('floating');
|
||||
expect(modeChanged).not.toHaveBeenCalled();
|
||||
|
||||
chat.setMode('panel');
|
||||
expect(chat.getMode()).toBe('panel');
|
||||
expect(modeChanged).toHaveBeenCalledWith('panel');
|
||||
});
|
||||
|
||||
test('the snapshot is immutable per version and consistent with the registry', () => {
|
||||
const before = getChatSnapshot();
|
||||
|
||||
disposables.push(
|
||||
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
|
||||
);
|
||||
chat.open();
|
||||
|
||||
const after = getChatSnapshot();
|
||||
// Unchanged references for old snapshots; a new object per change.
|
||||
expect(after).not.toBe(before);
|
||||
expect(before.active).toBeUndefined();
|
||||
expect(after).toMatchObject({
|
||||
open: true,
|
||||
mode: 'floating',
|
||||
active: getActiveChat(),
|
||||
});
|
||||
expect(after.version).toBeGreaterThan(before.version);
|
||||
// Stable reference between changes.
|
||||
expect(getChatSnapshot()).toBe(after);
|
||||
});
|
||||
238
superset-frontend/src/core/chat/index.ts
Normal file
238
superset-frontend/src/core/chat/index.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Host implementation of the `chat` contribution type.
|
||||
*
|
||||
* Chat is a dedicated contribution type, not a view: extensions register via
|
||||
* the public `chat.registerChat()` and the host owns mounting, open/close
|
||||
* state, and the display mode. Multiple chat extensions may register, but the
|
||||
* host applies singleton resolution — the most-recently-registered chat is
|
||||
* active; disposing it falls back to the previous one.
|
||||
*
|
||||
* Open-state policy across active-chat transitions: when the active chat's
|
||||
* identity changes — a takeover by a different id, disposal falling back to a
|
||||
* different id, or disposal of the last chat — the panel is closed (firing
|
||||
* `onDidClose`) so the incoming chat never mounts into an open state it did
|
||||
* not request. A same-id re-registration is an upgrade in place and keeps the
|
||||
* open state.
|
||||
*
|
||||
* The public namespace (`chat`) is exposed to extensions on
|
||||
* `window.superset`; the other exports are host-internal accessors for
|
||||
* ChatMount and are NOT part of the public `@apache-superset/core` API.
|
||||
*/
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import type { chat as chatApi } from '@apache-superset/core';
|
||||
import { Disposable } from '../models';
|
||||
import { createEmitter, createEventEmitter } from '../utils';
|
||||
|
||||
type Chat = chatApi.Chat;
|
||||
type ChatMode = chatApi.ChatMode;
|
||||
|
||||
/** A registered chat: its descriptor plus the host-mountable providers. */
|
||||
export interface RegisteredChat {
|
||||
/** The chat descriptor passed to `registerChat`. */
|
||||
chat: Chat;
|
||||
/** Renders the collapsed bubble. Hidden by the host in panel mode. */
|
||||
trigger: () => ReactElement;
|
||||
/** Renders the chat panel, mounted per the current {@link ChatMode}. */
|
||||
panel: () => ReactElement;
|
||||
/**
|
||||
* Unique per registration (a same-id re-registration gets a new one). The
|
||||
* host UI keys mounts and fault containment on it, so a replacement resets
|
||||
* crashed error boundaries instead of inheriting their latched state.
|
||||
*/
|
||||
registrationId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable snapshot of the whole chat state, rebuilt on every change.
|
||||
* Returned by reference from `getChatSnapshot` so `useSyncExternalStore`
|
||||
* consumers read registrations, open state, and mode from one consistent
|
||||
* object instead of tearing across separate live reads.
|
||||
*/
|
||||
export interface ChatSnapshot {
|
||||
/** Monotonic change counter, useful as a memo/effect dependency. */
|
||||
version: number;
|
||||
/** Whether the active chat's panel is open. */
|
||||
open: boolean;
|
||||
/** The current display mode. */
|
||||
mode: ChatMode;
|
||||
/** The active registration, or undefined when none is registered. */
|
||||
active: RegisteredChat | undefined;
|
||||
}
|
||||
|
||||
/** Registration order is the singleton-resolution order: last entry wins. */
|
||||
const registrations: RegisteredChat[] = [];
|
||||
|
||||
let panelOpen = false;
|
||||
let nextRegistrationId = 1;
|
||||
|
||||
const registerEmitter = createEventEmitter<Chat>();
|
||||
const unregisterEmitter = createEventEmitter<Chat>();
|
||||
const openEmitter = createEventEmitter<Chat>();
|
||||
const closeEmitter = createEventEmitter<Chat>();
|
||||
const resizePanelEmitter = createEventEmitter<{ width: number }>();
|
||||
const modeEmitter = createEmitter<ChatMode>('floating');
|
||||
|
||||
/**
|
||||
* Host-internal: resolves the active chat with its providers.
|
||||
* The most-recently-registered chat wins; when it is disposed the previous
|
||||
* registration takes over the slot again.
|
||||
*/
|
||||
export const getActiveChat = (): RegisteredChat | undefined =>
|
||||
registrations[registrations.length - 1];
|
||||
|
||||
let snapshot: ChatSnapshot = {
|
||||
version: 0,
|
||||
open: false,
|
||||
mode: modeEmitter.getCurrent(),
|
||||
active: undefined,
|
||||
};
|
||||
|
||||
const stateSubscribers = new Set<() => void>();
|
||||
|
||||
const notifyState = () => {
|
||||
snapshot = {
|
||||
version: snapshot.version + 1,
|
||||
open: panelOpen,
|
||||
mode: modeEmitter.getCurrent(),
|
||||
active: getActiveChat(),
|
||||
};
|
||||
stateSubscribers.forEach(fn => fn());
|
||||
};
|
||||
|
||||
export const subscribeToChatState = (listener: () => void): (() => void) => {
|
||||
stateSubscribers.add(listener);
|
||||
return () => {
|
||||
stateSubscribers.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const getChatSnapshot = (): ChatSnapshot => snapshot;
|
||||
|
||||
/** Closes the panel and fires `onDidClose` with the chat that was closed. */
|
||||
const closePanel = (closedChat: Chat) => {
|
||||
panelOpen = false;
|
||||
closeEmitter.fire(closedChat);
|
||||
};
|
||||
|
||||
const registerChat: typeof chatApi.registerChat = (
|
||||
chat: Chat,
|
||||
trigger: () => ReactElement,
|
||||
panel: () => ReactElement,
|
||||
): Disposable => {
|
||||
const previousActive = getActiveChat();
|
||||
|
||||
// Re-registering an id replaces the previous entry and moves it to the
|
||||
// most-recent position, mirroring the view registry's same-id semantics.
|
||||
const existingIndex = registrations.findIndex(r => r.chat.id === chat.id);
|
||||
if (existingIndex !== -1) {
|
||||
registrations.splice(existingIndex, 1);
|
||||
}
|
||||
|
||||
const entry: RegisteredChat = {
|
||||
chat,
|
||||
trigger,
|
||||
panel,
|
||||
registrationId: nextRegistrationId,
|
||||
};
|
||||
nextRegistrationId += 1;
|
||||
registrations.push(entry);
|
||||
registerEmitter.fire(chat);
|
||||
|
||||
// A takeover by a different id closes the displaced chat's panel so the
|
||||
// incoming chat never mounts already-open; a same-id replacement is an
|
||||
// upgrade in place and keeps the open state.
|
||||
if (panelOpen && previousActive && previousActive.chat.id !== chat.id) {
|
||||
closePanel(previousActive.chat);
|
||||
}
|
||||
notifyState();
|
||||
|
||||
return new Disposable(() => {
|
||||
const index = registrations.indexOf(entry);
|
||||
if (index === -1) {
|
||||
// Already removed — replaced by a same-id registration or disposed twice.
|
||||
return;
|
||||
}
|
||||
const wasActive = getActiveChat() === entry;
|
||||
registrations.splice(index, 1);
|
||||
unregisterEmitter.fire(chat);
|
||||
// Disposing the active chat closes its panel; the fallback chat (if any)
|
||||
// starts closed. Disposing an inactive registration leaves the open
|
||||
// state of the active chat untouched.
|
||||
if (panelOpen && wasActive) {
|
||||
closePanel(chat);
|
||||
}
|
||||
notifyState();
|
||||
});
|
||||
};
|
||||
|
||||
const getChat: typeof chatApi.getChat = (): Chat | undefined => {
|
||||
const active = getActiveChat();
|
||||
// Copy so extensions cannot mutate another extension's descriptor.
|
||||
return active ? { ...active.chat } : undefined;
|
||||
};
|
||||
|
||||
const open: typeof chatApi.open = (): void => {
|
||||
const active = getActiveChat();
|
||||
// Open state only exists while a chat is registered; opening an empty slot
|
||||
// would otherwise leak `open` into a future, unrelated registration.
|
||||
if (panelOpen || !active) return;
|
||||
panelOpen = true;
|
||||
openEmitter.fire(active.chat);
|
||||
notifyState();
|
||||
};
|
||||
|
||||
const close: typeof chatApi.close = (): void => {
|
||||
const active = getActiveChat();
|
||||
if (!panelOpen || !active) return;
|
||||
closePanel(active.chat);
|
||||
notifyState();
|
||||
};
|
||||
|
||||
const isOpen: typeof chatApi.isOpen = (): boolean => panelOpen;
|
||||
|
||||
const getMode: typeof chatApi.getMode = (): ChatMode =>
|
||||
modeEmitter.getCurrent();
|
||||
|
||||
const setMode: typeof chatApi.setMode = (mode: ChatMode): void => {
|
||||
if (mode === modeEmitter.getCurrent()) return;
|
||||
modeEmitter.fire(mode);
|
||||
notifyState();
|
||||
};
|
||||
|
||||
export const chat: typeof chatApi = {
|
||||
registerChat,
|
||||
getChat,
|
||||
onDidRegisterChat: registerEmitter.event,
|
||||
onDidUnregisterChat: unregisterEmitter.event,
|
||||
open,
|
||||
close,
|
||||
isOpen,
|
||||
onDidOpen: openEmitter.event,
|
||||
onDidClose: closeEmitter.event,
|
||||
getMode,
|
||||
setMode,
|
||||
onDidChangeMode: modeEmitter.event,
|
||||
// The host fires this from its panel resizer; until that chrome exists the
|
||||
// event is exposed but never fires.
|
||||
onDidResizePanel: resizePanelEmitter.event,
|
||||
};
|
||||
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; 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,
|
||||
};
|
||||
@@ -27,11 +27,16 @@ export const core: typeof coreType = {
|
||||
};
|
||||
|
||||
export * from './authentication';
|
||||
export * from './chat';
|
||||
export * from './commands';
|
||||
export * from './dashboard';
|
||||
export * from './dataset';
|
||||
export * from './editors';
|
||||
export * from './explore';
|
||||
export * from './extensions';
|
||||
export * from './menus';
|
||||
export * from './models';
|
||||
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,
|
||||
};
|
||||
@@ -46,6 +46,11 @@ 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();
|
||||
@@ -53,8 +58,9 @@ const registerView: typeof viewsApi.registerView = (
|
||||
locationIndex.set(location, ids);
|
||||
|
||||
return new Disposable(() => {
|
||||
const registeredLocation = viewRegistry.get(id)?.location ?? location;
|
||||
viewRegistry.delete(id);
|
||||
locationIndex.get(location)?.delete(id);
|
||||
locationIndex.get(registeredLocation)?.delete(id);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -95,6 +97,7 @@ test('sets up global superset object when user is logged in', async () => {
|
||||
// Verify the global superset object is set up
|
||||
expect((window as any).superset).toBeDefined();
|
||||
expect((window as any).superset.authentication).toBeDefined();
|
||||
expect((window as any).superset.chat).toBeDefined();
|
||||
expect((window as any).superset.core).toBeDefined();
|
||||
expect((window as any).superset.commands).toBeDefined();
|
||||
expect((window as any).superset.extensions).toBeDefined();
|
||||
@@ -109,6 +112,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 +131,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -144,6 +149,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 +175,7 @@ test('only initializes once even with multiple renders', async () => {
|
||||
|
||||
const { rerender } = render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -205,6 +212,7 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -234,6 +242,7 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
|
||||
|
||||
render(<ExtensionsStartup />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
});
|
||||
|
||||
@@ -268,6 +277,7 @@ test('continues rendering children even when ExtensionsLoader initialization fai
|
||||
</ExtensionsStartup>,
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: mockInitialState,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -16,48 +16,67 @@
|
||||
* 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,
|
||||
chat,
|
||||
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 +86,34 @@ 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,
|
||||
chat,
|
||||
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.
|
||||
// ChatMount re-resolves reactively once a chat extension registers (via
|
||||
// subscribeToChatState / getChatSnapshot), so the bubble appears
|
||||
// without blocking the UI.
|
||||
setInitialized(true);
|
||||
|
||||
setup();
|
||||
if (isFeatureEnabled(FeatureFlag.EnableExtensions)) {
|
||||
ExtensionsLoader.getInstance().initializeExtensions();
|
||||
}
|
||||
}, [initialized, userId]);
|
||||
|
||||
if (!initialized) {
|
||||
|
||||
66
superset-frontend/src/extensions/supersetGlobal.ts
Normal file
66
superset-frontend/src/extensions/supersetGlobal.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 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,
|
||||
chat,
|
||||
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;
|
||||
chat: typeof chat;
|
||||
commands: typeof commands;
|
||||
dashboard: typeof dashboard;
|
||||
dataset: typeof dataset;
|
||||
editors: typeof editors;
|
||||
explore: typeof explore;
|
||||
extensions: typeof extensions;
|
||||
menus: typeof menus;
|
||||
navigation: typeof navigation;
|
||||
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>
|
||||
|
||||
@@ -24,10 +24,12 @@ import { useSqlLabInitialState } from 'src/hooks/apiResources/sqlLab';
|
||||
import type { InitialState } from 'src/hooks/apiResources/sqlLab';
|
||||
import { resetState } from 'src/SqlLab/actions/sqlLab';
|
||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||
import { ErrorAlert } from 'src/components/ErrorMessage';
|
||||
import type { SqlLabRootState } from 'src/SqlLab/types';
|
||||
import { SqlLabGlobalStyles } from 'src/SqlLab//SqlLabGlobalStyles';
|
||||
import App from 'src/SqlLab/components/App';
|
||||
import { Loading } from '@superset-ui/core/components';
|
||||
import { Button, Loading } from '@superset-ui/core/components';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import EditorAutoSync from 'src/SqlLab/components/EditorAutoSync';
|
||||
import useEffectEvent from 'src/hooks/useEffectEvent';
|
||||
import { LocationProvider } from './LocationContext';
|
||||
@@ -36,7 +38,7 @@ export default function SqlLab() {
|
||||
const lastInitializedAt = useSelector<SqlLabRootState, number>(
|
||||
state => state.sqlLab.queriesLastUpdate || 0,
|
||||
);
|
||||
const { data, isLoading, isError, error, fulfilledTimeStamp } =
|
||||
const { data, isLoading, isError, error, fulfilledTimeStamp, refetch } =
|
||||
useSqlLabInitialState();
|
||||
const shouldInitialize = lastInitializedAt <= (fulfilledTimeStamp || 0);
|
||||
const dispatch = useDispatch();
|
||||
@@ -55,11 +57,39 @@ export default function SqlLab() {
|
||||
}
|
||||
}, [data, initBootstrapData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
dispatch(addDangerToast(error?.message || t('An error occurred')));
|
||||
}
|
||||
}, [isError, error, dispatch]);
|
||||
|
||||
if (isLoading || shouldInitialize) return <Loading />;
|
||||
|
||||
if (isError && error?.message) {
|
||||
dispatch(addDangerToast(error?.message));
|
||||
return null;
|
||||
if (isError) {
|
||||
return (
|
||||
<div
|
||||
css={css`
|
||||
padding: 24px;
|
||||
`}
|
||||
>
|
||||
<ErrorAlert
|
||||
errorType={t('Could not load SQL Lab')}
|
||||
message={t(
|
||||
'An error occurred while loading SQL Lab. This may be caused by a corrupted query state.',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
onClick={refetch}
|
||||
css={css`
|
||||
margin-top: 16px;
|
||||
`}
|
||||
>
|
||||
{t('Reload SQL Lab')}
|
||||
</Button>
|
||||
</ErrorAlert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -99,6 +99,11 @@ export class ThemeController {
|
||||
|
||||
private dashboardCrudTheme: AnyThemeConfig | null = null;
|
||||
|
||||
// Tracks whether an explicit theme config override has been applied via
|
||||
// setThemeConfig (e.g. from the Embedded SDK). When set, it must take
|
||||
// precedence over a dashboard-level theme.
|
||||
private themeConfigOverride = false;
|
||||
|
||||
// Track loaded font URLs to avoid duplicate injections
|
||||
private loadedFontUrls: Set<string> = new Set();
|
||||
|
||||
@@ -467,6 +472,15 @@ export class ThemeController {
|
||||
return this.devThemeOverride !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an explicit theme config override has been applied via
|
||||
* setThemeConfig (e.g. from the Embedded SDK). When true, this override
|
||||
* takes precedence over any dashboard-level theme.
|
||||
*/
|
||||
public hasThemeConfigOverride(): boolean {
|
||||
return this.themeConfigOverride;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the applied theme ID (for UI display purposes).
|
||||
*/
|
||||
@@ -512,6 +526,7 @@ export class ThemeController {
|
||||
public setThemeConfig(config: SupersetThemeConfig): void {
|
||||
this.defaultTheme = config.theme_default;
|
||||
this.darkTheme = config.theme_dark || null;
|
||||
this.themeConfigOverride = true;
|
||||
|
||||
let newMode: ThemeMode;
|
||||
try {
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from '@apache-superset/core/theme';
|
||||
import { ThemeController } from './ThemeController';
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||
export const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
@@ -52,6 +52,10 @@ export function SupersetThemeProvider({
|
||||
themeController.getCurrentMode(),
|
||||
);
|
||||
|
||||
const [hasThemeConfigOverride, setHasThemeConfigOverride] = useState<boolean>(
|
||||
themeController.hasThemeConfigOverride(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: Once we migrate to react>=18 is should be possible
|
||||
// to replace the useState and useEffect with a singular
|
||||
@@ -59,6 +63,7 @@ export function SupersetThemeProvider({
|
||||
const updateState = (theme: Theme) => {
|
||||
setCurrentTheme(theme);
|
||||
setCurrentThemeMode(themeController.getCurrentMode());
|
||||
setHasThemeConfigOverride(themeController.hasThemeConfigOverride());
|
||||
document.documentElement.setAttribute(
|
||||
'data-theme-mode',
|
||||
themeController.getCurrentModeResolved(),
|
||||
@@ -143,6 +148,7 @@ export function SupersetThemeProvider({
|
||||
clearLocalOverrides,
|
||||
getCurrentCrudThemeId,
|
||||
hasDevOverride,
|
||||
hasThemeConfigOverride,
|
||||
canSetMode,
|
||||
canSetTheme,
|
||||
canDetectOSPreference,
|
||||
@@ -159,6 +165,7 @@ export function SupersetThemeProvider({
|
||||
clearLocalOverrides,
|
||||
getCurrentCrudThemeId,
|
||||
hasDevOverride,
|
||||
hasThemeConfigOverride,
|
||||
canSetMode,
|
||||
canSetTheme,
|
||||
canDetectOSPreference,
|
||||
|
||||
@@ -1082,6 +1082,24 @@ test('setThemeConfig sets complete theme configuration', () => {
|
||||
expect(controller.canSetMode()).toBe(true);
|
||||
});
|
||||
|
||||
test('setThemeConfig flags an active theme config override', () => {
|
||||
mockGetBootstrapData.mockReturnValue(
|
||||
createMockBootstrapData({ default: {}, dark: {} }),
|
||||
);
|
||||
|
||||
const controller = createController({ defaultTheme: { token: {} } });
|
||||
|
||||
// No override until setThemeConfig is called (e.g. from the Embedded SDK).
|
||||
expect(controller.hasThemeConfigOverride()).toBe(false);
|
||||
|
||||
controller.setThemeConfig({
|
||||
theme_default: DEFAULT_THEME,
|
||||
theme_dark: DARK_THEME,
|
||||
});
|
||||
|
||||
expect(controller.hasThemeConfigOverride()).toBe(true);
|
||||
});
|
||||
|
||||
test('setThemeConfig handles theme_default only', () => {
|
||||
mockGetBootstrapData.mockReturnValue(
|
||||
createMockBootstrapData({
|
||||
|
||||
@@ -86,6 +86,7 @@ describe('SupersetThemeProvider', () => {
|
||||
clearLocalOverrides: jest.fn(),
|
||||
getCurrentCrudThemeId: jest.fn().mockReturnValue(null),
|
||||
hasDevOverride: jest.fn().mockReturnValue(false),
|
||||
hasThemeConfigOverride: jest.fn().mockReturnValue(false),
|
||||
canSetMode: jest.fn().mockReturnValue(true),
|
||||
canSetTheme: jest.fn().mockReturnValue(true),
|
||||
canDetectOSPreference: jest.fn().mockReturnValue(true),
|
||||
|
||||
@@ -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 ChatMount from 'src/components/ChatMount';
|
||||
import { RootContextProviders } from './RootContextProviders';
|
||||
import { ScrollToTop } from './ScrollToTop';
|
||||
|
||||
@@ -112,6 +114,13 @@ const App = () => (
|
||||
</Route>
|
||||
))}
|
||||
</Switch>
|
||||
{/*
|
||||
The singleton chat slot. Rendered as a sibling of the route
|
||||
Switch — inside ExtensionsStartup so chat extensions have been
|
||||
loaded and registered, but outside the Switch so the chat persists
|
||||
across route changes.
|
||||
*/}
|
||||
{isFeatureEnabled(FeatureFlag.EnableExtensions) && <ChatMount />}
|
||||
</ExtensionsStartup>
|
||||
<ToastContainer />
|
||||
</RootContextProviders>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
# limitations under the License.
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
# Harden `npm ci` against transient npm-registry network blips (e.g. ECONNRESET),
|
||||
# which otherwise fail the image build with no retry.
|
||||
ENV npm_config_fetch_retries=5 \
|
||||
npm_config_fetch_retry_mintimeout=20000 \
|
||||
npm_config_fetch_retry_maxtimeout=120000 \
|
||||
npm_config_fetch_timeout=600000
|
||||
|
||||
WORKDIR /home/superset-websocket
|
||||
|
||||
COPY . ./
|
||||
@@ -24,7 +31,12 @@ RUN npm ci && \
|
||||
|
||||
FROM node:22-alpine
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# Retry npm-registry fetches so a transient blip doesn't fail the build.
|
||||
ENV NODE_ENV=production \
|
||||
npm_config_fetch_retries=5 \
|
||||
npm_config_fetch_retry_mintimeout=20000 \
|
||||
npm_config_fetch_retry_maxtimeout=120000 \
|
||||
npm_config_fetch_timeout=600000
|
||||
WORKDIR /home/superset-websocket
|
||||
|
||||
COPY --from=build /home/superset-websocket/dist ./dist
|
||||
|
||||
@@ -23,6 +23,7 @@ from zipfile import BadZipfile, is_zipfile, ZipFile
|
||||
|
||||
import pandas as pd
|
||||
import pyarrow.parquet as pq
|
||||
from flask import current_app
|
||||
from flask_babel import lazy_gettext as _
|
||||
from pyarrow.lib import ArrowException
|
||||
from werkzeug.datastructures import FileStorage
|
||||
@@ -33,10 +34,47 @@ from superset.commands.database.uploaders.base import (
|
||||
FileMetadata,
|
||||
ReaderOptions,
|
||||
)
|
||||
from superset.exceptions import SupersetException
|
||||
from superset.utils.core import check_is_safe_zip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_file_size(file: FileStorage) -> None:
|
||||
"""
|
||||
Reject an uploaded file whose raw (on-the-wire) size exceeds the configured
|
||||
limit before its contents are buffered into memory.
|
||||
|
||||
This is complementary to the ZIP decompression-ratio guard: it bounds the
|
||||
raw bytes accepted regardless of whether the payload is compressed.
|
||||
|
||||
:param file: The uploaded file to check.
|
||||
:throws DatabaseUploadFailed: if the file exceeds the configured limit.
|
||||
"""
|
||||
max_size = current_app.config.get("UPLOAD_MAX_FILE_SIZE_BYTES")
|
||||
if not max_size:
|
||||
return
|
||||
stream = file.stream
|
||||
try:
|
||||
current_position = stream.tell()
|
||||
stream.seek(0, 2) # seek to end
|
||||
size = stream.tell()
|
||||
stream.seek(current_position)
|
||||
except (AttributeError, OSError):
|
||||
# If the stream is not seekable we cannot determine the size cheaply;
|
||||
# skip the check and rely on downstream guards.
|
||||
return
|
||||
if size > max_size:
|
||||
raise DatabaseUploadFailed(
|
||||
_(
|
||||
"File size %(size)s bytes exceeds the maximum allowed "
|
||||
"upload size of %(max_size)s bytes",
|
||||
size=size,
|
||||
max_size=max_size,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ColumnarReaderOptions(ReaderOptions, total=False):
|
||||
columns_read: list[str]
|
||||
|
||||
@@ -80,6 +118,7 @@ class ColumnarReader(BaseDataReader):
|
||||
:param file: The file to yield files from.
|
||||
:return: A generator that yields files.
|
||||
"""
|
||||
_check_file_size(file)
|
||||
file_suffix = Path(file.filename).suffix
|
||||
if not file_suffix:
|
||||
raise DatabaseUploadFailed(_("Unexpected no file extension found"))
|
||||
@@ -89,6 +128,12 @@ class ColumnarReader(BaseDataReader):
|
||||
raise DatabaseUploadFailed(_("Not a valid ZIP file"))
|
||||
try:
|
||||
with ZipFile(file) as zip_file:
|
||||
# guard against decompression bombs before reading entries,
|
||||
# mirroring the importer path
|
||||
try:
|
||||
check_is_safe_zip(zip_file)
|
||||
except SupersetException as ex:
|
||||
raise DatabaseUploadFailed(str(ex)) from ex
|
||||
# check if all file types are of the same extension
|
||||
file_suffixes = {Path(name).suffix for name in zip_file.namelist()}
|
||||
if len(file_suffixes) > 1:
|
||||
|
||||
@@ -14,21 +14,3 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from flask_appbuilder import expose
|
||||
from flask_appbuilder.security.decorators import has_access, permission_name
|
||||
|
||||
from superset.constants import MODEL_VIEW_RW_METHOD_PERMISSION_MAP
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.views.base import BaseSupersetView
|
||||
|
||||
|
||||
class ExtensionsView(BaseSupersetView):
|
||||
route_base = "/extensions"
|
||||
class_permission_name = "Extensions"
|
||||
method_permission_name = MODEL_VIEW_RW_METHOD_PERMISSION_MAP
|
||||
|
||||
@expose("/list/")
|
||||
@has_access
|
||||
@permission_name("read")
|
||||
def list(self) -> FlaskResponse:
|
||||
return super().render_app_template()
|
||||
@@ -51,7 +51,11 @@ from sqlalchemy.orm.query import Query
|
||||
from superset.advanced_data_type.plugins.internet_address import internet_address
|
||||
from superset.advanced_data_type.plugins.internet_port import internet_port
|
||||
from superset.advanced_data_type.types import AdvancedDataType
|
||||
from superset.constants import CHANGE_ME_GUEST_TOKEN_JWT_SECRET, CHANGE_ME_SECRET_KEY
|
||||
from superset.constants import (
|
||||
CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET,
|
||||
CHANGE_ME_GUEST_TOKEN_JWT_SECRET,
|
||||
CHANGE_ME_SECRET_KEY,
|
||||
)
|
||||
from superset.jinja_context import BaseTemplateProcessor
|
||||
from superset.key_value.types import JsonKeyValueCodec
|
||||
from superset.stats_logger import DummyStatsLogger
|
||||
@@ -178,6 +182,11 @@ VIZ_TIME_COMPARE_MAX = 50
|
||||
# amplification from a single multi-layer request.
|
||||
DECK_MULTI_MAX_SLICES = 50
|
||||
|
||||
# Upper bound on the page size accepted by the generic DAO list/pagination layer.
|
||||
# Caps how many rows a single paginated query can request, regardless of the
|
||||
# requested page size, to keep query result sets bounded.
|
||||
SQLALCHEMY_DAO_MAX_PAGE_SIZE = 1000
|
||||
|
||||
# SupersetClient HTTP retry configuration
|
||||
# Controls retry behavior for all HTTP requests made through SupersetClient
|
||||
# This helps handle transient server errors (like 502 Bad Gateway) automatically
|
||||
@@ -1131,6 +1140,12 @@ SCREENSHOT_TILED_VIEWPORT_HEIGHT = 2000 # Height of each tile in pixels
|
||||
UPLOAD_FOLDER = BASE_DIR + "/static/uploads/"
|
||||
UPLOAD_CHUNK_SIZE = 4096
|
||||
|
||||
# Upper bound, in bytes, on the size of a single uploaded data file (e.g. CSV,
|
||||
# Excel, columnar). Files larger than this are rejected before their contents
|
||||
# are buffered into memory, keeping the resources consumed by a single upload
|
||||
# bounded. Set to ``None`` to disable the check. Defaults to 100 MB.
|
||||
UPLOAD_MAX_FILE_SIZE_BYTES: int | None = 100 * 1024 * 1024
|
||||
|
||||
# ---------------------------------------------------
|
||||
# Cache configuration
|
||||
# ---------------------------------------------------
|
||||
@@ -2355,7 +2370,7 @@ GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE: None | (Literal["None", "Lax", "Strict
|
||||
None
|
||||
)
|
||||
GLOBAL_ASYNC_QUERIES_JWT_COOKIE_DOMAIN = None
|
||||
GLOBAL_ASYNC_QUERIES_JWT_SECRET = "test-secret-change-me" # noqa: S105
|
||||
GLOBAL_ASYNC_QUERIES_JWT_SECRET = CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET
|
||||
# Lifetime of the async-query JWT, in seconds. After this period the token
|
||||
# expires and a fresh one is issued on the next request.
|
||||
GLOBAL_ASYNC_QUERIES_JWT_EXPIRATION_SECONDS = int(timedelta(hours=1).total_seconds())
|
||||
|
||||
@@ -29,6 +29,7 @@ EMPTY_STRING = "<empty string>"
|
||||
|
||||
CHANGE_ME_SECRET_KEY = "CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET" # noqa: S105
|
||||
CHANGE_ME_GUEST_TOKEN_JWT_SECRET = "test-guest-secret-change-me" # noqa: S105
|
||||
CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET = "test-secret-change-me" # noqa: S105
|
||||
|
||||
SKIP_VISIBILITY_FILTER_CLASSES = "_skip_visibility_filter_classes"
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from typing import (
|
||||
)
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import current_app
|
||||
from flask_appbuilder.models.filters import BaseFilter
|
||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -772,7 +773,19 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
|
||||
else:
|
||||
query = query.order_by(asc(column))
|
||||
page = page
|
||||
page_size = max(page_size, 1)
|
||||
# Clamp the page size to a sane range: at least 1, and no larger than
|
||||
# the configured upper bound, to keep result sets bounded.
|
||||
# Normalize the configured maximum to a positive integer so that a
|
||||
# misconfigured value (non-int or <= 0) cannot produce a non-positive
|
||||
# page size, which would break pagination or yield unbounded queries.
|
||||
try:
|
||||
max_page_size = int(
|
||||
current_app.config.get("SQLALCHEMY_DAO_MAX_PAGE_SIZE", 1000)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
max_page_size = 1000
|
||||
max_page_size = max(max_page_size, 1)
|
||||
page_size = min(max(page_size, 1), max_page_size)
|
||||
query = query.offset(page * page_size).limit(page_size)
|
||||
items = query.all()
|
||||
# If columns are specified, SQLAlchemy returns Row objects (not tuples or
|
||||
|
||||
@@ -15,34 +15,41 @@
|
||||
# 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.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",
|
||||
]
|
||||
|
||||
@expose("/_info", methods=("GET",))
|
||||
@protect()
|
||||
@@ -72,13 +79,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 +165,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)
|
||||
@@ -210,7 +218,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,7 +37,11 @@ from flask_compress import Compress
|
||||
from flask_session import Session
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from superset.constants import CHANGE_ME_GUEST_TOKEN_JWT_SECRET, CHANGE_ME_SECRET_KEY
|
||||
from superset.constants import (
|
||||
CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET,
|
||||
CHANGE_ME_GUEST_TOKEN_JWT_SECRET,
|
||||
CHANGE_ME_SECRET_KEY,
|
||||
)
|
||||
from superset.databases.utils import make_url_safe
|
||||
from superset.extensions import (
|
||||
_event_logger,
|
||||
@@ -173,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
|
||||
@@ -414,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",
|
||||
@@ -691,6 +683,32 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
def check_async_query_secret(self) -> None:
|
||||
"""Refuse to start with the default async JWT secret when GAQ is enabled."""
|
||||
if not feature_flag_manager.is_feature_enabled("GLOBAL_ASYNC_QUERIES"):
|
||||
return
|
||||
if (
|
||||
self.config.get("GLOBAL_ASYNC_QUERIES_JWT_SECRET")
|
||||
!= CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET
|
||||
):
|
||||
return
|
||||
self._log_config_warning(
|
||||
"GLOBAL_ASYNC_QUERIES is enabled but GLOBAL_ASYNC_QUERIES_JWT_SECRET "
|
||||
"has not been changed from its default value.\n"
|
||||
"The default value is publicly known and must be replaced before "
|
||||
"running in production.\n"
|
||||
"Set a strong random value (at least 32 bytes) in superset_config.py:\n"
|
||||
" GLOBAL_ASYNC_QUERIES_JWT_SECRET = "
|
||||
"'<output of: openssl rand -base64 42>'"
|
||||
)
|
||||
if self.superset_app.debug or self.superset_app.config["TESTING"] or is_test():
|
||||
return
|
||||
logger.error(
|
||||
"Refusing to start: insecure GLOBAL_ASYNC_QUERIES_JWT_SECRET "
|
||||
"with GLOBAL_ASYNC_QUERIES enabled"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
def configure_session(self) -> None:
|
||||
if self.config["SESSION_SERVER_SIDE"]:
|
||||
Session(self.superset_app)
|
||||
@@ -776,6 +794,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
# conditionally
|
||||
self.configure_feature_flags()
|
||||
self.check_guest_token_secret()
|
||||
self.check_async_query_secret()
|
||||
self.configure_db_encrypt()
|
||||
self.setup_db()
|
||||
|
||||
@@ -1011,6 +1030,16 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
|
||||
def configure_async_queries(self) -> None:
|
||||
if feature_flag_manager.is_feature_enabled("GLOBAL_ASYNC_QUERIES"):
|
||||
# In production, check_async_query_secret() already aborts startup when
|
||||
# the default secret is present, so this branch is never reached with it.
|
||||
# In debug/testing the check only warns, so skip async-query init here to
|
||||
# avoid AsyncQueryManager.init_app() hard-failing on the too-short default
|
||||
# secret and crashing startup despite the warn-only intent.
|
||||
if (
|
||||
self.config.get("GLOBAL_ASYNC_QUERIES_JWT_SECRET")
|
||||
== CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET
|
||||
):
|
||||
return
|
||||
async_query_manager_factory.init_app(self.superset_app)
|
||||
|
||||
def configure_task_manager(self) -> None:
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
@@ -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.
|
||||
"""Drop extension_settings table (ExtensionSettings model removed in chatbot SIP).
|
||||
|
||||
The active chatbot is now resolved purely from the view registry (last-loaded
|
||||
wins), so the admin-pin settings table is no longer read or written.
|
||||
|
||||
Revision ID: e2f3a4b5c6d7
|
||||
Revises: d1e2f3a4b5c6
|
||||
Create Date: 2026-06-10 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from superset.migrations.shared.utils import create_table, drop_table
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "e2f3a4b5c6d7"
|
||||
down_revision = "d1e2f3a4b5c6"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
drop_table("extension_settings")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
create_table(
|
||||
"extension_settings",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("active_chatbot_id", sa.String(250), nullable=True),
|
||||
)
|
||||
@@ -596,6 +596,21 @@ class TabState(AuditMixinNullable, ExtraJSONMixin, Model):
|
||||
saved_query = relationship("SavedQuery", foreign_keys=[saved_query_id])
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
latest_query = None
|
||||
try:
|
||||
if self.latest_query:
|
||||
latest_query = self.latest_query.to_dict()
|
||||
except Exception:
|
||||
query = self.__dict__.get("latest_query")
|
||||
logger.warning(
|
||||
"Failed to load/serialize latest_query for tab state %s "
|
||||
"(latest_query_id=%s, query_status=%s)",
|
||||
self.id,
|
||||
self.latest_query_id,
|
||||
getattr(query, "status", "N/A"),
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": self.id,
|
||||
"user_id": self.user_id,
|
||||
@@ -607,7 +622,7 @@ class TabState(AuditMixinNullable, ExtraJSONMixin, Model):
|
||||
"table_schemas": [ts.to_dict() for ts in self.table_schemas],
|
||||
"sql": self.sql,
|
||||
"query_limit": self.query_limit,
|
||||
"latest_query": self.latest_query.to_dict() if self.latest_query else None,
|
||||
"latest_query": latest_query,
|
||||
"autorun": self.autorun,
|
||||
"template_params": self.template_params,
|
||||
"hide_left_bar": self.hide_left_bar,
|
||||
|
||||
@@ -271,6 +271,19 @@ def execute_query( # pylint: disable=too-many-statements, too-many-locals # no
|
||||
log_params,
|
||||
)
|
||||
db.session.commit()
|
||||
# Eagerly reload query attributes so no lazy-load triggers a new
|
||||
# metadata DB connection during the (potentially long) cursor
|
||||
# execution. With NullPool each lazy-load opens a fresh connection
|
||||
# that stays idle for the query duration; if the query runs longer
|
||||
# than the DB's idle_in_transaction_session_timeout the connection
|
||||
# is killed, leaving the query stuck in "running" state forever.
|
||||
db.session.expire_on_commit = False
|
||||
try:
|
||||
db.session.refresh(query)
|
||||
_ = query.database
|
||||
db.session.commit()
|
||||
finally:
|
||||
db.session.expire_on_commit = True
|
||||
with event_logger.log_context(
|
||||
action="execute_sql",
|
||||
database=database,
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
import io
|
||||
import tempfile
|
||||
from typing import Any
|
||||
from zipfile import ZipFile
|
||||
from unittest.mock import patch
|
||||
from zipfile import ZIP_DEFLATED, ZipFile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from flask import current_app
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
from superset.commands.database.exceptions import DatabaseUploadFailed
|
||||
@@ -230,6 +232,87 @@ def test_columnar_reader_bad_zip():
|
||||
assert str(ex.value) == "Not a valid ZIP file"
|
||||
|
||||
|
||||
def _make_high_ratio_zip() -> io.BytesIO:
|
||||
"""
|
||||
Build a ZIP whose single entry has a very high decompression ratio,
|
||||
well above the default ``ZIP_FILE_MAX_COMPRESS_RATIO`` threshold.
|
||||
"""
|
||||
buffer = io.BytesIO()
|
||||
with ZipFile(buffer, "w", ZIP_DEFLATED) as zip_file:
|
||||
# A megabyte of zeros compresses to roughly a kilobyte, far exceeding
|
||||
# the default 200:1 ratio guard.
|
||||
zip_file.writestr("test.parquet", b"\x00" * (1024 * 1024))
|
||||
buffer.seek(0)
|
||||
return buffer
|
||||
|
||||
|
||||
def test_columnar_reader_unsafe_zip_rejected():
|
||||
reader = ColumnarReader(
|
||||
options=ColumnarReaderOptions(),
|
||||
)
|
||||
unsafe_zip = _make_high_ratio_zip()
|
||||
with pytest.raises(DatabaseUploadFailed) as ex:
|
||||
reader.file_to_dataframe(FileStorage(unsafe_zip, "test.zip"))
|
||||
assert "compress ratio above allowed threshold" in str(ex.value)
|
||||
|
||||
|
||||
def test_columnar_reader_unsafe_zip_rejected_in_metadata():
|
||||
reader = ColumnarReader(
|
||||
options=ColumnarReaderOptions(),
|
||||
)
|
||||
unsafe_zip = _make_high_ratio_zip()
|
||||
with pytest.raises(DatabaseUploadFailed) as ex:
|
||||
reader.file_metadata(FileStorage(unsafe_zip, "test.zip"))
|
||||
assert "compress ratio above allowed threshold" in str(ex.value)
|
||||
|
||||
|
||||
def test_columnar_reader_oversize_file_rejected():
|
||||
reader = ColumnarReader(
|
||||
options=ColumnarReaderOptions(),
|
||||
)
|
||||
file = create_columnar_file(COLUMNAR_DATA)
|
||||
file.stream.seek(0, 2)
|
||||
file_size = file.stream.tell()
|
||||
file.stream.seek(0)
|
||||
with patch.dict(
|
||||
current_app.config,
|
||||
{"UPLOAD_MAX_FILE_SIZE_BYTES": file_size - 1},
|
||||
):
|
||||
with pytest.raises(DatabaseUploadFailed) as ex:
|
||||
reader.file_to_dataframe(file)
|
||||
assert "exceeds the maximum allowed upload size" in str(ex.value)
|
||||
|
||||
|
||||
def test_columnar_reader_oversize_file_rejected_in_metadata():
|
||||
reader = ColumnarReader(
|
||||
options=ColumnarReaderOptions(),
|
||||
)
|
||||
file = create_columnar_file(COLUMNAR_DATA)
|
||||
file.stream.seek(0, 2)
|
||||
file_size = file.stream.tell()
|
||||
file.stream.seek(0)
|
||||
with patch.dict(
|
||||
current_app.config,
|
||||
{"UPLOAD_MAX_FILE_SIZE_BYTES": file_size - 1},
|
||||
):
|
||||
with pytest.raises(DatabaseUploadFailed) as ex:
|
||||
reader.file_metadata(file)
|
||||
assert "exceeds the maximum allowed upload size" in str(ex.value)
|
||||
|
||||
|
||||
def test_columnar_reader_under_limit_accepted():
|
||||
reader = ColumnarReader(
|
||||
options=ColumnarReaderOptions(),
|
||||
)
|
||||
file = create_columnar_file(COLUMNAR_DATA)
|
||||
with patch.dict(
|
||||
current_app.config,
|
||||
{"UPLOAD_MAX_FILE_SIZE_BYTES": 100 * 1024 * 1024},
|
||||
):
|
||||
df = reader.file_to_dataframe(file)
|
||||
assert len(df) == 3
|
||||
|
||||
|
||||
def test_columnar_reader_metadata():
|
||||
reader = ColumnarReader(
|
||||
options=ColumnarReaderOptions(),
|
||||
|
||||
@@ -258,3 +258,54 @@ def test_find_by_ids_none_id_column():
|
||||
results = TestDAO.find_by_ids([1, 2, 3])
|
||||
|
||||
assert results == []
|
||||
|
||||
|
||||
def _list_with_page_size(page_size: int) -> Mock:
|
||||
"""
|
||||
Run ``BaseDAO.list`` with a mocked query chain and return the mock query so
|
||||
the ``.limit()`` call (the effective page size) can be inspected.
|
||||
"""
|
||||
mock_query = Mock()
|
||||
# Every chainable call returns the same mock so the chain is easy to inspect
|
||||
mock_query.options.return_value = mock_query
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.order_by.return_value = mock_query
|
||||
mock_query.offset.return_value = mock_query
|
||||
mock_query.limit.return_value = mock_query
|
||||
mock_query.count.return_value = 0
|
||||
mock_query.all.return_value = []
|
||||
|
||||
mock_data_model = Mock()
|
||||
mock_data_model.session.query.return_value = mock_query
|
||||
|
||||
with (
|
||||
patch("superset.daos.base.SQLAInterface", return_value=mock_data_model),
|
||||
patch.object(TestDAO, "_apply_base_filter", side_effect=lambda q, **_: q),
|
||||
):
|
||||
TestDAO.list(page=0, page_size=page_size)
|
||||
|
||||
return mock_query
|
||||
|
||||
|
||||
def test_list_page_size_oversized_is_clamped():
|
||||
"""An oversized page_size is clamped to the configured maximum."""
|
||||
from flask import current_app
|
||||
|
||||
max_page_size = current_app.config.get("SQLALCHEMY_DAO_MAX_PAGE_SIZE", 1000)
|
||||
mock_query = _list_with_page_size(max_page_size + 5000)
|
||||
|
||||
mock_query.limit.assert_called_once_with(max_page_size)
|
||||
|
||||
|
||||
def test_list_page_size_normal_unaffected():
|
||||
"""A page_size within the allowed range is passed through unchanged."""
|
||||
mock_query = _list_with_page_size(50)
|
||||
|
||||
mock_query.limit.assert_called_once_with(50)
|
||||
|
||||
|
||||
def test_list_page_size_below_one_is_floored():
|
||||
"""A non-positive page_size is floored to 1 (existing semantics)."""
|
||||
mock_query = _list_with_page_size(0)
|
||||
|
||||
mock_query.limit.assert_called_once_with(1)
|
||||
|
||||
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
|
||||
@@ -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"]
|
||||
|
||||
16
tests/unit_tests/initialization/__init__.py
Normal file
16
tests/unit_tests/initialization/__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.
|
||||
140
tests/unit_tests/initialization/check_async_query_secret_test.py
Normal file
140
tests/unit_tests/initialization/check_async_query_secret_test.py
Normal file
@@ -0,0 +1,140 @@
|
||||
# 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 default async JWT secret startup check."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.constants import CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET
|
||||
from superset.initialization import SupersetAppInitializer
|
||||
|
||||
|
||||
def _make_initializer(
|
||||
secret: str,
|
||||
*,
|
||||
debug: bool = False,
|
||||
testing: bool = False,
|
||||
) -> SupersetAppInitializer:
|
||||
initializer = SupersetAppInitializer.__new__(SupersetAppInitializer)
|
||||
app = MagicMock()
|
||||
app.debug = debug
|
||||
app.config = {
|
||||
"GLOBAL_ASYNC_QUERIES_JWT_SECRET": secret,
|
||||
"TESTING": testing,
|
||||
}
|
||||
initializer.superset_app = app
|
||||
initializer.config = app.config
|
||||
return initializer
|
||||
|
||||
|
||||
def test_check_async_query_secret_rejects_default_in_production(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A default async secret with GAQ enabled refuses to start in production."""
|
||||
mocker.patch(
|
||||
"superset.initialization.feature_flag_manager.is_feature_enabled",
|
||||
return_value=True,
|
||||
)
|
||||
mocker.patch("superset.initialization.is_test", return_value=False)
|
||||
initializer = _make_initializer(CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET)
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
initializer.check_async_query_secret()
|
||||
|
||||
|
||||
def test_check_async_query_secret_allows_overridden_secret(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-default async secret does not block startup."""
|
||||
mocker.patch(
|
||||
"superset.initialization.feature_flag_manager.is_feature_enabled",
|
||||
return_value=True,
|
||||
)
|
||||
mocker.patch("superset.initialization.is_test", return_value=False)
|
||||
initializer = _make_initializer("a-strong-random-secret-value-1234567890")
|
||||
|
||||
# Should not raise.
|
||||
initializer.check_async_query_secret()
|
||||
|
||||
|
||||
def test_check_async_query_secret_skipped_when_gaq_disabled(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""The check is a no-op when GLOBAL_ASYNC_QUERIES is disabled."""
|
||||
mocker.patch(
|
||||
"superset.initialization.feature_flag_manager.is_feature_enabled",
|
||||
return_value=False,
|
||||
)
|
||||
mocker.patch("superset.initialization.is_test", return_value=False)
|
||||
initializer = _make_initializer(CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET)
|
||||
|
||||
# Should not raise even with the default secret.
|
||||
initializer.check_async_query_secret()
|
||||
|
||||
|
||||
def test_check_async_query_secret_warns_only_in_debug(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""In debug the default secret warns but does not exit (matches SECRET_KEY)."""
|
||||
mocker.patch(
|
||||
"superset.initialization.feature_flag_manager.is_feature_enabled",
|
||||
return_value=True,
|
||||
)
|
||||
mocker.patch("superset.initialization.is_test", return_value=False)
|
||||
initializer = _make_initializer(
|
||||
CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET, debug=True
|
||||
)
|
||||
|
||||
# Should not raise in debug mode.
|
||||
initializer.check_async_query_secret()
|
||||
|
||||
|
||||
def test_configure_async_queries_skips_init_with_default_secret(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""In warn-only modes, async init is skipped so the short default secret cannot
|
||||
crash startup via AsyncQueryManager.init_app()'s length check."""
|
||||
mocker.patch(
|
||||
"superset.initialization.feature_flag_manager.is_feature_enabled",
|
||||
return_value=True,
|
||||
)
|
||||
factory = mocker.patch("superset.initialization.async_query_manager_factory")
|
||||
initializer = _make_initializer(
|
||||
CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET, debug=True
|
||||
)
|
||||
|
||||
initializer.configure_async_queries()
|
||||
|
||||
factory.init_app.assert_not_called()
|
||||
|
||||
|
||||
def test_configure_async_queries_inits_with_overridden_secret(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A non-default secret proceeds to initialize the async query manager."""
|
||||
mocker.patch(
|
||||
"superset.initialization.feature_flag_manager.is_feature_enabled",
|
||||
return_value=True,
|
||||
)
|
||||
factory = mocker.patch("superset.initialization.async_query_manager_factory")
|
||||
initializer = _make_initializer("a-strong-random-secret-value-1234567890")
|
||||
|
||||
initializer.configure_async_queries()
|
||||
|
||||
factory.init_app.assert_called_once_with(initializer.superset_app)
|
||||
@@ -57,6 +57,9 @@ def test_execute_query(mocker: MockerFixture, app: None) -> None:
|
||||
cursor = mocker.MagicMock()
|
||||
SupersetResultSet = mocker.patch("superset.sql_lab.SupersetResultSet") # noqa: N806
|
||||
|
||||
# Mock db.session.refresh to avoid AttributeError during session refresh
|
||||
mocker.patch("superset.sql_lab.db.session.refresh", return_value=None)
|
||||
|
||||
execute_query(query, cursor=cursor, log_params={})
|
||||
|
||||
db_engine_spec.execute_with_cursor.assert_called_with(
|
||||
|
||||
Reference in New Issue
Block a user